using System; using System.Collections.Generic; using System.IO; using System.Text; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// The taste-gate renderer: re-colours existing height fields into shaded-relief maps across a /// small set of named looks, and writes an INDEX.md for browsing. /// /// ⚠ IT RE-COLOURS; IT DOES NOT REGENERATE. Height fields are LOADED from the `.f32` dumps a /// generation run left behind, so a look change provably cannot move the terrain. Only when a /// dump is missing for the requested size — a showpiece at 4096, say, where no dump exists — /// does it generate one, deterministically from the same seed. Same seed, same island. /// /// ═══ RUNNING IT ═══ /// /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn /// /// ISLA_MAPSIZE side in columns (default 2048) /// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds) /// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk) /// ISLA_TASK authoring task number (default 3) /// ISLA_BATCH descriptor, NO prefix (default "relief_taste") /// ISLA_SOURCE batch to read .f32 from (default 01_pass1_port) /// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated /// public partial class ReliefRenderTool : Node { private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 }; public override void _Ready() { ToolingPaths.Configure(OS.GetUserDataDir()); int mapSize = EnvInt("ISLA_MAPSIZE", 2048); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); int task = EnvInt("ISLA_TASK", 3); // the task that authored this batch string batch = EnvStr("ISLA_BATCH", "relief_taste"); string source = EnvStr("ISLA_SOURCE", "01_pass1_port"); bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1"; LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null)); // ⚠ Composed by BatchRoot, never free-form: the prefix is the AUTHORING TASK number, // not a counter, and a descriptor carrying its own prefix is refused. → Tools/README.md. string batchRoot = ToolingPaths.BatchRoot(task, batch); string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); GD.Print("=================================================================="); GD.Print(" SHADED RELIEF — hypsometric tint + hillshade (Phase 1, the look)"); GD.Print("=================================================================="); GD.Print($"MapSize : {mapSize}"); GD.Print($"seeds : {string.Join(", ", seeds)}"); GD.Print($"looks : {string.Join(", ", Array.ConvertAll(looks, l => l.Name))}"); GD.Print($"source : {sourceRoot} (.f32 dumps; generated only if absent)"); GD.Print($"out : {batchRoot}"); GD.Print("------------------------------------------------------------------"); foreach (var l in looks) GD.Print($" {l}"); GD.Print("=================================================================="); var rows = new List(); foreach (int seed in seeds) { // --- get the height field: LOAD if a dump exists, generate only as a fallback --- ulong t0 = Time.GetTicksMsec(); string dumpPath = Path.Combine(sourceRoot, $"{seed}_full", "height.f32"); float[,] height = HeightField.Load(dumpPath, mapSize); string origin; if (height != null) { origin = "loaded"; } else { var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }; Pass1Result r = Topography.Generate(cfg); height = r.Height; origin = "generated"; if (dumpRaw) { string dir = Path.Combine(batchRoot, $"{seed}_source"); DirAccess.MakeDirRecursiveAbsolute(dir); HeightField.Save(height, mapSize, Path.Combine(dir, "height.f32")); } } ulong tLoad = Time.GetTicksMsec() - t0; float hMin = float.MaxValue, hMax = float.MinValue; for (int x = 0; x < mapSize; x++) for (int y = 0; y < mapSize; y++) { float v = height[x, y]; if (v < hMin) hMin = v; if (v > hMax) hMax = v; } GD.Print($"\n seed {seed} [{origin} in {tLoad} ms] h[{hMin:F3} .. {hMax:F3}]"); foreach (LookConfig look in looks) { ulong t1 = Time.GetTicksMsec(); string dir = Path.Combine(batchRoot, $"{seed}_{look.Name}"); DirAccess.MakeDirRecursiveAbsolute(dir); ReliefRenderer.SavePng(height, mapSize, look, Path.Combine(dir, "relief.png")); ulong ms = Time.GetTicksMsec() - t1; GD.Print($" {look.Name,-8} -> {dir}/relief.png {ms} ms"); rows.Add($"| `{seed}_{look.Name}` | {seed} | {look.Name} | {look.Palette} | " + $"{look.ZExaggeration:F0} | {look.LightAzimuth:F0}°/{look.LightAltitude:F0}° | " + $"{look.HillshadeStrength:F2} | {ms} ms |"); } } WriteIndex(batchRoot, mapSize, seeds, looks, source, rows); GD.Print("\n=================================================================="); GD.Print($" DONE — {rows.Count} renders in {batchRoot}"); GD.Print("=================================================================="); GetTree().Quit(0); } private static LookConfig[] SelectLooks(string csv) { LookConfig[] all = LookConfig.Variants(); if (string.IsNullOrWhiteSpace(csv)) return all; var picked = new List(); foreach (string want in csv.Split(',', StringSplitOptions.RemoveEmptyEntries)) foreach (LookConfig l in all) if (string.Equals(l.Name, want.Trim(), StringComparison.OrdinalIgnoreCase)) picked.Add(l); return picked.Count > 0 ? picked.ToArray() : all; } private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, LookConfig[] looks, string source, List rows) { var (landLo, landHi) = ReliefPalette.LandAnchors; var sb = new StringBuilder(); sb.AppendLine("# Batch — shaded relief, the taste gate (Phase 1)"); sb.AppendLine(); sb.AppendLine("**Presentation only.** These are the *same* height fields as"); sb.AppendLine($"`{source}` — loaded from its `.f32` dumps, not regenerated. Nothing here can move the"); sb.AppendLine("terrain; only its colours and its lighting differ."); sb.AppendLine(); sb.AppendLine($"- **MapSize:** {mapSize}"); sb.AppendLine($"- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2)."); sb.AppendLine($"- **Palette anchors (FIXED across every image):** land {landLo} .. {landHi}; sea compressed by `d/(d+{ReliefPalette.SeaCompression})`"); sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}"); sb.AppendLine(); sb.AppendLine("## ⭐ Pick a look"); sb.AppendLine(); sb.AppendLine("Three, deliberately — a subjective gate drowns in a wall of near-duplicates. Each pair"); sb.AppendLine("isolates one question:"); sb.AppendLine(); sb.AppendLine("| Look | Palette | Z-exag | Light | Strength | The question it answers |"); sb.AppendLine("|---|---|---|---|---|---|"); foreach (LookConfig l in looks) { string q = l.Name switch { "atlas" => "the baseline — classic physical-atlas plate, tint-forward", "relief" => "**vs `atlas`:** how strong should the relief be? (same palette + light)", "dusk" => "**vs `atlas`:** warmer palette and a lower sun — better, or too much?", _ => "", }; sb.AppendLine($"| `{l.Name}` | {l.Palette} | {l.ZExaggeration:F0} | {l.LightAzimuth:F0}°/{l.LightAltitude:F0}° | {l.HillshadeStrength:F2} | {q} |"); } sb.AppendLine(); sb.AppendLine("Compare the **same seed** across the three folders. `1063685222` is the reference's own"); sb.AppendLine("commented seed and the one used throughout tasks 02–03."); sb.AppendLine(); sb.AppendLine("> ⚠ **The seabed is raw, and that is expected — not unfinished-because-broken.** The"); sb.AppendLine("> submarine coast shelf and the offshore islets are DEFERRED Phase-2 items; they act only"); sb.AppendLine("> below sea level and are judged once water renders. Relief is deliberately dialled back"); sb.AppendLine("> underwater so the eye is not dragged to the one surface that is knowingly incomplete."); sb.AppendLine(); sb.AppendLine("> ⚠ **No biome colour here.** This is height + slope + above/below sea, on a continuous"); sb.AppendLine("> ramp. White at the top is snow-*coloured cartography*, not a snow biome."); sb.AppendLine(); sb.AppendLine("## Renders"); sb.AppendLine(); sb.AppendLine("| Folder | Seed | Look | Palette | Z-exag | Light | Strength | Time |"); sb.AppendLine("|---|---|---|---|---|---|---|---|"); foreach (string r in rows) sb.AppendLine(r); sb.AppendLine(); sb.AppendLine("`scratch/` is persistent and is never cleaned."); using var f = Godot.FileAccess.Open(Path.Combine(batchRoot, "INDEX.md"), Godot.FileAccess.ModeFlags.Write); if (f == null) { GD.PrintErr("could not write INDEX.md"); return; } f.StoreString(sb.ToString()); } private static string EnvStr(string k, string fallback) { string v = System.Environment.GetEnvironmentVariable(k); return string.IsNullOrWhiteSpace(v) ? fallback : v; } private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback; private static int[] EnvSeeds(string k, int[] fallback) { string v = EnvStr(k, null); if (v == null) return fallback; var o = new List(); foreach (string p in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) if (int.TryParse(p.Trim(), out int s) && s > 0) o.Add(s); return o.Count > 0 ? o.ToArray() : fallback; } } }