NewLife/LuckyClover

fix: clover 补齐架构识别、SP/WIC 预检与退出码提示

实测暴露的问题集中修正:

1) 架构:新增 PE Machine 解析,x64 安装包装到 32 位系统时直接跳过并报错(此前只得到「不是有效的 Win32 应用程序」);.NET2.0 在 64 位系统改下载 NetFx20SP2_x64.exe(x86 包会在会话 0 挂起);32 位进程跑在 64 位系统上不再用 IntPtr.Size 判断系统架构。
2) SP 预检:.NET2.0 要求 XP SP2 / 2003 SP1,低于该版本直接给结论,避免安装器 300s 无响应;4.0 补充 XP SP3 / 2003 SP2 提示。
3) WIC:XP/2003 装 4.0 前自动尝试 wic_x86/wic_x64,装不上时明确说明 1603 的原因。
4) 退出码翻译:1603/5100/1638/1618/5/216,失败时直接给出可执行建议。
5) net11 的支持性判定此前恒为 false,已修正(Shared 与 C++ 同步)。
6) 横幅新增「系统/进程位数」,便于区分 x86/x64 产物。
7) 无参数且无交互输入时打印用法,修掉 Linux 下的 ArgumentNullException。
8) AOT 产物在输入/输出被重定向时跳过 ReadKey,修掉非交互环境 0xC0000409。
大石头 authored at 2026-11-02 09:01:16
2c5d55d
Tree
1 Parent(s) c63388d
Summary: 9 changed files with 333 additions and 12 deletions.
Modified +5 -1
Modified +6 -0
Modified +2 -0
Modified +110 -3
Modified +2 -2
Modified +10 -1
Modified +174 -2
Modified +22 -1
Modified +2 -2
Modified +5 -1
diff --git a/.github/skills/vm-compat-matrix/SKILL.md b/.github/skills/vm-compat-matrix/SKILL.md
index 5262f08..c69cc6e 100644
--- a/.github/skills/vm-compat-matrix/SKILL.md
+++ b/.github/skills/vm-compat-matrix/SKILL.md
@@ -84,10 +84,14 @@ dotnet Tools/VmTest.cs stop "<虚拟机名>"
 | `-998` | vmrun/VIX 调用失败(Tools 掉线/未运行) | `net start vmtools` 后按 `--filter` 单独重跑 |
 | `-999` | 客机命令超时(多半缺运行时弹安装框) | 看 `.tmp\vmtest\shots\*_timeout.png` |
 
-**AOT 教训(2026-11-02)**:`cloverAot` 末尾有 `Console.ReadKey()`,只要 stdout/stdin 不是真控制台就抛 `InvalidOperation_ConsoleReadKeyOnFile` 并以 `0xC0000409` 退出——宿主 Windows 11 同样复现,**与客机系统版本无关**。`Classify` 已见到该异常直接判 ✅。别再把「出 banner 后异常」写成系统兼容性结论。
+**AOT 教训(2026-11-02)**:`cloverAot` 末尾有 `Console.ReadKey()`,只要 stdout/stdin 不是真控制台就抛 `InvalidOperation_ConsoleReadKeyOnFile` 并以 `0xC0000409` 退出——宿主 Windows 11 同样复现,**与客机系统版本无关**。v1.2 起产物已改为「重定向时跳过按键」,但 `Classify` 仍保留该识别(旧产物、旧快照里拷出来的二进制还会遇到)。别再把「出 banner 后异常」写成系统兼容性结论。
 
 **自包含 vs 框架依赖**:`clover10-x86.exe` 是自包含单文件,跑通只说明该系统的 Win32 接口够用,**不代表装了 .NET 运行时**;`clover10.exe`(x64 框架依赖)才验证运行时。两者不一致时以 x64 为准并在备注写清。
 
+**测前必须重建产物**:`Bin\vmtest` 里可能残留 `Bin\publish` 的旧版本(曾出现 clover 只认 `net2|net35|net4|net45|net48` 的 v1.1 老二进制被当成"不支持 net6/8/10")。跑矩阵前先 `dotnet Tools/VmTest.cs build`,再用 `clover.exe list` 确认 banner 版本与自动计划符合预期。
+
+**clover 已具备的预检(v1.2)**:架构识别(PE Machine 校验,x64 包装到 32 位系统直接跳过并报错)、SP 预检(.NET 2.0 要求 XP SP2 / 2003 SP1,避免安装器长时间无响应)、WIC 预检(XP/2003 装 4.0 前自动尝试 `wic_x86/x64.exe`)、退出码翻译(1603/5100/1638/1618/5)。
+
 ## 回写 `Doc/os_net.md` 的硬要求
 
 - 一行一结论,改动只碰对应行;新增系统插到同族末尾
Modified +6 -0
diff --git a/Clover/CGetOSVersion.cpp b/Clover/CGetOSVersion.cpp
index 1565562..03265d0 100644
--- a/Clover/CGetOSVersion.cpp
+++ b/Clover/CGetOSVersion.cpp
@@ -34,6 +34,7 @@ CGetOSVersion::CGetOSVersion()
     m_Info.Build = 0;
     m_Info.IsServer = false;
     m_Info.Is64Bit = false;
+    m_Info.ServicePack = 0;
 }
 
 CGetOSVersion::~CGetOSVersion()
@@ -71,6 +72,7 @@ bool CGetOSVersion::CheckOSVer()
     m_Info.Major = 0;
     m_Info.Minor = 0;
     m_Info.Build = 0;
+    m_Info.ServicePack = 0;
     m_Info.IsServer = IsServerOS();
     m_Info.Desc = "未知的系统版本";
 
@@ -131,6 +133,10 @@ bool CGetOSVersion::CheckOSVer()
 
     if (bExcute) m_Info.Desc = FormatDesc(m_Info.Major, m_Info.Minor, m_Info.Build, m_Info.IsServer, csd);
 
+    // 从 CSDVersion(如 "Service Pack 3")解析 Service Pack 序号
+    auto ps = csd.find("Service Pack");
+    if (ps != std::string::npos) m_Info.ServicePack = (DWORD)atoi(csd.c_str() + ps + 12);
+
     return bExcute;
 }
 
Modified +2 -0
diff --git a/Clover/CGetOSVersion.h b/Clover/CGetOSVersion.h
index 1bfbd75..dc5bbf1 100644
--- a/Clover/CGetOSVersion.h
+++ b/Clover/CGetOSVersion.h
@@ -14,6 +14,8 @@ struct OSVersionInfo
     bool IsServer;
     /// <summary>是否64位系统</summary>
     bool Is64Bit;
+    /// <summary>Service Pack 序号,如 3;取不到时为 0</summary>
+    DWORD ServicePack;
     /// <summary>版本描述,如 Windows 7 SP1</summary>
     std::string Desc;
 };
Modified +110 -3
diff --git a/Clover/Clover.cpp b/Clover/Clover.cpp
index f23aac7..d57a122 100644
--- a/Clover/Clover.cpp
+++ b/Clover/Clover.cpp
@@ -22,6 +22,10 @@ static string _server = "http://x.newlifex.com";
 static string _serverPath = "/dotnet/";
 static OSVersionInfo _os;
 
+// 前置声明:这两个函数在很多位置被调用,定义见文件后部
+static bool InstallPackage(const string& fileName, const string& args);
+static string ExplainExitCode(DWORD code);
+
 /// <summary>获取程序所在目录</summary>
 static string GetExeDir()
 {
@@ -161,6 +165,83 @@ static bool RunProcess(const string& file, const string& args)
 
     cout << "安装失败!退出码:" << code << endl;
 
+    auto tip = ExplainExitCode(code);
+    if (!tip.empty()) cout << "原因:" << tip << endl;
+
+    return false;
+}
+
+/// <summary>把安装器退出码翻译成可执行的建议</summary>
+static string ExplainExitCode(DWORD code)
+{
+    switch (code)
+    {
+    case 5: return "拒绝访问,需要以管理员身份运行";
+    case 1603: return "致命错误:XP/2003 常见原因是缺少 WIC(Windows Imaging Component),也可能是权限不足、源文件损坏或已存在更高版本";
+    case 5100: return "系统中已存在更高版本的 .NET";
+    case 1638: return "已安装同版本或更高版本";
+    case 1618: return "另一个安装正在进行,请等待其完成后重试";
+    default: return "";
+    }
+}
+
+/// <summary>读取 PE 文件的目标架构(IMAGE_FILE_MACHINE_*),不是 PE 文件时返回 0</summary>
+static DWORD GetPeMachine(const string& file)
+{
+    auto h = CreateFileA(file.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+    if (h == INVALID_HANDLE_VALUE) return 0;
+
+    DWORD machine = 0;
+    BYTE head[1024];
+    DWORD read = 0;
+    if (ReadFile(h, head, sizeof(head), &read, NULL) && read >= 0x40 && head[0] == 'M' && head[1] == 'Z')
+    {
+        auto pe = *(DWORD*)(head + 0x3C);
+        if (pe > 0 && pe + 6 <= read && head[pe] == 'P' && head[pe + 1] == 'E') machine = *(WORD*)(head + pe + 4);
+    }
+    CloseHandle(h);
+
+    return machine;
+}
+
+/// <summary>校验安装包架构与本机是否匹配</summary>
+static bool CheckArch(const string& file)
+{
+    auto machine = GetPeMachine(file);
+    if (machine == 0) return true;
+
+    auto is64 = machine == 0x8664 || machine == 0xAA64 || machine == 0x0200;
+    auto name = machine == 0x014C ? "x86" : machine == 0x8664 ? "x64" : machine == 0xAA64 ? "arm64" : "ia64";
+
+    // 架构不符时尽早报错,否则只会得到“不是有效的 Win32 应用程序”这类无效信息
+    if (is64 && !_os.Is64Bit)
+    {
+        cout << "跳过 " << name << " 版安装包,当前系统只有 32 位:" << file << endl;
+
+        return false;
+    }
+
+    return true;
+}
+
+/// <summary>XP/2003 安装 .NET4.0 的前置:Windows Imaging Component</summary>
+static bool EnsureWic()
+{
+    char win[MAX_PATH] = { 0 };
+    if (GetWindowsDirectoryA(win, MAX_PATH) > 0)
+    {
+        auto dll = string(win) + "\\System32\\WindowsCodecs.dll";
+        if (GetFileAttributesA(dll.c_str()) != INVALID_FILE_ATTRIBUTES) return true;
+    }
+
+    auto file = _os.Is64Bit ? "wic_x64.exe" : "wic_x86.exe";
+    cout << "缺少 WIC(Windows Imaging Component),先安装 " << file << endl;
+
+    auto args = _silent ? string("/quiet /norestart") : string();
+    if (InstallPackage(file, args)) return true;
+
+    cout << "WIC 未能自动安装,.NET4.0 会以 1603 失败;请先手动安装 WIC 后重试" << endl;
+
     return false;
 }
 
@@ -174,6 +255,8 @@ static bool InstallPackage(const string& fileName, const string& args)
         return false;
     }
 
+    if (!CheckArch(file)) return false;
+
     return RunProcess(file, args);
 }
 
@@ -197,8 +280,8 @@ static bool IsSupported(const string& version)
         return true;
     }
 
-    // .NET8/10 仅支持 Server 2012/2012 R2 及以上
-    if (major == 8 || major == 10) return _os.IsServer && _os.Major == 6 && _os.Minor >= 2;
+    // .NET8/10/11 仅支持 Server 2012/2012 R2 及以上
+    if (major == 8 || major == 10 || major == 11) return _os.IsServer && _os.Major == 6 && _os.Minor >= 2;
 
     return false;
 }
@@ -282,7 +365,21 @@ static bool InstallNet20(CRegistryVisit& reg)
         return true;
     }
 
-    return InstallPackage("NetFx20SP2_x86.exe", _silent ? "/quiet /norestart" : "");
+    // .NET2.0 SP2 要求 XP SP2 / 2003 SP1 及以上;低于该版本时安装器会长时间无响应,先给出结论
+    if (_os.Major == 5)
+    {
+        auto need = _os.Minor == 1 ? 2 : 1;
+        if (_os.ServicePack < need && !_force)
+        {
+            cout << "当前系统 Service Pack " << _os.ServicePack << " 低于 .NET2.0 要求的 SP" << need << ",请先安装系统补丁包,或加 -force 强制尝试" << endl;
+            return false;
+        }
+    }
+
+    // 64 位系统装 x64 包:x86 包会挂起在会话 0(实测 2003 R2 x64)
+    auto file = _os.Is64Bit ? "NetFx20SP2_x64.exe" : "NetFx20SP2_x86.exe";
+
+    return InstallPackage(file, _silent ? "/quiet /norestart" : "");
 }
 
 /// <summary>安装 .NET Framework 4.0</summary>
@@ -300,6 +397,16 @@ static bool InstallNet40(CRegistryVisit& reg)
         return true;
     }
 
+    if (_os.Major <= 5)
+    {
+        // .NET4.0 官方要求 XP SP3 / 2003 SP2,低版本安装器会以 1603 失败
+        auto need = _os.Minor == 1 ? 3 : 2;
+        if (_os.ServicePack < need && !_force) cout << "注意:.NET4.0 要求 SP" << need << ",当前为 SP" << _os.ServicePack << ",安装可能失败" << endl;
+
+        // XP/2003 上 .NET4.0 依赖 WIC,缺了必然 1603
+        EnsureWic();
+    }
+
     return InstallPackage("dotNetFx40_Full_x86_x64.exe", _silent ? "/passive /norestart" : "");
 }
 
Modified +2 -2
diff --git a/Doc/os_net.md b/Doc/os_net.md
index 657168f..0a02d62 100644
--- a/Doc/os_net.md
+++ b/Doc/os_net.md
@@ -194,8 +194,8 @@
 
 | 编号 | 现象 | 范围 | 说明 |
 |---|---|---|---|
-| FIX-1 | Linux 下无参数运行 `clover` 会抛 `ArgumentNullException`(`Program.cs:155`) | 全部 Linux 架构(x64/arm64 均复现) | stdin 为空时 `ShowMenu()` 返回 null,随后 `_menus.TryGetValue(null)` 抛异常。带任意参数运行正常,退出码 0 |
-| FIX-2 | `cloverAot` 在非交互环境(stdout/stdin 被重定向、无控制台)退出码 `0xC0000409` | 全部 Windows(宿主 Win11 与各客机同样复现,与系统版本无关) | 产物末尾 `Console.ReadKey()` 等按键,无控制台时抛 `InvalidOperation_ConsoleReadKeyOnFile`。**交互式双击/命令行运行退出码为 0**,因此早期"各系统 AOT 出 banner 后异常"的结论是测试夹具误判,已按可运行修正 |
+| FIX-1 | Linux 下无参数运行 `clover` 会抛 `ArgumentNullException`(`Program.cs:155`) | 全部 Linux 架构(x64/arm64 均复现) | stdin 为空时 `ShowMenu()` 返回 null,随后 `_menus.TryGetValue(null)` 抛异常。带任意参数运行正常,退出码 0。**v1.2 已修复**:`cmd` 为空时打印用法,退出码 0 |
+| FIX-2 | `cloverAot` 在非交互环境(stdout/stdin 被重定向、无控制台)退出码 `0xC0000409` | 全部 Windows(宿主 Win11 与各客机同样复现,与系统版本无关) | 产物末尾 `Console.ReadKey()` 等按键,无控制台时抛 `InvalidOperation_ConsoleReadKeyOnFile`。**交互式双击/命令行运行退出码为 0**,因此早期"各系统 AOT 出 banner 后异常"的结论是测试夹具误判,已按可运行修正。**v1.2 已修复**:输入/输出被重定向时跳过按键等待,退出码 0 |
 
 ---
 
Modified +10 -1
diff --git a/LuckyAOT/Program.cs b/LuckyAOT/Program.cs
index 5b17321..116e7a8 100644
--- a/LuckyAOT/Program.cs
+++ b/LuckyAOT/Program.cs
@@ -37,7 +37,16 @@ internal class Program
         Console.WriteLine("发布:{0:yyyy-MM-dd HH:mm:ss}", GetCompileTime(ver));
         Console.WriteLine();
 
-        Console.ReadKey();
+        // 非交互环境(输入/输出被重定向、无控制台)不能 ReadKey,否则抛
+        // InvalidOperation_ConsoleReadKeyOnFile 并以 0xC0000409 退出,看起来像兼容性崩溃
+        if (!Console.IsInputRedirected && !Console.IsOutputRedirected)
+        {
+            try
+            {
+                Console.ReadKey();
+            }
+            catch (InvalidOperationException) { }
+        }
     }
 
     /// <summary>根据版本号计算得到编译时间</summary>
Modified +174 -2
diff --git a/LuckyClover/NetRuntime.cs b/LuckyClover/NetRuntime.cs
index b4b05bd..6c8fd34 100644
--- a/LuckyClover/NetRuntime.cs
+++ b/LuckyClover/NetRuntime.cs
@@ -45,6 +45,81 @@ public class NetRuntime
 
     /// <summary>文件哈希。用于校验下载文件的完整性</summary>
     public IDictionary<String, String> Hashs { get; set; }
+
+    /// <summary>是否 64 位操作系统</summary>
+    /// <remarks>32 位进程跑在 64 位系统上时,IntPtr.Size/Is64BitProcess 都只反映进程,必须看 PROCESSOR_ARCHITEW6432</remarks>
+    public static Boolean Is64BitOS
+    {
+        get
+        {
+#if NET20
+            var arch = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITEW6432");
+            if (String.IsNullOrEmpty(arch)) arch = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE");
+            return "AMD64".Equals(arch, StringComparison.OrdinalIgnoreCase)
+                || "IA64".Equals(arch, StringComparison.OrdinalIgnoreCase)
+                || "ARM64".Equals(arch, StringComparison.OrdinalIgnoreCase);
+#else
+            return Environment.Is64BitOperatingSystem;
+#endif
+        }
+    }
+
+    /// <summary>当前进程位数</summary>
+    public static Int32 ProcessBits => IntPtr.Size * 8;
+
+    /// <summary>操作系统位数</summary>
+    public static Int32 OSBits => Is64BitOS ? 64 : 32;
+
+    /// <summary>获取系统 Service Pack 序号,取不到时为 0</summary>
+    public static Int32 GetServicePack()
+    {
+        var sp = Environment.OSVersion.ServicePack + "";
+        var p = sp.LastIndexOf(' ');
+        if (p >= 0 && Int32.TryParse(sp.Substring(p + 1).Trim(), out var n)) return n;
+
+        return 0;
+    }
+
+    /// <summary>读取 PE 文件的目标架构(IMAGE_FILE_MACHINE_*)。不是 PE 文件时返回 0</summary>
+    /// <param name="file">文件路径</param>
+    /// <returns>目标机器码,0x14C=x86、0x8664=x64、0xAA64=ARM64、0x200=IA64</returns>
+    public static Int32 GetPeMachine(String file)
+    {
+        try
+        {
+            var head = new Byte[1024];
+            using (var fs = File.OpenRead(file))
+            {
+                if (fs.Read(head, 0, head.Length) < 0x40) return 0;
+            }
+            if (head[0] != (Byte)'M' || head[1] != (Byte)'Z') return 0;
+
+            var pe = BitConverter.ToInt32(head, 0x3C);
+            if (pe <= 0 || pe + 6 > head.Length) return 0;
+            if (head[pe] != (Byte)'P' || head[pe + 1] != (Byte)'E') return 0;
+
+            return BitConverter.ToUInt16(head, pe + 4);
+        }
+        catch
+        {
+            return 0;
+        }
+    }
+
+    /// <summary>把 PE 机器码翻译成架构名称</summary>
+    /// <param name="machine">PE 机器码</param>
+    /// <returns>架构名称,未知时返回空</returns>
+    public static String GetMachineName(Int32 machine)
+    {
+        switch (machine)
+        {
+            case 0x014C: return "x86";
+            case 0x8664: return "x64";
+            case 0xAA64: return "arm64";
+            case 0x0200: return "ia64";
+            default: return "";
+        }
+    }
     #endregion
 
     #region 方法
@@ -63,7 +138,9 @@ public class NetRuntime
                 case "net45": InstallNet45(); break;
                 case "net48": InstallNet48(); break;
                 case "net6": InstallNet("v6.0", Version6, kind); break;
+                case "net8": InstallNet("v8.0", Version8, kind); break;
                 case "net10": InstallNet("v10.0", Version10, kind); break;
+                case "net11": InstallNet("v11.0", Version11, kind); break;
             }
         }
     }
@@ -177,6 +254,13 @@ public class NetRuntime
         var fullFile = Download(fileName, baseUrl);
         if (String.IsNullOrEmpty(fullFile)) return false;
 
+        // 安装包架构与本机不符时尽早报错,否则只会得到“不是有效的 Win32 应用程序”这类无效信息
+        if (IsWindows && !CheckArch(fullFile))
+        {
+            Environment.ExitCode = 216;     // ERROR_EXE_MACHINE_TYPE_MISMATCH
+            return false;
+        }
+
         WriteLog("正在安装:{0} {1}", fullFile, arg);
 
         if (IsWindows)
@@ -185,6 +269,60 @@ public class NetRuntime
             return InstallOnLinux(fullFile, arg);
     }
 
+    /// <summary>校验安装包架构与本机是否匹配</summary>
+    /// <param name="fullFile">安装包路径</param>
+    /// <returns>可安装返回 true;架构不符返回 false</returns>
+    Boolean CheckArch(String fullFile)
+    {
+        var machine = GetPeMachine(fullFile);
+        if (machine == 0) return true;
+
+        var name = GetMachineName(machine);
+        var is64 = machine == 0x8664 || machine == 0xAA64 || machine == 0x0200;
+
+        if (is64 && !Is64BitOS)
+        {
+            WriteLog("跳过 {0}:安装包是 {1} 版,当前系统只有 32 位", Path.GetFileName(fullFile), name);
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /// <summary>把安装器退出码翻译成可执行的建议</summary>
+    /// <param name="code">退出码</param>
+    /// <returns>建议文案,无建议时返回空</returns>
+    static String ExplainExitCode(Int32 code)
+    {
+        switch (code)
+        {
+            case 5: return "拒绝访问,需要以管理员身份运行";
+            case 1603: return "致命错误:XP/2003 常见原因是缺少 WIC(Windows Imaging Component),也可能是权限不足、源文件损坏或已存在更高版本";
+            case 5100: return "系统中已存在更高版本的 .NET";
+            case 1638: return "已安装同版本或更高版本";
+            case 1618: return "另一个安装正在进行,请等待其完成后重试";
+            case 216: return "安装包架构与系统不符(如把 x64 包装到 32 位系统)";
+            default: return "";
+        }
+    }
+
+    /// <summary>XP/2003 上安装 .NET4.0 的前置:Windows Imaging Component</summary>
+    /// <returns>已安装或安装成功返回 true</returns>
+    Boolean EnsureWic()
+    {
+        var dll = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsCodecs.dll");
+        if (File.Exists(dll)) return true;
+
+        var file = Is64BitOS ? "wic_x64.exe" : "wic_x86.exe";
+        WriteLog("缺少 WIC(Windows Imaging Component),先安装 {0}", file);
+        if (Install(file, null, Silent ? "/quiet /norestart" : null)) return true;
+
+        WriteLog("WIC 未能自动安装({0}),.NET4.0 会以 1603 失败;请先手动安装 WIC 后重试", file);
+
+        return false;
+    }
+
     Boolean InstallOnWindows(String fullFile, String? arg)
     {
         var p = Process.Start(fullFile, arg + "");
@@ -197,7 +335,11 @@ public class NetRuntime
             if (rs)
                 WriteLog(code == 0 ? "安装完成!" : $"安装完成,需要重启系统!ExitCode={code}");
             else
+            {
                 WriteLog("安装失败!ExitCode={0}", code);
+                var tip = ExplainExitCode(code);
+                if (!String.IsNullOrEmpty(tip)) WriteLog("原因:{0}", tip);
+            }
             Environment.ExitCode = code;
 
             return rs;
@@ -299,7 +441,24 @@ public class NetRuntime
             return true;
         }
 
-        return Install("NetFx20SP2_x86.exe");
+        // .NET2.0 SP2 要求 XP SP2 / 2003 SP1 及以上;低于该版本时安装器会长时间无响应,先给出结论
+        var osVer = Environment.OSVersion.Version;
+        if (osVer.Major == 5)
+        {
+            var sp = GetServicePack();
+            var need = osVer.Minor == 1 ? 2 : 1;
+            if (sp < need && !Force)
+            {
+                WriteLog("当前系统 Service Pack {0} 低于 .NET2.0 要求的 SP{1},请先安装系统补丁包,或加 -force 强制尝试", sp, need);
+
+                return false;
+            }
+        }
+
+        // 64 位系统装 x64 包:x86 包会挂起在会话 0(实测 2003 R2 x64)
+        var file = Is64BitOS ? "NetFx20SP2_x64.exe" : "NetFx20SP2_x86.exe";
+
+        return Install(file);
     }
 
     /// <summary>安装.NET4.0</summary>
@@ -322,6 +481,19 @@ public class NetRuntime
             return true;
         }
 
+        var osVer = Environment.OSVersion.Version;
+
+        // .NET4.0 官方要求 XP SP3 / 2003 SP2,低版本安装器会以 1603 失败
+        if (osVer.Major == 5 && !Force)
+        {
+            var sp = GetServicePack();
+            var need = osVer.Minor == 1 ? 3 : 2;
+            if (sp < need) WriteLog("注意:.NET4.0 要求 SP{0},当前为 SP{1},安装可能失败", need, sp);
+        }
+
+        // XP/2003 上 .NET4.0 依赖 WIC,缺了必然 1603
+        if (osVer.Major <= 5) EnsureWic();
+
         var rs = Install("dotNetFx40_Full_x86_x64.exe", null);
         if (!rs)
         {
@@ -450,7 +622,7 @@ public class NetRuntime
         }
 
 #if NET20
-        var is64 = IntPtr.Size == 8;
+        var is64 = Is64BitOS;
 #else
         var is64 = Environment.Is64BitOperatingSystem;
 #endif
Modified +22 -1
diff --git a/LuckyClover/Program.cs b/LuckyClover/Program.cs
index 1b696b8..81ea6da 100644
--- a/LuckyClover/Program.cs
+++ b/LuckyClover/Program.cs
@@ -49,6 +49,7 @@ internal class Program
             Console.WriteLine(asm.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description);
 #endif
             Console.WriteLine("{0}", Environment.OSVersion);
+            Console.WriteLine("架构:系统 {0} 位,进程 {1} 位", NetRuntime.OSBits, NetRuntime.ProcessBits);
             Console.WriteLine("运行时:\e[34;1m{0}\e[0m", Environment.Version);
             Console.WriteLine("发布:{0:yyyy-MM-dd HH:mm:ss}", GetCompileTime(ver));
             Console.WriteLine();
@@ -150,7 +151,15 @@ internal class Program
         _menus["untar"] = () => new TarCommand().Extract(args);
 #endif
 
-        if (String.IsNullOrEmpty(cmd)) cmd = ShowMenu();
+        if (String.IsNullOrEmpty(cmd)) cmd = ShowMenu() ?? "";
+
+        // 无参数且没有交互输入时 cmd 为空,直接给帮助,避免用 null 去查菜单字典
+        if (String.IsNullOrEmpty(cmd))
+        {
+            PrintUsage();
+
+            return;
+        }
 
         if (_menus.TryGetValue(cmd, out var func))
             func();
@@ -158,6 +167,18 @@ internal class Program
             Console.WriteLine("无法识别命令:{0}", cmd);
     }
 
+    private static void PrintUsage()
+    {
+        Console.WriteLine("用法:clover <命令> [选项]");
+        Console.WriteLine("  net20|net40|net45|net48|net6|net8|net10|net11   安装指定运行时");
+        Console.WriteLine("  net6-desktop|net8-desktop|net10-desktop         安装桌面运行时");
+        Console.WriteLine("  net6-aspnet|net8-aspnet|net10-aspnet            安装 ASP.NET Core 运行时");
+        Console.WriteLine("  auto|net                                        按操作系统自动选择");
+        Console.WriteLine("  md5 [pattern]                                   列出文件校验值");
+        Console.WriteLine("  zip|unzip|tar|untar                             压缩解压");
+        Console.WriteLine("选项:-silent 静默安装;-force 强制安装;-dryrun 只打印计划;<url> 指定下载服务器");
+    }
+
     private static String ShowMenu()
     {
         var vers = new List<VerInfo>();
Modified +2 -2
diff --git a/Shared/NetVersions.cs b/Shared/NetVersions.cs
index ced5c06..082c7aa 100644
--- a/Shared/NetVersions.cs
+++ b/Shared/NetVersions.cs
@@ -111,8 +111,8 @@ public static class NetVersions
             return true;
         }
 
-        // .NET8/10 仅支持 Server 2012/2012 R2 及以上
-        if (major == 8 || major == 10) return isServer && osVer.Major == 6 && osVer.Minor >= 2;
+        // .NET8/10/11 仅支持 Server 2012/2012 R2 及以上
+        if (major is 8 or 10 or 11) return isServer && osVer.Major == 6 && osVer.Minor >= 2;
 
         return false;
     }