diff --git a/src/Services/SimulationService.cs b/src/Services/SimulationService.cs
new file mode 100644
index 0000000..0e828d9
--- /dev/null
+++ b/src/Services/SimulationService.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using GeneralUpdate.Tools.Models;
+
+namespace GeneralUpdate.Tools.Services;
+
+///
+/// Orchestrates the full update simulation: server, client, upgrade, log collection.
+///
+public class SimulationService
+{
+ private readonly ClientGeneratorService _generator = new();
+ private readonly LocalUpdateServer _server = new();
+ private readonly StringBuilder _fullLog = new();
+ private int _timeoutSeconds = 60;
+
+ public IReadOnlyList LogLines => _fullLog.ToString().Split('\n').ToList();
+
+ public async Task RunAsync(
+ SimulateConfigModel config,
+ IProgress? progress = null,
+ CancellationToken ct = default)
+ {
+ var result = new SimulationResult();
+ var sw = Stopwatch.StartNew();
+
+ try
+ {
+ // 1. Validate
+ Log("STEP 1: Validating inputs", progress);
+ Validate(config);
+
+ // 2. Prepare output directory
+ Log($"STEP 2: Preparing {config.OutputDirectory}", progress);
+ Directory.CreateDirectory(config.OutputDirectory);
+
+ // 3. Copy patch to server working dir
+ Log("STEP 3: Setting up local server", progress);
+ var serverPatchDir = Path.Combine(config.OutputDirectory, ".server");
+ Directory.CreateDirectory(serverPatchDir);
+ var patchName = Path.GetFileName(config.PatchFilePath);
+ var patchDest = Path.Combine(serverPatchDir, patchName);
+ File.Copy(config.PatchFilePath, patchDest, true);
+
+ var hash = ComputeQuickHash(patchDest);
+ LocalUpdateServerFiles.Register(patchName, patchDest);
+ _server.Updates.Add((config.CurrentVersion, config.TargetVersion, hash, patchDest, config.AppType));
+
+ await _server.StartAsync(config.ServerPort);
+ Log($" Server running on {_server.BaseUrl}", progress);
+ config.ServerPort = _server.Port;
+
+ // 4. Generate client/upgrade scripts
+ Log("STEP 4: Generating client.cs and upgrade.cs", progress);
+ await _generator.GenerateAsync(config, config.OutputDirectory);
+ Log($" client.cs → {config.OutputDirectory}", progress);
+ Log($" upgrade.cs → {config.OutputDirectory}", progress);
+
+ // 5. Run client
+ Log("STEP 5: Running client (dotnet run client.cs)", progress);
+ var clientResult = await RunDotNetScript(config.OutputDirectory, "client.cs", ct);
+ Log(clientResult.Output, progress);
+
+ if (!clientResult.Success)
+ {
+ Log(" Client failed - see output above", progress);
+ result.Success = false;
+ result.ErrorMessage = "Client exited with error";
+ return result;
+ }
+
+ Log(" Client completed successfully", progress);
+
+ // 6. Verify the patch was applied
+ Log("STEP 6: Verifying update result", progress);
+ await Task.Delay(2000, ct); // Give upgrade process time to complete
+ VerifyUpdateResult(config, result);
+
+ result.Success = true;
+ result.Elapsed = sw.Elapsed;
+ Log($"✅ Simulation complete ({sw.Elapsed.TotalSeconds:F1}s)", progress);
+ }
+ catch (Exception ex)
+ {
+ result.Success = false;
+ result.ErrorMessage = ex.Message;
+ Log($"❌ Simulation failed: {ex.Message}", progress);
+ }
+ finally
+ {
+ try
+ {
+ await _server.DisposeAsync();
+ LocalUpdateServerFiles.Clear();
+ }
+ catch { }
+
+ result.FullLog = _fullLog.ToString();
+ }
+
+ return result;
+ }
+
+ private void Validate(SimulateConfigModel config)
+ {
+ if (!Directory.Exists(config.AppDirectory))
+ throw new DirectoryNotFoundException($"App directory not found: {config.AppDirectory}");
+
+ if (!File.Exists(config.PatchFilePath))
+ throw new FileNotFoundException($"Patch file not found: {config.PatchFilePath}");
+
+ if (string.IsNullOrWhiteSpace(config.OutputDirectory))
+ throw new ArgumentException("Output directory is required");
+
+ // Check dotnet
+ try
+ {
+ var psi = new ProcessStartInfo("dotnet", "--version")
+ {
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ using var p = Process.Start(psi);
+ p?.WaitForExit(5000);
+ var ver = p?.StandardOutput.ReadToEnd().Trim();
+ if (string.IsNullOrEmpty(ver) || !ver.StartsWith("10.") && !ver.StartsWith("11."))
+ throw new InvalidOperationException(".NET 10.0 SDK is required. Install from https://dotnet.microsoft.com/");
+ }
+ catch (InvalidOperationException) { throw; }
+ catch { throw new InvalidOperationException("dotnet CLI not found. Install .NET 10.0 SDK."); }
+ }
+
+ private async Task<(bool Success, string Output)> RunDotNetScript(string workDir, string script, CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo("dotnet", $"run {script}")
+ {
+ WorkingDirectory = workDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var p = Process.Start(psi)!;
+ var output = new StringBuilder();
+
+ var readTask = Task.Run(async () =>
+ {
+ while (!p.StandardOutput.EndOfStream)
+ output.AppendLine(await p.StandardOutput.ReadLineAsync(ct));
+ }, ct);
+
+ var errorTask = Task.Run(async () =>
+ {
+ while (!p.StandardError.EndOfStream)
+ output.AppendLine(await p.StandardError.ReadLineAsync(ct));
+ }, ct);
+
+ // Wait with timeout
+ var completed = p.WaitForExit(_timeoutSeconds * 1000);
+ if (!completed)
+ {
+ p.Kill(true);
+ return (false, output + "\n[TIMEOUT] Simulation exceeded time limit");
+ }
+
+ await Task.WhenAll(readTask, errorTask);
+ return (p.ExitCode == 0, output.ToString());
+ }
+
+ private void VerifyUpdateResult(SimulateConfigModel config, SimulationResult result)
+ {
+ // Check if delete_files.json was consumed (it should be gone or applied)
+ var deleteFile = Path.Combine(config.AppDirectory, "delete_files.json");
+ if (File.Exists(deleteFile))
+ {
+ result.Notes.Add("delete_files.json still present - HandleDeleteList may not have run");
+ }
+
+ // Count files changed in app directory
+ var fileCount = Directory.GetFiles(config.AppDirectory, "*", SearchOption.AllDirectories).Length;
+ result.Notes.Add($"Files in app directory after update: {fileCount}");
+ }
+
+ private static string ComputeQuickHash(string filePath)
+ {
+ using var sha = System.Security.Cryptography.SHA256.Create();
+ using var fs = File.OpenRead(filePath);
+ return BitConverter.ToString(sha.ComputeHash(fs)).Replace("-", "").ToLowerInvariant();
+ }
+
+ private void Log(string msg, IProgress? progress)
+ {
+ var line = $"[{DateTime.Now:HH:mm:ss}] {msg}";
+ _fullLog.AppendLine(line);
+ progress?.Report(line);
+ }
+}
+
+public class SimulationResult
+{
+ public bool Success { get; set; }
+ public string? ErrorMessage { get; set; }
+ public TimeSpan Elapsed { get; set; }
+ public string FullLog { get; set; } = "";
+ public List Notes { get; } = new();
+}
diff --git a/src/ViewModels/SimulateViewModel.cs b/src/ViewModels/SimulateViewModel.cs
index 211be8d..600be07 100644
--- a/src/ViewModels/SimulateViewModel.cs
+++ b/src/ViewModels/SimulateViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
+using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -11,6 +12,7 @@ namespace GeneralUpdate.Tools.ViewModels;
public partial class SimulateViewModel : ViewModelBase
{
private readonly LocalizationService _loc = LocalizationService.Instance;
+ private readonly SimulationService _sim = new();
public SimulateConfigModel Config { get; } = new();
@@ -62,11 +64,44 @@ public SimulateViewModel()
[RelayCommand]
async Task StartSimulation()
{
- // Validation will be implemented in issue #4
- if (string.IsNullOrWhiteSpace(Config.AppDirectory)) { Status = "请选择旧版本应用目录"; return; }
- if (string.IsNullOrWhiteSpace(Config.PatchFilePath)) { Status = "请选择补丁包文件"; return; }
- if (string.IsNullOrWhiteSpace(Config.OutputDirectory)) { Status = "请选择模拟输出目录"; return; }
- Status = "模拟功能将在后续 issue 中实现";
+ if (string.IsNullOrWhiteSpace(Config.AppDirectory)) { Status = _loc["Sim.ValidateDirs"]; return; }
+ if (string.IsNullOrWhiteSpace(Config.PatchFilePath)) { Status = _loc["Sim.ValidateDirs"]; return; }
+ if (string.IsNullOrWhiteSpace(Config.OutputDirectory)) { Status = _loc["Sim.ValidateDirs"]; return; }
+
+ IsRunning = true;
+ Log.Clear();
+ Status = "Starting simulation...";
+
+ try
+ {
+ var progress = new Progress(L);
+ var result = await _sim.RunAsync(Config, progress);
+
+ if (result.Success)
+ {
+ Status = $"Simulation completed ({result.Elapsed.TotalSeconds:F1}s)";
+ L($"Result: {(result.Success ? "PASS" : "FAIL")}");
+ foreach (var note in result.Notes)
+ L($" Note: {note}");
+
+ // Generate report
+ var reportPath = Path.Combine(Config.OutputDirectory, "simulation_report.md");
+ // report generation will be in next PR
+ }
+ else
+ {
+ Status = $"Simulation failed: {result.ErrorMessage}";
+ }
+ }
+ catch (Exception ex)
+ {
+ Status = $"Error: {ex.Message}";
+ L($"FATAL: {ex}");
+ }
+ finally
+ {
+ IsRunning = false;
+ }
}
void L(string msg) => Log.Add($"[{DateTime.Now:HH:mm:ss}] {msg}");