using System.Diagnostics; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// Wraps BlueMap CLI as an out-of-process renderer. /// /// The CLI jar is downloaded from GitHub releases on first render (mirrors the /// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets /// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also /// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC. /// /// Renders are kicked off manually from the panel and produce static HTML/JS/PNG /// output served at /map/. Zero impact on the running MC server (separate JVM, /// separate memory pool -- only competes for disk I/O during render). /// public sealed class BlueMapService : IDisposable { private const string BlueMapVersion = "5.20"; private const string BlueMapJarUrl = "https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v" + BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar"; private const int BlueMapJavaVersion = 25; private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) }; private readonly ServerConfig _config; private readonly Action _log; private readonly SemaphoreSlim _gate = new(1, 1); private Process? _renderProc; public sealed class RenderState { public bool InProgress { get; set; } public string Phase { get; set; } = "idle"; // "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed" public string? Error { get; set; } public DateTimeOffset? StartedAt { get; set; } public DateTimeOffset? FinishedAt { get; set; } public int? ExitCode { get; set; } public string? LastLogLine { get; set; } } public RenderState State { get; private set; } = new(); public string RootDir { get { // Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default // is sibling of serverDir so it doesn't bloat the world dir or the // server install -- typically ~/brass-sigil-server/bluemap. if (!string.IsNullOrWhiteSpace(_config.BlueMapDir)) return Path.GetFullPath(_config.BlueMapDir); var serverFull = Path.GetFullPath(_config.ServerDir); var parent = Path.GetDirectoryName(serverFull) ?? serverFull; return Path.Combine(parent, "bluemap"); } } public string CliJarPath => Path.Combine(RootDir, "cli.jar"); public string WebDir => Path.Combine(RootDir, "web"); public string ConfigDir => Path.Combine(RootDir, "config"); public BlueMapService(ServerConfig config, Action log) { _config = config; _log = log; } public bool HasRendered => Directory.Exists(WebDir) && File.Exists(Path.Combine(WebDir, "index.html")); /// Kick off a render in the background. Returns false if one is already running. public bool StartRender() { if (!_gate.Wait(0)) return false; State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow }; _ = Task.Run(RenderAsync); return true; } /// /// Cancel an in-progress render by killing the BlueMap process. State on disk /// is preserved, so the next render resumes from where this one stopped. /// public bool CancelRender() { if (!State.InProgress) return false; try { // Kill the whole process tree (the BlueMap CLI may spawn worker JVMs // for parallel rendering). entireProcessTree=true on Linux uses // process group; on Windows it walks the tree via Job Objects. _renderProc?.Kill(entireProcessTree: true); State.Phase = "cancelled"; State.Error = "Cancelled by user."; _log("[bluemap] Render cancelled by user."); return true; } catch (Exception ex) { _log($"[bluemap] Cancel failed: {ex.Message}"); return false; } } /// /// Delete rendered map output + render state. Used after a world wipe -- the /// old tiles reference terrain that no longer exists. Preserves cli.jar and /// configs so a follow-up Render still works (and skips re-download + /// re-config). Returns true if anything was deleted. /// public bool ClearRenderOutput() { var dataDir = Path.Combine(RootDir, "data"); var mapsDir = Path.Combine(RootDir, "web", "maps"); var anyDeleted = false; try { if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); } if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); } } catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); } return anyDeleted; } /// /// Called when brass-sigil-server itself shuts down -- kill any in-flight /// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after /// the daemon's gone. Render state on disk is preserved; next start can /// resume the render exactly where this one was killed. /// public void Dispose() { try { if (_renderProc is { HasExited: false }) { _renderProc.Kill(entireProcessTree: true); _renderProc.WaitForExit(2000); } } catch { } _renderProc?.Dispose(); _gate.Dispose(); } private async Task RenderAsync() { try { Directory.CreateDirectory(RootDir); State.Phase = "downloading"; await DownloadJarIfMissingAsync(); // BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a // separate JRE 25 install for BlueMap only. var bluemapJava = await EnsureJava25Async(); if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap."); // First run: BlueMap with no config writes default configs and exits. // We need to (a) run it once to seed configs, (b) patch the world path, // (c) re-run with -r to actually render. State.Phase = "configuring"; EnsureConfig(bluemapJava); State.Phase = "rendering"; // -r = render. --mods = pull textures from mod jars so Create // blocks etc. show real colours instead of magenta/grey fallback. var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods"); var args = new List { "-jar", CliJarPath, "-r" }; if (Directory.Exists(modsDir)) { args.Add("--mods"); args.Add(modsDir); } var psi = new ProcessStartInfo { FileName = bluemapJava, WorkingDirectory = RootDir, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; foreach (var a in args) psi.ArgumentList.Add(a); _renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true }; _renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } }; _renderProc.ErrorDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } }; _renderProc.Start(); _renderProc.BeginOutputReadLine(); _renderProc.BeginErrorReadLine(); await _renderProc.WaitForExitAsync(); State.ExitCode = _renderProc.ExitCode; State.FinishedAt = DateTimeOffset.UtcNow; if (_renderProc.ExitCode == 0) State.Phase = "complete"; else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; } } catch (Exception ex) { State.Phase = "failed"; State.Error = ex.Message; State.FinishedAt = DateTimeOffset.UtcNow; _log($"[bluemap] Failed: {ex.Message}"); } finally { State.InProgress = false; _renderProc?.Dispose(); _renderProc = null; _gate.Release(); } } private async Task DownloadJarIfMissingAsync() { if (File.Exists(CliJarPath)) return; _log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}"); Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!); using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead); resp.EnsureSuccessStatusCode(); await using var src = await resp.Content.ReadAsStreamAsync(); await using var dst = File.Create(CliJarPath); await src.CopyToAsync(dst); _log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB."); } /// Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs). private async Task EnsureJava25Async() { var installer = new JavaInstaller(); var serverFull = Path.GetFullPath(_config.ServerDir); // Look for an existing JRE 25 install first (idempotent across renders). var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion); if (existing is not null) return existing; var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion); var progress = new Progress(msg => _log("[bluemap] " + msg)); return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default); } /// /// Run BlueMap with no flags so it writes default configs (bluemap.conf, /// maps/overworld.conf, etc.), then patch the overworld map's world path /// to point at our serverDir. Idempotent -- only writes configs that don't /// exist; existing user edits survive. /// private void EnsureConfig(string javaPath) { Directory.CreateDirectory(ConfigDir); var bluemapConf = Path.Combine(ConfigDir, "core.conf"); var seeded = File.Exists(bluemapConf); if (!seeded) { _log("[bluemap] First run -- generating default configs."); var psi = new ProcessStartInfo { FileName = javaPath, WorkingDirectory = RootDir, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; psi.ArgumentList.Add("-jar"); psi.ArgumentList.Add(CliJarPath); using var p = Process.Start(psi)!; p.WaitForExit(60_000); } // Patch the overworld map config to reference our world dir. var serverDirAbs = Path.GetFullPath(_config.ServerDir); var levelName = ReadLevelName(_config.ServerDir) ?? "world"; var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/'); var mapsDir = Path.Combine(ConfigDir, "maps"); Directory.CreateDirectory(mapsDir); var owConf = Path.Combine(mapsDir, "overworld.conf"); if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir)) { File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk. world: ""{worldDir}"" dimension: ""minecraft:overworld"" name: ""Brass and Sigil -- Overworld"" sorting: 0 sky-color: ""#7dabff"" ambient-light: 0 "); _log("[bluemap] Wrote map config: maps/overworld.conf"); } // Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice). if (File.Exists(bluemapConf)) { var text = File.ReadAllText(bluemapConf); if (!text.Contains("accept-download: true")) { text = text.Replace("accept-download: false", "accept-download: true"); if (!text.Contains("accept-download:")) text += "\naccept-download: true\n"; File.WriteAllText(bluemapConf, text); _log("[bluemap] Set accept-download: true in core.conf"); } } } private static string? ReadLevelName(string serverDir) { var path = Path.Combine(serverDir, "server.properties"); if (!File.Exists(path)) return null; foreach (var line in File.ReadAllLines(path)) { if (line.StartsWith("level-name=", StringComparison.Ordinal)) return line.Substring("level-name=".Length).Trim(); } return null; } }