diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index cf214cd69..da66d8d0c 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
- "version": "2.14.2",
+ "version": "2.14.3",
"commands": [
"ModernUOSchemaGenerator"
]
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index bf1100d9d..87309b849 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -14,10 +14,10 @@ jobs:
fail-fast: false
matrix:
include:
- - os: macos-14
- name: MacOS 14
- os: macos-15
name: MacOS 15
+ - os: macos-26
+ name: MacOS 26
steps:
- uses: actions/checkout@v4
@@ -34,7 +34,7 @@ jobs:
- name: Set Library Path
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:\$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
- name: Build
- run: ./publish.cmd Release
+ run: dotnet run --project Projects/BuildTool -- --config Release --skip-prereqs
- name: Migration Changes
run: git diff --exit-code ./**/Migrations/*.v*.json
- name: Test
@@ -90,6 +90,6 @@ jobs:
with:
global-json-file: global.json
- name: Build
- run: ./publish.cmd Release
+ run: dotnet run --project Projects/BuildTool -- --config Release --skip-prereqs
- name: Test
run: dotnet test --no-restore
diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml
new file mode 100644
index 000000000..1353f7607
--- /dev/null
+++ b/.github/workflows/build-tool-release.yml
@@ -0,0 +1,91 @@
+name: Build Tool Release
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'Projects/BuildTool/**'
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ name: Build (${{ matrix.rid }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ rid: win-x64
+ artifact: build-tool-win-x64.exe
+ - os: windows-latest
+ rid: win-arm64
+ artifact: build-tool-win-arm64.exe
+ - os: macos-15
+ rid: osx-arm64
+ artifact: build-tool-osx-arm64
+ - os: ubuntu-latest
+ rid: linux-x64
+ artifact: build-tool-linux-x64
+ - os: ubuntu-24.04-arm
+ rid: linux-arm64
+ artifact: build-tool-linux-arm64
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Publish NativeAOT
+ run: dotnet publish Projects/BuildTool/BuildTool.csproj -c Release -r ${{ matrix.rid }} -o publish/
+
+ - name: Rename artifact (Unix)
+ if: runner.os != 'Windows'
+ run: mv publish/build-tool publish/${{ matrix.artifact }}
+
+ - name: Rename artifact (Windows)
+ if: runner.os == 'Windows'
+ run: mv publish/build-tool.exe publish/${{ matrix.artifact }}
+ shell: bash
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: publish/${{ matrix.artifact }}
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: github.ref == 'refs/heads/main'
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts/
+ merge-multiple: true
+
+ - name: Generate checksums
+ run: |
+ cd artifacts
+ sha256sum build-tool-* > checksums-sha256.txt
+
+ - name: Create or update release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: build-tool-latest
+ name: Build Tool (Latest)
+ body: |
+ Latest NativeAOT-compiled build tool binaries.
+ These are automatically downloaded by `publish.cmd` / `publish.sh`.
+ prerelease: true
+ files: |
+ artifacts/build-tool-*
+ artifacts/checksums-sha256.txt
+ make_latest: false
diff --git a/.gitignore b/.gitignore
index 169cf5b30..7bb942e52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,6 @@
/packages/*
/Distribution/Configuration/server-access.json
+
+# BuildTool native binaries (downloaded from GitHub Releases)
+/tools/
diff --git a/ModernUO.sln b/ModernUO.sln
deleted file mode 100644
index 584626227..000000000
--- a/ModernUO.sln
+++ /dev/null
@@ -1,67 +0,0 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 18
-VisualStudioVersion = 18.0.11222.15 d18.0
-MinimumVisualStudioVersion = 18.0.11222.15
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server", "Projects\Server\Server.csproj", "{5E93BB35-3661-4822-9A8A-859726BAD87F}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UOContent", "Projects\UOContent\UOContent.csproj", "{83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server.Tests", "Projects\Server.Tests\Server.Tests.csproj", "{D7A5D3AF-D607-46EF-BAAD-0D424190311F}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UOContent.Tests", "Projects\UOContent.Tests\UOContent.Tests.csproj", "{3C4797F9-603E-44EF-8E8C-9275CC9EA74B}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Projects\Application\Application.csproj", "{E9849FA1-D4F5-4D68-A36B-249F4CB4E374}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logger", "Projects\Logger\Logger.csproj", "{ECAD3793-A7C5-4546-AA88-77DD24574410}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Analyze|Any CPU = Analyze|Any CPU
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5E93BB35-3661-4822-9A8A-859726BAD87F}.Release|Any CPU.Build.0 = Release|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {83CF2484-BCCB-4B7C-9C5F-7AB43AEA5E8F}.Release|Any CPU.Build.0 = Release|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D7A5D3AF-D607-46EF-BAAD-0D424190311F}.Release|Any CPU.Build.0 = Release|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3C4797F9-603E-44EF-8E8C-9275CC9EA74B}.Release|Any CPU.Build.0 = Release|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {E9849FA1-D4F5-4D68-A36B-249F4CB4E374}.Release|Any CPU.Build.0 = Release|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Analyze|Any CPU.Build.0 = Analyze|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {ECAD3793-A7C5-4546-AA88-77DD24574410}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {8F69EF13-E193-40B9-BA9F-4862A9F0C038}
- EndGlobalSection
-EndGlobal
diff --git a/ModernUO.slnx b/ModernUO.slnx
new file mode 100644
index 000000000..5697dface
--- /dev/null
+++ b/ModernUO.slnx
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Projects/BuildTool/Branding.cs b/Projects/BuildTool/Branding.cs
new file mode 100644
index 000000000..8764b20bf
--- /dev/null
+++ b/Projects/BuildTool/Branding.cs
@@ -0,0 +1,55 @@
+using Spectre.Console;
+
+namespace BuildTool;
+
+///
+/// Static branding assets for the BuildTool CLI.
+/// Colors sourced from ModernUO brand palette (docs/packets/template.html, docs/branding/logo.svg).
+///
+public static class Branding
+{
+ // ModernUO brand gold palette (from the "U" in the logo)
+ public static readonly Color Gold = new(213, 191, 116); // #d5bf74 - primary brand gold
+ public static readonly Color GoldLight = new(223, 198, 136); // #dfc688 - interactive elements
+ public static readonly Color GoldMuted = new(232, 206, 161); // #e8cea1 - body text
+ public static readonly Color GoldDim = new(162, 145, 71); // #a29147 - dimmed gold
+
+ // Silver palette (from the "O" in the logo)
+ public static readonly Color Silver = new(223, 223, 221); // #dfdfdd - primary silver
+ public static readonly Color SilverDim = new(150, 150, 148); // #969694 - dimmed silver
+
+ // Functional colors
+ public static readonly Color Success = new(34, 197, 94); // #22c55e - green
+ public static readonly Color Info = new(59, 130, 246); // #3b82f6 - blue
+
+ // Styles
+ public static readonly Style GoldStyle = new(Gold);
+ public static readonly Style SilverStyle = new(Silver);
+ public static readonly Style HighlightStyle = new(GoldLight);
+ public static readonly Style DimGoldStyle = new(GoldDim);
+
+ ///
+ /// The iconic ModernUO circular UO monogram with true-color ANSI escape codes.
+ /// Converted from the brand PNG (docs/branding/android-chrome-512x512.png).
+ /// Gold U on the left, silver O on the right, rendered with per-character RGB colors.
+ /// Output with Console.Write() to bypass Spectre markup parsing.
+ ///
+ public const string UOLogoAnsi =
+ " \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;19;17;10m \e[0m\e[38;2;56;49;31m.\e[0m\e[38;2;46;41;24m.\e[0m\e[38;2;86;86;87m-\e[0m\e[38;2;134;133;131m+\e[0m\e[38;2;136;135;134m+\e[0m\e[38;2;133;132;131m+\e[0m\e[38;2;119;118;117m=\e[0m\e[38;2;87;86;85m-\e[0m\e[38;2;52;52;51m:\e[0m\e[38;2;20;20;20m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;19;17;11m \e[0m\e[38;2;97;87;52m-\e[0m\e[38;2;163;145;87m+\e[0m\e[38;2;184;164;101m*\e[0m\e[38;2;202;179;111m*\e[0m\e[38;2;135;121;74m=\e[0m\e[38;2;96;97;101m-\e[0m\e[38;2;134;134;131m+\e[0m\e[38;2;124;123;123m=\e[0m\e[38;2;131;131;130m+\e[0m\e[38;2;144;143;143m+\e[0m\e[38;2;197;196;195m#\e[0m\e[38;2;211;210;209m%\e[0m\e[38;2;195;194;193m#\e[0m\e[38;2;169;168;167m*\e[0m\e[38;2;99;99;98m-\e[0m\e[38;2;19;18;18m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;6;6;4m \e[0m\e[38;2;120;109;64m=\e[0m\e[38;2;228;204;120m#\e[0m\e[38;2;218;196;118m#\e[0m\e[38;2;180;163;101m*\e[0m\e[38;2;229;206;122m%\e[0m\e[38;2;231;207;122m%\e[0m\e[38;2;145;130;80m+\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;139;140;139m+\e[0m\e[38;2;236;237;236m@\e[0m\e[38;2;234;234;234m@\e[0m\e[38;2;183;183;182m#\e[0m\e[38;2;222;222;222m%\e[0m\e[38;2;231;231;231m@\e[0m\e[38;2;120;120;119m=\e[0m\e[38;2;6;6;6m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;18;17;11m \e[0m\e[38;2;180;162;93m*\e[0m\e[38;2;241;216;123m%\e[0m\e[38;2;172;156;94m*\e[0m\e[38;2;22;21;15m \e[0m\e[38;2;68;63;41m:\e[0m\e[38;2;240;216;123m%\e[0m\e[38;2;244;219;125m%\e[0m\e[38;2;150;135;80m+\e[0m\e[38;2;2;2;1m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;3;3;3m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;148;148;148m+\e[0m\e[38;2;244;245;245m@\e[0m\e[38;2;240;241;241m@\e[0m\e[38;2;66;66;66m:\e[0m\e[38;2;21;21;21m \e[0m\e[38;2;171;172;171m*\e[0m\e[38;2;241;242;242m@\e[0m\e[38;2;179;179;179m#\e[0m\e[38;2;17;17;17m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;1;1;2m \e[0m\e[38;2;136;120;70m=\e[0m\e[38;2;207;180;98m#\e[0m\e[38;2;146;130;76m+\e[0m\e[38;2;0;1;4m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;76;68;42m:\e[0m\e[38;2;204;178;98m*\e[0m\e[38;2;204;177;97m*\e[0m\e[38;2;135;119;70m=\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;134;135;134m+\e[0m\e[38;2;208;209;209m%\e[0m\e[38;2;207;208;207m%\e[0m\e[38;2;75;75;75m:\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;146;147;146m+\e[0m\e[38;2;211;212;212m%\e[0m\e[38;2;135;135;135m+\e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;33;30;21m.\e[0m\e[38;2;161;134;70m+\e[0m\e[38;2;161;133;69m+\e[0m\e[38;2;51;46;30m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;67;59;37m:\e[0m\e[38;2;162;134;69m+\e[0m\e[38;2;158;129;65m+\e[0m\e[38;2;118;102;58m=\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;118;118;118m=\e[0m\e[38;2;165;166;166m*\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;66;66;66m:\e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;50;50;50m.\e[0m\e[38;2;166;167;167m*\e[0m\e[38;2;165;166;165m*\e[0m\e[38;2;32;32;31m.\e[0m\n" +
+ " \e[38;2;35;32;23m.\e[0m\e[38;2;154;128;62m+\e[0m\e[38;2;154;127;61m+\e[0m\e[38;2;49;44;29m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;66;59;36m:\e[0m\e[38;2;154;126;61m+\e[0m\e[38;2;150;121;56m=\e[0m\e[38;2;112;96;54m-\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;3;2;0m \e[0m\e[38;2;3;3;1m \e[0m\e[38;2;2;2;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;118;119;118m=\e[0m\e[38;2;165;165;165m*\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;67;67;66m:\e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;48;48;47m.\e[0m\e[38;2;167;168;168m*\e[0m\e[38;2;166;167;166m*\e[0m\e[38;2;33;33;33m.\e[0m\n" +
+ " \e[38;2;3;3;5m \e[0m\e[38;2;130;114;62m=\e[0m\e[38;2;181;154;68m+\e[0m\e[38;2;132;115;59m=\e[0m\e[38;2;0;0;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;46;42;28m.\e[0m\e[38;2;184;159;76m*\e[0m\e[38;2;178;152;66m+\e[0m\e[38;2;151;132;66m+\e[0m\e[38;2;13;12;9m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;132;132;132m+\e[0m\e[38;2;212;213;213m%\e[0m\e[38;2;208;208;208m%\e[0m\e[38;2;50;50;49m.\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;146;146;146m+\e[0m\e[38;2;213;213;213m%\e[0m\e[38;2;139;139;139m+\e[0m\e[38;2;1;1;1m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;22;21;16m \e[0m\e[38;2;161;142;72m+\e[0m\e[38;2;197;172;77m*\e[0m\e[38;2;138;122;60m=\e[0m\e[38;2;24;22;13m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;89;80;46m-\e[0m\e[38;2;185;162;76m*\e[0m\e[38;2;195;171;77m*\e[0m\e[38;2;192;168;78m*\e[0m\e[38;2;151;132;62m+\e[0m\e[38;2;136;119;56m=\e[0m\e[38;2;160;140;67m+\e[0m\e[38;2;115;100;48m-\e[0m\e[38;2;143;145;150m+\e[0m\e[38;2;220;219;218m%\e[0m\e[38;2;92;92;92m-\e[0m\e[38;2;0;0;0m \e[0m\e[38;2;24;24;24m \e[0m\e[38;2;155;155;155m*\e[0m\e[38;2;232;232;232m@\e[0m\e[38;2;180;180;180m#\e[0m\e[38;2;21;21;21m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;11;11;11m \e[0m\e[38;2;109;97;54m-\e[0m\e[38;2;183;159;79m*\e[0m\e[38;2;181;157;78m*\e[0m\e[38;2;109;94;48m-\e[0m\e[38;2;36;32;19m.\e[0m\e[38;2;33;31;21m.\e[0m\e[38;2;68;62;36m:\e[0m\e[38;2;102;91;48m-\e[0m\e[38;2;126;111;57m=\e[0m\e[38;2;131;116;59m=\e[0m\e[38;2;127;113;58m=\e[0m\e[38;2;71;64;36m:\e[0m\e[38;2;37;37;40m.\e[0m\e[38;2;39;39;38m.\e[0m\e[38;2;38;38;38m.\e[0m\e[38;2;122;122;121m=\e[0m\e[38;2;204;204;204m%\e[0m\e[38;2;208;208;208m%\e[0m\e[38;2;116;116;115m=\e[0m\e[38;2;9;9;9m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;1;1;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;24;23;17m \e[0m\e[38;2;88;77;46m-\e[0m\e[38;2;142;123;67m=\e[0m\e[38;2;161;138;75m+\e[0m\e[38;2;143;121;65m=\e[0m\e[38;2;115;97;54m-\e[0m\e[38;2;95;80;46m-\e[0m\e[38;2;83;71;42m:\e[0m\e[38;2;79;67;40m:\e[0m\e[38;2;86;74;44m:\e[0m\e[38;2;62;54;30m:\e[0m\e[38;2;87;88;89m-\e[0m\e[38;2;160;160;159m*\e[0m\e[38;2;178;178;178m#\e[0m\e[38;2;156;156;156m*\e[0m\e[38;2;94;94;94m-\e[0m\e[38;2;22;22;22m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;1;1;1m \e[0m\e[38;2;0;0;0m \e[0m\n" +
+ " \e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;1;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;1m \e[0m\e[38;2;25;22;17m \e[0m\e[38;2;58;51;34m:\e[0m\e[38;2;87;75;47m:\e[0m\e[38;2;105;90;56m-\e[0m\e[38;2;115;98;60m-\e[0m\e[38;2;117;99;60m-\e[0m\e[38;2;115;99;60m-\e[0m\e[38;2;76;67;41m:\e[0m\e[38;2;57;58;60m:\e[0m\e[38;2;66;65;65m:\e[0m\e[38;2;23;23;23m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;2;2;2m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\e[38;2;0;0;0m \e[0m\n";
+
+ ///
+ /// Subtitle shown below the banner.
+ ///
+ public const string Subtitle = "Ultima Online Server Emulator";
+}
diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs
new file mode 100644
index 000000000..9aea5010d
--- /dev/null
+++ b/Projects/BuildTool/BuildOptions.cs
@@ -0,0 +1,17 @@
+namespace BuildTool;
+
+public enum BuildAction
+{
+ Publish,
+ Migrate
+}
+
+public sealed class BuildOptions
+{
+ public BuildAction Action { get; set; } = BuildAction.Publish;
+ public string Config { get; set; } = "Release";
+ public string? Os { get; set; }
+ public string? Arch { get; set; }
+ public bool SkipPrereqs { get; set; }
+ public bool Interactive { get; set; }
+}
diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj
new file mode 100644
index 000000000..8ac070629
--- /dev/null
+++ b/Projects/BuildTool/BuildTool.csproj
@@ -0,0 +1,20 @@
+
+
+ Exe
+ net10.0
+ Preview
+ false
+ BuildTool
+ build-tool
+ enable
+ true
+ true
+ link
+ true
+ win-x64;win-arm64;osx-x64;osx-arm64;linux-x64;linux-arm64
+ true
+
+
+
+
+
diff --git a/Projects/BuildTool/Interactive/CancellationTracker.cs b/Projects/BuildTool/Interactive/CancellationTracker.cs
new file mode 100644
index 000000000..21bb2a1c0
--- /dev/null
+++ b/Projects/BuildTool/Interactive/CancellationTracker.cs
@@ -0,0 +1,25 @@
+namespace BuildTool.Interactive;
+
+///
+/// Tracks Ctrl+C press timing to detect double-press for exit.
+///
+public sealed class CancellationTracker
+{
+ private DateTime _lastCancelTime = DateTime.MinValue;
+ private const int DoublePressMs = 500;
+
+ ///
+ /// Checks if this is a double-press (within 500ms of last press).
+ /// Also updates the last press time.
+ ///
+ public bool IsDoublePress()
+ {
+ var now = DateTime.UtcNow;
+ var isDouble = (now - _lastCancelTime).TotalMilliseconds < DoublePressMs;
+ _lastCancelTime = now;
+ return isDouble;
+ }
+
+ /// Global instance for app-wide tracking.
+ public static CancellationTracker Instance { get; } = new();
+}
diff --git a/Projects/BuildTool/Interactive/GuidedMode.cs b/Projects/BuildTool/Interactive/GuidedMode.cs
new file mode 100644
index 000000000..e86561d30
--- /dev/null
+++ b/Projects/BuildTool/Interactive/GuidedMode.cs
@@ -0,0 +1,365 @@
+using BuildTool.Platform;
+using BuildTool.Prerequisites;
+using BuildTool.Publishing;
+using Spectre.Console;
+
+namespace BuildTool.Interactive;
+
+///
+/// Handles interactive mode when the BuildTool is run without arguments.
+/// Provides a polished menu-driven experience with proper Ctrl+C navigation.
+/// Uses ModernUO brand gold color palette.
+///
+public static class GuidedMode
+{
+ // Brand gold as markup string for inline use
+ private const string GoldMarkup = "rgb(213,191,116)";
+ private const string GoldLightMarkup = "rgb(223,198,136)";
+
+ ///
+ /// Tracks menu depth. 0 = root menu, >0 = submenu.
+ /// Used by ShowPrompt to automatically add Back option in submenus.
+ ///
+ private static int _menuDepth;
+
+ public static int Run(PlatformInfo platform, string repoRoot)
+ {
+ return ShowMainMenu(platform, repoRoot);
+ }
+
+ ///
+ /// Shows a selection prompt with automatic Back support.
+ /// When _menuDepth > 0, appends a ":left_arrow: Back" choice.
+ /// Returns null if the user chose Back or pressed Ctrl+C in a submenu.
+ /// At the root menu, Ctrl+C requires double-press to exit (returns null on double-press).
+ ///
+ private static string? ShowPrompt(string title, params string[] choices)
+ {
+ var allChoices = choices.Where(c => !string.IsNullOrEmpty(c)).ToList();
+
+ if (_menuDepth > 0)
+ {
+ allChoices.Add(":left_arrow: Back");
+ }
+
+ var prompt = new SelectionPrompt()
+ .Title($"[{GoldMarkup}]{title}[/]")
+ .PageSize(10)
+ .HighlightStyle(Branding.HighlightStyle)
+ .AddChoices(allChoices);
+
+ string selection;
+ try
+ {
+ selection = prompt.Show(AnsiConsole.Console);
+ }
+ catch (OperationCanceledException)
+ {
+ InteractiveCancellation.Instance.Reset();
+
+ if (_menuDepth > 0)
+ {
+ return null; // Back
+ }
+
+ // Root menu: require double-press to exit
+ if (CancellationTracker.Instance.IsDoublePress())
+ {
+ AnsiConsole.MarkupLine("\n[grey]Exiting...[/]");
+ return ":cross_mark: Exit";
+ }
+
+ AnsiConsole.Write(new Text("\nPress Ctrl+C again to exit\n", Branding.DimGoldStyle));
+ Thread.Sleep(100);
+ return ""; // Signal to redraw menu
+ }
+
+ if (selection.Contains("Back"))
+ {
+ return null;
+ }
+
+ return selection;
+ }
+
+ private static int ShowMainMenu(PlatformInfo platform, string repoRoot)
+ {
+ while (true)
+ {
+ AnsiConsole.Clear();
+
+ // Write the true-color ANSI logo directly (bypasses Spectre markup parsing)
+ Console.Write(Branding.UOLogoAnsi);
+ AnsiConsole.Write(new Text($" {Branding.Subtitle}\n", Branding.DimGoldStyle));
+ AnsiConsole.WriteLine();
+
+ // Show environment info
+ var sdkVersion = GetDotNetSdkVersion();
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Branding.GoldDim)
+ .HideHeaders()
+ .AddColumn("Key")
+ .AddColumn("Value");
+
+ table.AddRow(
+ new Text("Platform", Branding.DimGoldStyle),
+ new Markup($"[white]{Markup.Escape(platform.OsName)} {platform.ArchRid}[/]")
+ );
+ table.AddRow(
+ new Text(".NET SDK", Branding.DimGoldStyle),
+ sdkVersion is not null
+ ? new Markup($"[white]{Markup.Escape(sdkVersion)}[/]")
+ : new Markup("[red]Not found[/]")
+ );
+
+ if (platform.DistroName is not null)
+ {
+ table.AddRow(
+ new Text("Distribution", Branding.DimGoldStyle),
+ new Markup($"[white]{Markup.Escape(platform.DistroName)}[/]")
+ );
+ }
+
+ if (platform.KernelVersion is not null)
+ {
+ table.AddRow(
+ new Text("Kernel", Branding.DimGoldStyle),
+ new Markup($"[white]{Markup.Escape(platform.KernelVersion)}[/]")
+ );
+ }
+
+ AnsiConsole.Write(table);
+ AnsiConsole.WriteLine();
+
+ var selection = ShowPrompt(
+ "What would you like to do?",
+ ":hammer: Publish Server",
+ ":stethoscope: Check Prerequisites",
+ "",
+ ":cross_mark: Exit"
+ );
+
+ if (string.IsNullOrEmpty(selection))
+ {
+ continue; // Redraw (single Ctrl+C)
+ }
+
+ if (selection.Contains("Publish"))
+ {
+ _menuDepth++;
+ AnsiConsole.Clear();
+ AnsiConsole.MarkupLine($"[bold {GoldMarkup}]:hammer: PUBLISH SERVER[/]\n");
+ var result = RunPublish(platform, repoRoot);
+ _menuDepth--;
+
+ if (result is not null) // null = Back, skip WaitForKey
+ {
+ WaitForKey();
+ }
+ }
+ else if (selection.Contains("Prerequisites"))
+ {
+ _menuDepth++;
+ AnsiConsole.Clear();
+ AnsiConsole.MarkupLine($"[bold {GoldMarkup}]:stethoscope: PREREQUISITE CHECK[/]\n");
+ PrerequisiteChecker.CheckAll(platform, repoRoot, interactive: true);
+ _menuDepth--;
+ WaitForKey();
+ }
+ else if (selection.Contains("Exit"))
+ {
+ return 0;
+ }
+ }
+ }
+
+ ///
+ /// Returns null if user navigated Back to main menu, 0 on success, non-zero on error.
+ /// Uses a step loop so Back goes to the previous step, not the main menu.
+ ///
+ ///
+ /// Returns null if user navigated Back to main menu, 0 on success, non-zero on error.
+ /// Uses a step loop so Back goes to the previous step, not the main menu.
+ ///
+ private static int? RunPublish(PlatformInfo platform, string repoRoot)
+ {
+ // Step 0: SDK check (always needed for building)
+ if (!PrerequisiteChecker.CheckSdk(platform, repoRoot, interactive: true))
+ {
+ return 1;
+ }
+
+ var config = "Release";
+ var rid = platform.Rid;
+ var os = platform.OsRid;
+ var arch = platform.ArchRid;
+
+ // Steps 1+: interactive prompts with back navigation
+ var step = 1;
+ while (step <= 4)
+ {
+ switch (step)
+ {
+ case 1: // Configuration
+ {
+ var choice = ShowPrompt("Configuration:", "Release (Recommended)", "Debug");
+ if (choice is null)
+ {
+ return null; // Back from first prompt = back to main menu
+ }
+
+ config = choice.Contains("Release") ? "Release" : "Debug";
+ step++;
+ break;
+ }
+ case 2: // Platform confirmation
+ {
+ var choice = ShowPrompt(
+ $"Target platform: [{GoldLightMarkup}]{platform.Rid}[/] [grey](auto-detected)[/]",
+ $":check_mark: Use {platform.Rid}",
+ ":wrench: Choose different platform"
+ );
+
+ if (choice is null)
+ {
+ step--;
+ break;
+ }
+
+ if (choice.Contains("Choose"))
+ {
+ step++;
+ }
+ else
+ {
+ rid = platform.Rid;
+ os = platform.OsRid;
+ step = 5; // Skip OS/arch selection, go to publish
+ }
+ break;
+ }
+ case 3: // OS selection (only if custom platform)
+ {
+ var choice = ShowPrompt(
+ "Operating System:",
+ "win (Windows)",
+ "osx (macOS)",
+ "linux (Linux)"
+ );
+
+ if (choice is null)
+ {
+ step--;
+ break;
+ }
+
+ os = choice.Split(' ')[0].Trim();
+ step++;
+ break;
+ }
+ case 4: // Architecture selection (only if custom platform)
+ {
+ var choice = ShowPrompt(
+ "Architecture:",
+ "x64 (Intel/AMD 64-bit)",
+ "arm64 (ARM 64-bit)"
+ );
+
+ if (choice is null)
+ {
+ step--;
+ break;
+ }
+
+ arch = choice.Split(' ')[0].Trim();
+ rid = $"{os}-{arch}";
+ step++;
+ break;
+ }
+ }
+ }
+
+ var isCrossCompile = os != platform.OsRid;
+
+ // Native library checks — only relevant when targeting the current OS
+ if (!isCrossCompile)
+ {
+ if (!PrerequisiteChecker.CheckNativeLibraries(platform, interactive: true))
+ {
+ return 1;
+ }
+ }
+
+ // Run publish
+ var exitCode = PublishOrchestrator.Run(config, rid, interactive: true, isCrossCompile);
+
+ if (exitCode == 0)
+ {
+ CheckFirstTimeSetup(repoRoot);
+
+ if (isCrossCompile)
+ {
+ DisplayTargetRequirements(os, rid);
+ }
+ }
+
+ return exitCode;
+ }
+
+ private static void DisplayTargetRequirements(string targetOs, string rid)
+ {
+ var (osName, commands) = NativeLibraryChecker.GetRequirementsForTarget(targetOs);
+
+ var rows = new List
+ {
+ new Markup($"[bold {GoldMarkup}]:clipboard: Prerequisites for {Markup.Escape(osName)} target ({rid})[/]"),
+ new Markup("")
+ };
+
+ for (var i = 0; i < commands.Length; i++)
+ {
+ var cmd = commands[i];
+ if (i > 0)
+ {
+ rows.Add(new Markup(""));
+ }
+ rows.Add(new Markup($" [grey]\u2022[/] {Markup.Escape(cmd)}"));
+ }
+
+ rows.Add(new Markup(""));
+ rows.Add(new Markup(""));
+ rows.Add(new Markup("[grey]Install these on the target machine, then copy the [/][rgb(213,191,116)]Distribution[/] [grey]folder to the server and run it.[/]"));
+
+ AnsiConsole.Write(new Panel(new Rows(rows))
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.GoldDim)
+ .Padding(1, 0));
+ AnsiConsole.WriteLine();
+ }
+
+ private static void CheckFirstTimeSetup(string repoRoot)
+ {
+ var configPath = Path.Combine(repoRoot, "Distribution", "Configuration", "modernuo.json");
+ if (!File.Exists(configPath))
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Text(
+ ":light_bulb: Tip: The server will prompt you for game data file locations on first run.\n",
+ Branding.DimGoldStyle));
+ }
+ }
+
+ private static string? GetDotNetSdkVersion()
+ {
+ var result = ProcessRunner.RunCaptured("dotnet", "--version");
+ return result.Success ? result.StandardOutput.Trim() : null;
+ }
+
+ private static void WaitForKey()
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[grey]Press any key to continue...[/]");
+ Console.ReadKey(true);
+ }
+}
diff --git a/Projects/BuildTool/Interactive/InteractiveCancellation.cs b/Projects/BuildTool/Interactive/InteractiveCancellation.cs
new file mode 100644
index 000000000..d8d052f7d
--- /dev/null
+++ b/Projects/BuildTool/Interactive/InteractiveCancellation.cs
@@ -0,0 +1,58 @@
+namespace BuildTool.Interactive;
+
+///
+/// Manages cancellation for interactive mode.
+/// When Ctrl+C is pressed, the current token is cancelled, causing prompts to throw OperationCanceledException.
+/// The token source is then reset so the next prompt can work.
+///
+public sealed class InteractiveCancellation : IDisposable
+{
+ private CancellationTokenSource _cts = new();
+ private readonly object _lock = new();
+
+ ///
+ /// Gets the current cancellation token to pass to prompts.
+ ///
+ public CancellationToken Token
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _cts.Token;
+ }
+ }
+ }
+
+ ///
+ /// Signals cancellation (called from Ctrl+C handler).
+ ///
+ public void Cancel()
+ {
+ lock (_lock)
+ {
+ _cts.Cancel();
+ }
+ }
+
+ ///
+ /// Resets the cancellation token source so new prompts can work.
+ /// Call this after catching OperationCanceledException.
+ ///
+ public void Reset()
+ {
+ lock (_lock)
+ {
+ _cts.Dispose();
+ _cts = new CancellationTokenSource();
+ }
+ }
+
+ /// Global instance for app-wide interactive cancellation.
+ public static InteractiveCancellation Instance { get; } = new();
+
+ public void Dispose()
+ {
+ _cts.Dispose();
+ }
+}
diff --git a/Projects/BuildTool/Json/GlobalJsonContext.cs b/Projects/BuildTool/Json/GlobalJsonContext.cs
new file mode 100644
index 000000000..d64fa29b1
--- /dev/null
+++ b/Projects/BuildTool/Json/GlobalJsonContext.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace BuildTool.Json;
+
+public sealed class GlobalJson
+{
+ [JsonPropertyName("sdk")]
+ public SdkConfig? Sdk { get; set; }
+}
+
+public sealed class SdkConfig
+{
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+
+ [JsonPropertyName("rollForward")]
+ public string? RollForward { get; set; }
+
+ [JsonPropertyName("allowPrerelease")]
+ public bool AllowPrerelease { get; set; }
+}
+
+[JsonSerializable(typeof(GlobalJson))]
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+internal partial class GlobalJsonContext : JsonSerializerContext;
diff --git a/Projects/BuildTool/Platform/PlatformDetector.cs b/Projects/BuildTool/Platform/PlatformDetector.cs
new file mode 100644
index 000000000..ac7e74dfd
--- /dev/null
+++ b/Projects/BuildTool/Platform/PlatformDetector.cs
@@ -0,0 +1,212 @@
+using System.Runtime.InteropServices;
+using BuildTool.Publishing;
+
+namespace BuildTool.Platform;
+
+public static class PlatformDetector
+{
+ // Windows 11 starts at build 22000
+ private const int Windows11MinBuild = 22000;
+
+ public static PlatformInfo Detect()
+ {
+ var osRid = GetOsRid();
+ var archRid = GetArchRid();
+ var (distroId, distroName) = GetLinuxDistroInfo();
+ var kernelVersion = GetKernelVersion(osRid);
+ var osName = GetOsDisplayName(osRid, distroName, kernelVersion);
+ var packageManager = DetectPackageManager(osRid, distroId);
+
+ return new PlatformInfo
+ {
+ OsName = osName,
+ OsRid = osRid,
+ ArchRid = archRid,
+ DistroId = distroId,
+ DistroName = distroName,
+ KernelVersion = kernelVersion,
+ PackageManager = packageManager
+ };
+ }
+
+ private static string GetOsDisplayName(string osRid, string? distroName, string? kernelVersion)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ var version = Environment.OSVersion.Version;
+ var windowsVersion = version.Build >= Windows11MinBuild ? "11" : "10";
+ return $"Windows {windowsVersion} (Build {version.Build})";
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ var version = Environment.OSVersion.Version;
+ var macosName = GetMacOSCodename(version.Major);
+ return macosName is not null
+ ? $"macOS {version.Major}.{version.Minor} {macosName}"
+ : $"macOS {version.Major}.{version.Minor}";
+ }
+
+ // Linux: show distro name with kernel version
+ if (distroName is not null && kernelVersion is not null)
+ {
+ return $"{distroName} (kernel {kernelVersion})";
+ }
+
+ return distroName ?? (kernelVersion is not null ? $"Linux (kernel {kernelVersion})" : "Linux");
+ }
+
+ private static string? GetMacOSCodename(int majorVersion) =>
+ majorVersion switch
+ {
+ 15 => "Sequoia",
+ 14 => "Sonoma",
+ 13 => "Ventura",
+ 12 => "Monterey",
+ _ => null
+ };
+
+ private static string GetOsRid()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return "win";
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ return "osx";
+ }
+
+ return "linux";
+ }
+
+ private static string GetArchRid() =>
+ RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "x64",
+ Architecture.Arm64 => "arm64",
+ _ => "x64"
+ };
+
+ private static string? GetKernelVersion(string osRid)
+ {
+ if (osRid != "linux")
+ {
+ return null;
+ }
+
+ // Try /proc/version first (most reliable)
+ if (File.Exists("/proc/version"))
+ {
+ try
+ {
+ var procVersion = File.ReadAllText("/proc/version");
+ // Format: "Linux version 6.5.0-44-generic ..."
+ var parts = procVersion.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 3 && parts[0] == "Linux" && parts[1] == "version")
+ {
+ return parts[2];
+ }
+ }
+ catch
+ {
+ // Fall through to uname
+ }
+ }
+
+ // Fall back to uname -r
+ var result = ProcessRunner.RunCaptured("uname", "-r");
+ return result.Success ? result.StandardOutput.Trim() : null;
+ }
+
+ private static (string? Id, string? Name) GetLinuxDistroInfo()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return (null, null);
+ }
+
+ const string osReleasePath = "/etc/os-release";
+ if (!File.Exists(osReleasePath))
+ {
+ return (null, null);
+ }
+
+ string? id = null;
+ string? name = null;
+
+ foreach (var line in File.ReadLines(osReleasePath))
+ {
+ if (line.StartsWith("ID=", StringComparison.Ordinal))
+ {
+ id = line[3..].Trim('"');
+ }
+ else if (line.StartsWith("PRETTY_NAME=", StringComparison.Ordinal))
+ {
+ name = line[12..].Trim('"');
+ }
+
+ if (id is not null && name is not null)
+ {
+ break;
+ }
+ }
+
+ return (id, name);
+ }
+
+ private static PackageManager DetectPackageManager(string osRid, string? distroId)
+ {
+ if (osRid == "osx")
+ {
+ return PackageManager.Brew;
+ }
+
+ if (osRid != "linux")
+ {
+ return PackageManager.Unknown;
+ }
+
+ // Check distro ID first for accuracy
+ return distroId?.ToLowerInvariant() switch
+ {
+ "ubuntu" or "debian" or "linuxmint" or "pop" or "elementary" or "zorin" => PackageManager.Apt,
+ "fedora" or "centos" or "rhel" or "rocky" or "alma" or "ol" => PackageManager.Dnf,
+ "opensuse" or "opensuse-leap" or "opensuse-tumbleweed" or "sles" => PackageManager.Zypper,
+ "arch" or "manjaro" or "endeavouros" => PackageManager.Pacman,
+ "alpine" => PackageManager.Apk,
+ _ => DetectPackageManagerFromBinaries()
+ };
+ }
+
+ private static PackageManager DetectPackageManagerFromBinaries()
+ {
+ if (File.Exists("/usr/bin/apt-get") || File.Exists("/usr/bin/apt"))
+ {
+ return PackageManager.Apt;
+ }
+
+ if (File.Exists("/usr/bin/dnf"))
+ {
+ return PackageManager.Dnf;
+ }
+
+ if (File.Exists("/usr/bin/zypper"))
+ {
+ return PackageManager.Zypper;
+ }
+
+ if (File.Exists("/usr/bin/pacman"))
+ {
+ return PackageManager.Pacman;
+ }
+
+ if (File.Exists("/sbin/apk"))
+ {
+ return PackageManager.Apk;
+ }
+
+ return PackageManager.Unknown;
+ }
+}
diff --git a/Projects/BuildTool/Platform/PlatformInfo.cs b/Projects/BuildTool/Platform/PlatformInfo.cs
new file mode 100644
index 000000000..5d40827d1
--- /dev/null
+++ b/Projects/BuildTool/Platform/PlatformInfo.cs
@@ -0,0 +1,28 @@
+namespace BuildTool.Platform;
+
+public enum PackageManager
+{
+ Unknown,
+ Apt,
+ Dnf,
+ Zypper,
+ Pacman,
+ Apk,
+ Brew
+}
+
+public sealed class PlatformInfo
+{
+ public required string OsName { get; init; }
+ public required string OsRid { get; init; }
+ public required string ArchRid { get; init; }
+ public string Rid => $"{OsRid}-{ArchRid}";
+ public string? DistroId { get; init; }
+ public string? DistroName { get; init; }
+ public string? KernelVersion { get; init; }
+ public PackageManager PackageManager { get; init; } = PackageManager.Unknown;
+
+ public bool IsWindows => OsRid == "win";
+ public bool IsMacOS => OsRid == "osx";
+ public bool IsLinux => OsRid == "linux";
+}
diff --git a/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs b/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs
new file mode 100644
index 000000000..3659702d9
--- /dev/null
+++ b/Projects/BuildTool/Prerequisites/DotNetSdkManager.cs
@@ -0,0 +1,190 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using BuildTool.Json;
+using BuildTool.Platform;
+using BuildTool.Publishing;
+using Spectre.Console;
+
+namespace BuildTool.Prerequisites;
+
+public static partial class DotNetSdkManager
+{
+ private const string DotNetDownloadUrl = "https://dotnet.microsoft.com/download";
+ private const string InstallScriptUrlWindows = "https://dot.net/v1/dotnet-install.ps1";
+ private const string InstallScriptUrlUnix = "https://dot.net/v1/dotnet-install.sh";
+
+ public static PrerequisiteResult CheckSdk(string repoRoot)
+ {
+ var requiredVersionStr = ReadRequiredVersion(repoRoot) ?? "10.0.201";
+
+ if (!Version.TryParse(requiredVersionStr, out var requiredVersion))
+ {
+ requiredVersion = new Version(10, 0, 201);
+ }
+
+ // Check if dotnet is on PATH
+ var result = ProcessRunner.RunCaptured("dotnet", "--list-sdks");
+ if (!result.Success)
+ {
+ return new PrerequisiteResult
+ {
+ Name = ".NET SDK",
+ Passed = false,
+ Details = $".NET SDK {requiredVersionStr}+ is not installed",
+ DownloadUrl = DotNetDownloadUrl
+ };
+ }
+
+ // Parse installed SDK versions — find the best match
+ Version? bestVersion = null;
+ string? bestVersionStr = null;
+
+ foreach (var line in result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var match = SdkVersionRegex().Match(line);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ var versionStr = match.Groups[1].Value;
+ if (!Version.TryParse(versionStr, out var version))
+ {
+ continue;
+ }
+
+ if (bestVersion is null || version > bestVersion)
+ {
+ bestVersion = version;
+ bestVersionStr = versionStr;
+ }
+ }
+
+ if (bestVersion is null || bestVersion < requiredVersion)
+ {
+ return new PrerequisiteResult
+ {
+ Name = ".NET SDK",
+ Passed = false,
+ Details = bestVersionStr is not null
+ ? $".NET SDK {bestVersionStr} found, but {requiredVersionStr}+ is required"
+ : $".NET SDK {requiredVersionStr}+ is required but no SDK was found",
+ DownloadUrl = DotNetDownloadUrl
+ };
+ }
+
+ return new PrerequisiteResult
+ {
+ Name = ".NET SDK",
+ Passed = true,
+ Details = $".NET SDK {bestVersionStr}"
+ };
+ }
+
+ public static bool OfferInstall(PlatformInfo platform, bool interactive)
+ {
+ if (!interactive)
+ {
+ AnsiConsole.MarkupLine("[red]Error:[/] .NET SDK is not installed. Download from:");
+ AnsiConsole.MarkupLine($"[link]{DotNetDownloadUrl}[/]");
+ return false;
+ }
+
+ var install = AnsiConsole.Confirm(
+ "[yellow].NET SDK is required but not found.[/] Would you like to install it now?",
+ defaultValue: true
+ );
+
+ if (!install)
+ {
+ AnsiConsole.MarkupLine($"\nDownload the .NET SDK from: [link]{DotNetDownloadUrl}[/]");
+ return false;
+ }
+
+ return RunInstallScript(platform);
+ }
+
+ private static bool RunInstallScript(PlatformInfo platform)
+ {
+ AnsiConsole.MarkupLine("\n[blue]Installing .NET SDK...[/]");
+
+ if (platform.IsWindows)
+ {
+ return RunWindowsInstall();
+ }
+
+ return RunUnixInstall();
+ }
+
+ private static bool RunWindowsInstall()
+ {
+ // Download and run the official Microsoft install script
+ var result = ProcessRunner.RunPassthrough(
+ "powershell",
+ $"-NoProfile -ExecutionPolicy Bypass -Command \"& {{ " +
+ $"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; " +
+ $"$script = Invoke-WebRequest -Uri '{InstallScriptUrlWindows}' -UseBasicParsing; " +
+ $"$scriptBlock = [scriptblock]::Create($script.Content); " +
+ $"& $scriptBlock -Channel 10.0 }}\""
+ );
+
+ if (result != 0)
+ {
+ AnsiConsole.MarkupLine("[red]Failed to install .NET SDK.[/]");
+ AnsiConsole.MarkupLine($"Please install manually from: [link]{DotNetDownloadUrl}[/]");
+ return false;
+ }
+
+ AnsiConsole.MarkupLine("[green]Successfully installed .NET SDK.[/]");
+ AnsiConsole.MarkupLine("[yellow]Note:[/] You may need to restart your terminal for the PATH to update.");
+ return true;
+ }
+
+ private static bool RunUnixInstall()
+ {
+ // Download and run the official Microsoft install script
+ var result = ProcessRunner.RunPassthrough(
+ "bash",
+ $"-c \"curl -fsSL {InstallScriptUrlUnix} | bash -s -- --channel 10.0\""
+ );
+
+ if (result != 0)
+ {
+ AnsiConsole.MarkupLine("[red]Failed to install .NET SDK.[/]");
+ AnsiConsole.MarkupLine($"Please install manually from: [link]{DotNetDownloadUrl}[/]");
+ return false;
+ }
+
+ AnsiConsole.MarkupLine("[green]Successfully installed .NET SDK.[/]");
+ AnsiConsole.MarkupLine("[yellow]Note:[/] You may need to add ~/.dotnet to your PATH or restart your terminal.");
+ return true;
+ }
+
+ private static string? ReadRequiredVersion(string repoRoot)
+ {
+ var globalJsonPath = Path.Combine(repoRoot, "global.json");
+ if (!File.Exists(globalJsonPath))
+ {
+ return null;
+ }
+
+ var json = File.ReadAllText(globalJsonPath);
+ var globalJson = JsonSerializer.Deserialize(json, GlobalJsonContext.Default.GlobalJson);
+ return globalJson?.Sdk?.Version;
+ }
+
+ // Kept for reference but no longer used — Version.TryParse handles comparison now
+ private static int ParseMajorVersion(string version)
+ {
+ var dotIndex = version.IndexOf('.');
+ if (dotIndex <= 0)
+ {
+ return 0;
+ }
+
+ return int.TryParse(version.AsSpan(0, dotIndex), out var major) ? major : 0;
+ }
+
+ [GeneratedRegex(@"^(\d+\.\d+\.\d+\S*)")]
+ private static partial Regex SdkVersionRegex();
+}
diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs
new file mode 100644
index 000000000..29f9b488c
--- /dev/null
+++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs
@@ -0,0 +1,309 @@
+using BuildTool.Platform;
+using BuildTool.Publishing;
+
+namespace BuildTool.Prerequisites;
+
+public static class NativeLibraryChecker
+{
+ ///
+ /// Returns the required prerequisites for a target OS (without checking the local machine).
+ /// Used to inform users what they need to install on the deployment target after cross-compiling.
+ ///
+ public static (string Description, string[] InstallCommands) GetRequirementsForTarget(string targetOs)
+ {
+ return targetOs switch
+ {
+ "win" => (
+ "Windows",
+ [
+ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
+ "VC++ Redistributable v14 — https://aka.ms/vs/17/release/vc_redist.x64.exe"
+ ]
+ ),
+ "osx" => (
+ "macOS",
+ [
+ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
+ "brew install icu4c libdeflate zstd argon2"
+ ]
+ ),
+ "linux" => (
+ "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 liburing-dev",
+ "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel",
+ "CentOS: Also requires epel-release and CRB enabled"
+ ]
+ ),
+ _ => ("Unknown", [".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0"])
+ };
+ }
+
+ public static List Check(PlatformInfo platform)
+ {
+ if (platform.IsWindows)
+ {
+ return CheckWindows(platform);
+ }
+
+ if (platform.IsMacOS)
+ {
+ return CheckMacOS();
+ }
+
+ if (platform.IsLinux)
+ {
+ return CheckLinux(platform);
+ }
+
+ return [];
+ }
+
+ private static List CheckWindows(PlatformInfo platform)
+ {
+ var results = new List();
+
+ // Check VC++ Redistributable via registry
+ var vcRedistInstalled = CheckVcRedist(platform.ArchRid);
+ var downloadUrl = platform.ArchRid == "arm64"
+ ? "https://aka.ms/vs/17/release/vc_redist.arm64.exe"
+ : "https://aka.ms/vs/17/release/vc_redist.x64.exe";
+
+ results.Add(new PrerequisiteResult
+ {
+ Name = "VC++ Redistributable v14",
+ Passed = vcRedistInstalled,
+ Details = vcRedistInstalled ? "Installed" : "Not found",
+ DownloadUrl = vcRedistInstalled ? null : downloadUrl
+ });
+
+ return results;
+ }
+
+ private static bool CheckVcRedist(string arch)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ // Check multiple known registry paths for VC++ 14.x Redistributable
+ string[] registryPaths =
+ [
+ $@"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\{arch}",
+ $@"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\{arch}"
+ ];
+
+ foreach (var path in registryPaths)
+ {
+ try
+ {
+ using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(path);
+ if (key?.GetValue("Installed") is int installed && installed == 1)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Registry access may fail, continue checking
+ }
+ }
+
+ return false;
+ }
+
+ private static List CheckMacOS()
+ {
+ var results = new List();
+
+ // Check if Homebrew is installed
+ var brewResult = ProcessRunner.RunCaptured("which", "brew");
+ if (!brewResult.Success)
+ {
+ results.Add(new PrerequisiteResult
+ {
+ Name = "Homebrew",
+ Passed = false,
+ Details = "Homebrew is required to install native dependencies",
+ DownloadUrl = "https://brew.sh"
+ });
+ return results;
+ }
+
+ // Check required Homebrew formulae
+ var formulae = new[] { "icu4c", "libdeflate", "zstd", "argon2" };
+ var listResult = ProcessRunner.RunCaptured("brew", "list --formula");
+ var installedFormulae = listResult.Success
+ ? new HashSet(
+ listResult.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries),
+ StringComparer.OrdinalIgnoreCase)
+ : new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ var missing = new List();
+ foreach (var formula in formulae)
+ {
+ var installed = installedFormulae.Contains(formula);
+ if (!installed)
+ {
+ missing.Add(formula);
+ }
+
+ results.Add(new PrerequisiteResult
+ {
+ Name = formula,
+ Passed = installed,
+ Details = installed ? "Installed" : "Not installed",
+ InstallCommand = installed ? null : $"brew install {formula}"
+ });
+ }
+
+ 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 = $"brew install {string.Join(' ', missing)}"
+ });
+ }
+
+ return results;
+ }
+
+ 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", "liburing-dev" };
+ var missing = new List();
+
+ foreach (var package in packages)
+ {
+ var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
+ var installed = result.Success && result.StandardOutput.Contains("ii");
+
+ if (!installed)
+ {
+ missing.Add(package);
+ }
+
+ results.Add(new PrerequisiteResult
+ {
+ Name = package,
+ Passed = installed,
+ Details = installed ? "Installed" : "Not installed"
+ });
+ }
+
+ 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;
+ }
+
+ private static List CheckLinuxDnf(PlatformInfo platform)
+ {
+ var results = new List();
+ var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-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)
+ {
+ results.Add(new PrerequisiteResult
+ {
+ Name = "EPEL Repository",
+ Passed = false,
+ IsWarning = true,
+ Details = "CentOS requires EPEL for some packages. Enable it first:",
+ InstallCommand = "sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb"
+ });
+ }
+
+ 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 dnf install -y {string.Join(' ', missing)}"
+ });
+ }
+
+ return results;
+ }
+
+ private static List CheckLinuxGeneric(PlatformInfo platform)
+ {
+ 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
+ {
+ ["libicu"] = "libicuuc",
+ ["libdeflate"] = "libdeflate",
+ ["zstd"] = "libzstd",
+ ["libargon2"] = "libargon2",
+ ["liburing"] = "liburing"
+ };
+
+ 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 results;
+ }
+}
diff --git a/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs b/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs
new file mode 100644
index 000000000..245bcee4c
--- /dev/null
+++ b/Projects/BuildTool/Prerequisites/PrerequisiteChecker.cs
@@ -0,0 +1,165 @@
+using BuildTool.Platform;
+using Spectre.Console;
+
+namespace BuildTool.Prerequisites;
+
+public static class PrerequisiteChecker
+{
+ ///
+ /// Runs all prerequisite checks (SDK + native libraries) and displays results.
+ /// Returns true if all critical prerequisites pass.
+ ///
+ public static bool CheckAll(PlatformInfo platform, string repoRoot, bool interactive)
+ {
+ var panel = new Panel("[bold]Checking prerequisites[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.Gold)
+ .Padding(1, 0);
+ AnsiConsole.Write(panel);
+ AnsiConsole.WriteLine();
+
+ if (!CheckSdkInternal(platform, repoRoot, interactive))
+ {
+ return false;
+ }
+
+ return CheckNativeLibrariesInternal(platform, interactive);
+ }
+
+ ///
+ /// Checks only the .NET SDK (sufficient for cross-compilation).
+ /// Returns true if the SDK is available.
+ ///
+ public static bool CheckSdk(PlatformInfo platform, string repoRoot, bool interactive)
+ {
+ var panel = new Panel("[bold]Checking .NET SDK[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.Gold)
+ .Padding(1, 0);
+ AnsiConsole.Write(panel);
+ AnsiConsole.WriteLine();
+
+ var passed = CheckSdkInternal(platform, repoRoot, interactive);
+ AnsiConsole.WriteLine();
+ return passed;
+ }
+
+ ///
+ /// Checks native libraries for the current platform.
+ /// Call this only when the publish target matches the current OS.
+ /// Returns true if all pass or the user chose to continue.
+ ///
+ public static bool CheckNativeLibraries(PlatformInfo platform, bool interactive)
+ {
+ AnsiConsole.Write(new Panel("[bold]Checking native libraries[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.Gold)
+ .Padding(1, 0));
+ AnsiConsole.WriteLine();
+
+ var passed = CheckNativeLibrariesInternal(platform, interactive);
+ AnsiConsole.WriteLine();
+ return passed;
+ }
+
+ private static bool CheckSdkInternal(PlatformInfo platform, string repoRoot, bool interactive)
+ {
+ var sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
+ DisplayResult(sdkResult);
+
+ if (!sdkResult.Passed)
+ {
+ var installed = DotNetSdkManager.OfferInstall(platform, interactive);
+ if (!installed)
+ {
+ return false;
+ }
+
+ sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
+ if (!sdkResult.Passed)
+ {
+ AnsiConsole.MarkupLine("[red]SDK installation may require a terminal restart.[/]");
+ return false;
+ }
+
+ DisplayResult(sdkResult);
+ }
+
+ return true;
+ }
+
+ private static bool CheckNativeLibrariesInternal(PlatformInfo platform, bool interactive)
+ {
+ var nativeResults = NativeLibraryChecker.Check(platform);
+ var hasMissing = false;
+
+ foreach (var result in nativeResults)
+ {
+ if (result.IsWarning)
+ {
+ continue;
+ }
+
+ DisplayResult(result);
+ if (!result.Passed)
+ {
+ hasMissing = true;
+ }
+ }
+
+ if (hasMissing)
+ {
+ AnsiConsole.WriteLine();
+
+ foreach (var result in nativeResults)
+ {
+ if (!result.IsWarning)
+ {
+ continue;
+ }
+
+ if (result.InstallCommand is not null)
+ {
+ AnsiConsole.MarkupLine($" [yellow]:warning: {result.Details}[/]");
+ AnsiConsole.MarkupLine($" [white on grey23] {result.InstallCommand} [/]");
+ AnsiConsole.WriteLine();
+ }
+ }
+
+ if (interactive)
+ {
+ var continueAnyway = AnsiConsole.Confirm(
+ "[yellow]Some prerequisites are missing. Continue anyway?[/]",
+ defaultValue: false
+ );
+
+ if (!continueAnyway)
+ {
+ return false;
+ }
+ }
+
+ return interactive; // In interactive mode, user chose to continue
+ }
+
+ AnsiConsole.MarkupLine("[green]:check_mark_button: All prerequisites satisfied.[/]");
+ return true;
+ }
+
+ private static void DisplayResult(PrerequisiteResult result)
+ {
+ if (result.IsWarning)
+ {
+ return;
+ }
+
+ var icon = result.Passed ? "[green]:check_mark:[/]" : "[red]:cross_mark:[/]";
+ var details = result.Details is not null ? $" [grey]({Markup.Escape(result.Details)})[/]" : "";
+ AnsiConsole.MarkupLine($" {icon} {Markup.Escape(result.Name)}{details}");
+
+ if (!result.Passed && result.DownloadUrl is not null)
+ {
+ AnsiConsole.MarkupLine($" [grey]Download: {result.DownloadUrl}[/]");
+ }
+ }
+}
diff --git a/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs b/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs
new file mode 100644
index 000000000..e7b123730
--- /dev/null
+++ b/Projects/BuildTool/Prerequisites/PrerequisiteResult.cs
@@ -0,0 +1,11 @@
+namespace BuildTool.Prerequisites;
+
+public sealed class PrerequisiteResult
+{
+ public required string Name { get; init; }
+ public required bool Passed { get; init; }
+ public string? Details { get; init; }
+ public string? InstallCommand { get; init; }
+ public string? DownloadUrl { get; init; }
+ public bool IsWarning { get; init; }
+}
diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs
new file mode 100644
index 000000000..3b36066da
--- /dev/null
+++ b/Projects/BuildTool/Program.cs
@@ -0,0 +1,173 @@
+using System.Text;
+using BuildTool;
+using BuildTool.Interactive;
+using BuildTool.Platform;
+using BuildTool.Prerequisites;
+using BuildTool.Publishing;
+
+Console.OutputEncoding = Encoding.UTF8;
+
+var repoRoot = FindRepoRoot();
+if (repoRoot is null)
+{
+ Console.Error.WriteLine("Error: Could not find ModernUO.slnx. Run this tool from the repository root.");
+ return 1;
+}
+
+var options = ParseArguments(args);
+
+// Interactive mode: no args provided
+if (options.Interactive)
+{
+ // Handle Ctrl+C by signaling our cancellation token (not killing the process)
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ InteractiveCancellation.Instance.Cancel();
+ };
+
+ var platform = PlatformDetector.Detect();
+ return GuidedMode.Run(platform, repoRoot);
+}
+
+// Non-interactive mode
+var detectedPlatform = PlatformDetector.Detect();
+options.Os ??= detectedPlatform.OsRid;
+options.Arch ??= detectedPlatform.ArchRid;
+var rid = $"{options.Os}-{options.Arch}";
+
+// Run prerequisite checks unless skipped
+if (!options.SkipPrereqs)
+{
+ var sdkResult = DotNetSdkManager.CheckSdk(repoRoot);
+ if (!sdkResult.Passed)
+ {
+ Console.Error.WriteLine($"Error: {sdkResult.Details}");
+ if (sdkResult.DownloadUrl is not null)
+ {
+ Console.Error.WriteLine($"Download: {sdkResult.DownloadUrl}");
+ }
+ return 1;
+ }
+}
+
+return options.Action switch
+{
+ BuildAction.Publish => PublishOrchestrator.Run(options.Config, rid, interactive: false),
+ BuildAction.Migrate => SchemaMigrator.Run(interactive: false),
+ _ => 1
+};
+
+static BuildOptions ParseArguments(string[] args)
+{
+ var options = new BuildOptions();
+
+ if (args.Length == 0)
+ {
+ options.Interactive = true;
+ return options;
+ }
+
+ // Check for named arguments first
+ var hasNamedArgs = false;
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--config" when i + 1 < args.Length:
+ {
+ options.Config = NormalizeConfig(args[++i]);
+ hasNamedArgs = true;
+ break;
+ }
+ case "--os" when i + 1 < args.Length:
+ {
+ options.Os = args[++i].ToLowerInvariant();
+ hasNamedArgs = true;
+ break;
+ }
+ case "--arch" when i + 1 < args.Length:
+ {
+ options.Arch = args[++i].ToLowerInvariant();
+ hasNamedArgs = true;
+ break;
+ }
+ case "--action" when i + 1 < args.Length:
+ {
+ options.Action = args[++i].ToLowerInvariant() switch
+ {
+ "migrate" => BuildAction.Migrate,
+ _ => BuildAction.Publish
+ };
+ hasNamedArgs = true;
+ break;
+ }
+ case "--skip-prereqs":
+ {
+ options.SkipPrereqs = true;
+ hasNamedArgs = true;
+ break;
+ }
+ case "--interactive":
+ {
+ options.Interactive = true;
+ hasNamedArgs = true;
+ break;
+ }
+ }
+ }
+
+ if (hasNamedArgs)
+ {
+ return options;
+ }
+
+ // Positional argument parsing (backward compat: [config] [os] [arch])
+ if (args.Length >= 1)
+ {
+ options.Config = NormalizeConfig(args[0]);
+ }
+
+ if (args.Length >= 2)
+ {
+ options.Os = args[1].ToLowerInvariant();
+ }
+
+ if (args.Length >= 3)
+ {
+ options.Arch = args[2].ToLowerInvariant();
+ }
+
+ return options;
+}
+
+static string NormalizeConfig(string config) =>
+ config.ToLowerInvariant() switch
+ {
+ "release" => "Release",
+ "debug" => "Debug",
+ _ => char.ToUpperInvariant(config[0]) + config[1..].ToLowerInvariant()
+ };
+
+static string? FindRepoRoot()
+{
+ // Check current directory first
+ var current = Directory.GetCurrentDirectory();
+ if (File.Exists(Path.Combine(current, "ModernUO.slnx")))
+ {
+ return current;
+ }
+
+ // Walk up to find the solution file
+ var dir = new DirectoryInfo(current);
+ while (dir?.Parent is not null)
+ {
+ dir = dir.Parent;
+ if (File.Exists(Path.Combine(dir.FullName, "ModernUO.slnx")))
+ {
+ return dir.FullName;
+ }
+ }
+
+ return null;
+}
diff --git a/Projects/BuildTool/Publishing/ProcessRunner.cs b/Projects/BuildTool/Publishing/ProcessRunner.cs
new file mode 100644
index 000000000..861268032
--- /dev/null
+++ b/Projects/BuildTool/Publishing/ProcessRunner.cs
@@ -0,0 +1,95 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace BuildTool.Publishing;
+
+public sealed class ProcessResult
+{
+ public required int ExitCode { get; init; }
+ public required string StandardOutput { get; init; }
+ public required string StandardError { get; init; }
+ public bool Success => ExitCode == 0;
+}
+
+public static class ProcessRunner
+{
+ ///
+ /// Runs a command with output captured (for parsing results).
+ ///
+ public static ProcessResult RunCaptured(string fileName, string arguments, string? workingDirectory = null)
+ {
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(),
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = Process.Start(psi);
+ if (process is null)
+ {
+ return new ProcessResult
+ {
+ ExitCode = -1,
+ StandardOutput = "",
+ StandardError = $"Failed to start process: {fileName}"
+ };
+ }
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data is not null)
+ {
+ stdout.AppendLine(e.Data);
+ }
+ };
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data is not null)
+ {
+ stderr.AppendLine(e.Data);
+ }
+ };
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ process.WaitForExit();
+
+ return new ProcessResult
+ {
+ ExitCode = process.ExitCode,
+ StandardOutput = stdout.ToString(),
+ StandardError = stderr.ToString()
+ };
+ }
+
+ ///
+ /// Runs a command with output passed through to the console (for build commands in non-interactive mode).
+ ///
+ public static int RunPassthrough(string fileName, string arguments, string? workingDirectory = null)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(),
+ UseShellExecute = false
+ };
+
+ using var process = Process.Start(psi);
+ if (process is null)
+ {
+ return -1;
+ }
+
+ process.WaitForExit();
+ return process.ExitCode;
+ }
+}
diff --git a/Projects/BuildTool/Publishing/PublishOrchestrator.cs b/Projects/BuildTool/Publishing/PublishOrchestrator.cs
new file mode 100644
index 000000000..1a704b9b3
--- /dev/null
+++ b/Projects/BuildTool/Publishing/PublishOrchestrator.cs
@@ -0,0 +1,182 @@
+using BuildTool.Platform;
+using Spectre.Console;
+
+namespace BuildTool.Publishing;
+
+public static class PublishOrchestrator
+{
+ // Target the Application project (not the solution) to avoid building BuildTool and test projects
+ private const string AppProject = "Projects/Application/Application.csproj";
+
+ private static readonly (string Description, string Command, string Arguments)[] BuildSteps =
+ [
+ ("Restoring tools", "dotnet", "tool restore"),
+ ("Cleaning project", "dotnet", $"clean {AppProject} --verbosity quiet"),
+ ("Restoring packages", "dotnet", $"restore {AppProject} --force-evaluate --source https://api.nuget.org/v3/index.json"),
+ ];
+
+ public static int Run(string config, PlatformInfo platform, bool interactive)
+ {
+ return Run(config, platform.Rid, interactive);
+ }
+
+ public static int Run(string config, string rid, bool interactive, bool isCrossCompile = false)
+ {
+ if (interactive)
+ {
+ return RunInteractive(config, rid, isCrossCompile);
+ }
+
+ return RunNonInteractive(config, rid);
+ }
+
+ private static int RunInteractive(string config, string rid, bool isCrossCompile)
+ {
+ var panel = new Panel($"[bold]Publishing[/] [rgb(223,198,136)]{config}[/] for [rgb(223,198,136)]{rid}[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.Gold)
+ .Padding(1, 0);
+ AnsiConsole.Write(panel);
+ AnsiConsole.WriteLine();
+
+ var allSteps = new List<(string Description, string Command, string Arguments)>(BuildSteps)
+ {
+ ($"Publishing ({config}, {rid})", "dotnet", $"publish {AppProject} -c {config} -r {rid} --no-restore --self-contained=false"),
+ ("Generating serialization schema", "dotnet", "tool run ModernUOSchemaGenerator -- ModernUO.slnx")
+ };
+
+ var completed = 0;
+ var total = allSteps.Count;
+
+ foreach (var (description, command, arguments) in allSteps)
+ {
+ completed++;
+ var exitCode = RunStepInteractive(description, command, arguments, completed, total);
+ if (exitCode != 0)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Panel("[red bold]Build failed.[/] See error output above for details.")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Red)
+ .Padding(1, 0));
+ return exitCode;
+ }
+ }
+
+ AnsiConsole.WriteLine();
+ DisplaySuccess(rid, isCrossCompile);
+ return 0;
+ }
+
+ private static int RunStepInteractive(string description, string command, string arguments, int step, int total)
+ {
+ var exitCode = 0;
+ string errorOutput = "";
+
+ AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .SpinnerStyle(Branding.GoldStyle)
+ .Start($"[grey]({step}/{total})[/] {description}...", _ =>
+ {
+ var result = ProcessRunner.RunCaptured(command, arguments);
+ exitCode = result.ExitCode;
+ errorOutput = result.StandardError;
+ });
+
+ if (exitCode == 0)
+ {
+ AnsiConsole.MarkupLine($" [green]:check_mark:[/] [grey]({step}/{total})[/] {description}");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine($" [red]:cross_mark:[/] [grey]({step}/{total})[/] {description} [red](failed)[/]");
+
+ if (!string.IsNullOrWhiteSpace(errorOutput))
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
+ .Header("[red]Error Output[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Red)
+ .Expand());
+ }
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($" [grey]Command: {command} {arguments}[/]");
+ }
+
+ return exitCode;
+ }
+
+ private static int RunNonInteractive(string config, string rid)
+ {
+ // Run common build steps
+ foreach (var (description, command, arguments) in BuildSteps)
+ {
+ Console.WriteLine($"{command} {arguments}");
+ var exitCode = ProcessRunner.RunPassthrough(command, arguments);
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine($"Error: '{description}' failed with exit code {exitCode}");
+ return exitCode;
+ }
+ }
+
+ // Publish step
+ {
+ var publishArgs = $"publish {AppProject} -c {config} -r {rid} --no-restore --self-contained=false";
+ Console.WriteLine($"dotnet {publishArgs}");
+ var exitCode = ProcessRunner.RunPassthrough("dotnet", publishArgs);
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine($"Error: 'publish' failed with exit code {exitCode}");
+ return exitCode;
+ }
+ }
+
+ // Schema generation
+ {
+ Console.WriteLine("Generating serialization migration schema...");
+ const string schemaArgs = "tool run ModernUOSchemaGenerator -- ModernUO.slnx";
+ var exitCode = ProcessRunner.RunPassthrough("dotnet", schemaArgs);
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine($"Error: schema generation failed with exit code {exitCode}");
+ return exitCode;
+ }
+ }
+
+ return 0;
+ }
+
+ internal static void DisplaySuccess(string rid, bool isCrossCompile = false)
+ {
+ var isWindows = rid.StartsWith("win", StringComparison.OrdinalIgnoreCase);
+ var runCommand = isWindows ? "ModernUO.exe" : "dotnet ModernUO.dll";
+
+ var rows = new List
+ {
+ new Markup("[green bold]:check_mark_button: Build complete![/]"),
+ new Markup("")
+ };
+
+ if (isCrossCompile)
+ {
+ rows.Add(new Markup($"Copy the [rgb(213,191,116)]Distribution[/] folder to your [rgb(213,191,116)]{rid}[/] server, then run:"));
+ }
+ else
+ {
+ rows.Add(new Markup("Run the server from the [rgb(213,191,116)]Distribution[/] directory:"));
+ }
+
+ rows.Add(new Markup(""));
+ rows.Add(new Markup(" [white on grey23] cd Distribution [/]"));
+ rows.Add(new Markup($" [white on grey23] {runCommand} [/]"));
+
+ AnsiConsole.Write(new Panel(new Rows(rows))
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Green)
+ .Padding(1, 0));
+ AnsiConsole.WriteLine();
+ }
+}
diff --git a/Projects/BuildTool/Publishing/SchemaMigrator.cs b/Projects/BuildTool/Publishing/SchemaMigrator.cs
new file mode 100644
index 000000000..6bbe5291c
--- /dev/null
+++ b/Projects/BuildTool/Publishing/SchemaMigrator.cs
@@ -0,0 +1,126 @@
+using Spectre.Console;
+
+namespace BuildTool.Publishing;
+
+public static class SchemaMigrator
+{
+ public static int Run(bool interactive)
+ {
+ if (interactive)
+ {
+ return RunInteractive();
+ }
+
+ return RunNonInteractive();
+ }
+
+ private static int RunInteractive()
+ {
+ var panel = new Panel("[bold]Running schema migration[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Branding.Gold)
+ .Padding(1, 0);
+ AnsiConsole.Write(panel);
+ AnsiConsole.WriteLine();
+
+ // Tool restore
+ {
+ var exitCode = 0;
+ string errorOutput = "";
+
+ AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .SpinnerStyle(Branding.GoldStyle)
+ .Start("[grey](1/2)[/] Restoring tools...", _ =>
+ {
+ var result = ProcessRunner.RunCaptured("dotnet", "tool restore");
+ exitCode = result.ExitCode;
+ errorOutput = result.StandardError;
+ });
+
+ if (exitCode == 0)
+ {
+ AnsiConsole.MarkupLine(" [green]:check_mark:[/] [grey](1/2)[/] Tools restored");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine(" [red]:cross_mark:[/] [grey](1/2)[/] Tool restore [red](failed)[/]");
+ if (!string.IsNullOrWhiteSpace(errorOutput))
+ {
+ AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
+ .Header("[red]Error[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Red));
+ }
+
+ return exitCode;
+ }
+ }
+
+ // Schema generation
+ {
+ var exitCode = 0;
+ string errorOutput = "";
+
+ AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .SpinnerStyle(Branding.GoldStyle)
+ .Start("[grey](2/2)[/] Generating serialization schema...", _ =>
+ {
+ var result = ProcessRunner.RunCaptured(
+ "dotnet",
+ "tool run ModernUOSchemaGenerator -- ModernUO.slnx"
+ );
+ exitCode = result.ExitCode;
+ errorOutput = result.StandardError;
+ });
+
+ if (exitCode == 0)
+ {
+ AnsiConsole.MarkupLine(" [green]:check_mark:[/] [grey](2/2)[/] Schema generated");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine(" [red]:cross_mark:[/] [grey](2/2)[/] Schema generation [red](failed)[/]");
+ if (!string.IsNullOrWhiteSpace(errorOutput))
+ {
+ AnsiConsole.Write(new Panel(Markup.Escape(errorOutput.Trim()))
+ .Header("[red]Error[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Red));
+ }
+
+ return exitCode;
+ }
+ }
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Panel("[green bold]:check_mark_button: Schema migration complete.[/]")
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Green)
+ .Padding(1, 0));
+ AnsiConsole.WriteLine();
+ return 0;
+ }
+
+ private static int RunNonInteractive()
+ {
+ Console.WriteLine("dotnet tool restore");
+ var exitCode = ProcessRunner.RunPassthrough("dotnet", "tool restore");
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine($"Error: tool restore failed with exit code {exitCode}");
+ return exitCode;
+ }
+
+ Console.WriteLine("Generating serialization migration schema...");
+ exitCode = ProcessRunner.RunPassthrough("dotnet", "tool run ModernUOSchemaGenerator -- ModernUO.slnx");
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine($"Error: schema generation failed with exit code {exitCode}");
+ return exitCode;
+ }
+
+ return 0;
+ }
+}
diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index 02f6974eb..2612c203b 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -35,12 +35,12 @@
-
+
-
+
-
+
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 22b81d23e..1eaf9e807 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -39,15 +39,15 @@
-
-
+
+
-
+
diff --git a/README.md b/README.md
index daec5f817..2a74b051c 100644
--- a/README.md
+++ b/README.md
@@ -56,14 +56,25 @@ ModernUO [] [os] [arch (default: x64)]`
- - `os` - [Supported operating systems](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md)
- - `win` - [Windows](https://learn.microsoft.com/en-us/dotnet/core/install/windows)
- - `osx` - [MacOS](https://learn.microsoft.com/en-us/dotnet/core/install/macos)
- - `linux` - [Linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)
- - `arch`
- - `x64` - Intel/AMD 64-bit
- - `arm64` - ARM 64-bit (Windows not supported)
+
+#### Interactive Mode (Recommended for new users)
+Run `./publish.cmd` (Windows) or `./publish.sh` (Linux/macOS) with no arguments to launch the guided build tool. It will:
+- Check prerequisites (.NET SDK, native libraries)
+- Walk you through configuration and platform selection
+- Build and publish the server to the `Distribution` directory
+- Show deployment instructions for cross-compiled builds
+
+#### Command Line
+```shell
+./publish.cmd [release|debug] [os] [arch]
+```
+- `os` - [Supported operating systems](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md)
+ - `win` - [Windows](https://learn.microsoft.com/en-us/dotnet/core/install/windows)
+ - `osx` - [macOS](https://learn.microsoft.com/en-us/dotnet/core/install/macos)
+ - `linux` - [Linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)
+- `arch`
+ - `x64` - Intel/AMD 64-bit
+ - `arm64` - ARM 64-bit
## Linux Prerequisites
### Fedora, CentOS, RHEL, etc
@@ -87,7 +98,9 @@ brew install icu4c libdeflate zstd argon2
## Running the Server
- Follow the [publish](https://github.com/modernuo/ModernUO#buildingpublishing) instructions
+- The `Distribution` directory is portable — copy it to your production server for deployment
- Run `ModernUO.exe` or `dotnet ModernUO.dll` from the `Distribution` directory
+- On first run, the server will prompt you to configure game data file locations
## Troubleshooting / FAQ
- See [FAQ](./FAQ.md)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 0a0fb6a25..7c61877a6 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -18,7 +18,7 @@ jobs:
inputs:
useGlobalJson: true
- task: NuGetAuthenticate@1
- - script: ./publish.cmd Release
+ - script: dotnet run --project Projects\BuildTool -- --config Release --skip-prereqs
displayName: 'Build'
- powershell: ./.github/porcelain.ps1
displayName: Migration Changes
diff --git a/global.json b/global.json
index 6a288505a..fa8e68b5f 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.100",
+ "version": "10.0.201",
"rollForward": "latestMajor",
"allowPrerelease": false
}
diff --git a/publish.cmd b/publish.cmd
index f8e490426..a15991ab0 100755
--- a/publish.cmd
+++ b/publish.cmd
@@ -1,46 +1,9 @@
:<<"::SHELLSCRIPT"
@ECHO OFF
-GOTO :CMDSCRIPT
+powershell -ExecutionPolicy Bypass -NoProfile -File "%~dp0publish.ps1" %*
+exit /b %ERRORLEVEL%
::SHELLSCRIPT
+# If run from bash (e.g., CI on Linux/macOS), dispatch to publish.sh
path=$(dirname "$0")
-cd $path
-./publish.sh
-exit $?
-
-:CMDSCRIPT
-IF "%~1" == "" (
- SET config=-c Release
-) ELSE (
- IF "%~1" == "release" (
- SET config=-c Release
- ) ELSE (
- SET config=-c Debug
- )
-)
-
-IF "%~2" == "" (
- SET os=-r win
-) ELSE (
- SET os=-r %~2
-)
-
-IF "%~3" == "" (
- SET arch=x64
-) ELSE (
- SET arch=%~3
-)
-
-echo dotnet tool restore
-dotnet tool restore
-
-echo dotnet clean --verbosity quiet
-dotnet clean --verbosity quiet
-echo dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
-dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
-
-echo dotnet publish %config% %os%-%arch% --no-restore --self-contained=false
-dotnet publish %config% %os%-%arch% --no-restore --self-contained=false
-
-echo Generating serialization migration schema...
-dotnet tool run ModernUOSchemaGenerator -- ModernUO.sln
+exec "$path/publish.sh" "$@"
diff --git a/publish.ps1 b/publish.ps1
new file mode 100644
index 000000000..8f26159eb
--- /dev/null
+++ b/publish.ps1
@@ -0,0 +1,79 @@
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+$toolBinary = Join-Path (Join-Path $repoRoot 'tools') 'build-tool.exe'
+$buildToolProject = Join-Path (Join-Path (Join-Path $repoRoot 'Projects') 'BuildTool') 'BuildTool.csproj'
+
+function Get-BuildToolFromRelease {
+ try {
+ # Determine platform
+ $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
+ $assetName = "build-tool-win-$arch.exe"
+
+ Write-Host "Downloading build tool..." -ForegroundColor Blue
+
+ $releaseUrl = 'https://api.github.com/repos/modernuo/ModernUO/releases/tags/build-tool-latest'
+ $headers = @{ 'User-Agent' = 'ModernUO-BuildTool' }
+ $release = Invoke-RestMethod -Uri $releaseUrl -Headers $headers -TimeoutSec 10
+
+ $asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
+ if (-not $asset) {
+ return $false
+ }
+
+ $toolsDir = Join-Path $repoRoot 'tools'
+ if (-not (Test-Path $toolsDir)) {
+ New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null
+ }
+
+ Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $toolBinary -TimeoutSec 60
+ return $true
+ }
+ catch {
+ return $false
+ }
+}
+
+function Test-DotNetAvailable {
+ try {
+ $null = & dotnet --version 2>$null
+ return $LASTEXITCODE -eq 0
+ }
+ catch {
+ return $false
+ }
+}
+
+# Try native binary first
+if (Test-Path $toolBinary) {
+ & $toolBinary @args
+ exit $LASTEXITCODE
+}
+
+# Try to download native binary
+if (Get-BuildToolFromRelease) {
+ & $toolBinary @args
+ exit $LASTEXITCODE
+}
+
+# Fall back to dotnet run
+if (Test-DotNetAvailable) {
+ if (Test-Path $buildToolProject) {
+ Write-Host "Using dotnet run fallback..." -ForegroundColor Yellow
+ & dotnet run --project $buildToolProject -- @args
+ exit $LASTEXITCODE
+ }
+ else {
+ Write-Error "BuildTool project not found at: $buildToolProject"
+ exit 1
+ }
+}
+
+# Nothing works
+Write-Host ""
+Write-Host "Error: Could not run the build tool." -ForegroundColor Red
+Write-Host ""
+Write-Host "The .NET 10 SDK is required. Download it from:" -ForegroundColor Yellow
+Write-Host " https://dotnet.microsoft.com/download/dotnet/10.0" -ForegroundColor Cyan
+Write-Host ""
+exit 1
diff --git a/publish.sh b/publish.sh
index 8b6f76f81..c4e728117 100755
--- a/publish.sh
+++ b/publish.sh
@@ -1,45 +1,93 @@
#!/bin/bash
-config=$1
-os=$2
-arch=${3:-$(uname -m)}
+set -e
-if [[ -n $os ]]; then
- os="-r $os"
-elif [[ $(uname) = "Darwin" ]]; then
- os="-r osx"
-else
- os="-r linux"
+REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
+
+# Determine platform for native binary
+detect_platform() {
+ local os arch
+ case "$(uname -s)" in
+ Darwin) os="osx" ;;
+ *) os="linux" ;;
+ esac
+
+ case "$(uname -m)" in
+ aarch64|arm64) arch="arm64" ;;
+ *) arch="x64" ;;
+ esac
+
+ echo "${os}-${arch}"
+}
+
+PLATFORM="$(detect_platform)"
+TOOL_BINARY="$REPO_ROOT/tools/build-tool"
+BUILD_TOOL_PROJECT="$REPO_ROOT/Projects/BuildTool/BuildTool.csproj"
+
+# Try to download native binary from GitHub Release
+download_build_tool() {
+ local asset_name="build-tool-${PLATFORM}"
+ local tools_dir="$REPO_ROOT/tools"
+
+ echo -e "\033[34mDownloading build tool...\033[0m"
+
+ # Get latest release asset URL
+ local release_url="https://api.github.com/repos/modernuo/ModernUO/releases/tags/build-tool-latest"
+ local release_json
+ release_json=$(curl -fsSL --connect-timeout 10 -H "User-Agent: ModernUO-BuildTool" "$release_url" 2>/dev/null) || return 1
+
+ local download_url
+ download_url=$(echo "$release_json" | grep -o "\"browser_download_url\"[[:space:]]*:[[:space:]]*\"[^\"]*${asset_name}[^\"]*\"" | head -1 | grep -o 'https://[^"]*') || return 1
+
+ if [ -z "$download_url" ]; then
+ return 1
+ fi
+
+ mkdir -p "$tools_dir"
+ curl -fsSL --connect-timeout 10 -o "$TOOL_BINARY" "$download_url" || return 1
+ chmod +x "$TOOL_BINARY"
+ return 0
+}
+
+has_dotnet() {
+ command -v dotnet >/dev/null 2>&1
+}
+
+# CentOS/RHEL globalization workaround
+if [ -f /etc/os-release ]; then
+ . /etc/os-release
+ case "$ID" in
+ centos|rhel)
+ export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+ ;;
+ esac
fi
-if [[ $config ]]; then
- config="$(tr '[:lower:]' '[:upper:]' <<< ${1:0:1})${1:1}"
- config="-c $config"
-else
- config="-c Release"
+# Try native binary first
+if [ -x "$TOOL_BINARY" ]; then
+ exec "$TOOL_BINARY" "$@"
fi
-if [[ $arch == *'aarch'* || $arch == *'arm'* ]]; then
- arch="arm64"
-else
- arch="x64"
+# Try to download native binary
+if download_build_tool 2>/dev/null; then
+ exec "$TOOL_BINARY" "$@"
fi
-if [[ $os == *'centos'* || $os == *'rhel'* ]]; then
- export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+# Fall back to dotnet run
+if has_dotnet; then
+ if [ -f "$BUILD_TOOL_PROJECT" ]; then
+ echo -e "\033[33mUsing dotnet run fallback...\033[0m"
+ exec dotnet run --project "$BUILD_TOOL_PROJECT" -- "$@"
+ else
+ echo "Error: BuildTool project not found at: $BUILD_TOOL_PROJECT" >&2
+ exit 1
+ fi
fi
-echo dotnet tool restore
-dotnet tool restore
-
-echo dotnet clean --verbosity quiet
-dotnet clean --verbosity quiet
-echo dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
-dotnet restore --force-evaluate --source https://api.nuget.org/v3/index.json
-
-echo dotnet publish ${config} ${os}-${arch} --no-restore --self-contained=false
-dotnet publish ${config} ${os}-${arch} --no-restore --self-contained=false
-
-echo Generating serialization migration schema...
-dotnet tool run ModernUOSchemaGenerator -- ModernUO.sln
-
-exit $?
+# Nothing works
+echo ""
+echo -e "\033[31mError: Could not run the build tool.\033[0m"
+echo ""
+echo -e "\033[33mThe .NET 10 SDK is required. Download it from:\033[0m"
+echo -e "\033[36m https://dotnet.microsoft.com/download/dotnet/10.0\033[0m"
+echo ""
+exit 1
diff --git a/version.json b/version.json
index 89634f300..30cc19627 100644
--- a/version.json
+++ b/version.json
@@ -1,4 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
- "version": "0.15.5"
+ "version": "0.15.6"
}