using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.World; using Theriapolis.Core.World.Generation; using Theriapolis.Core.World.Generation.Stages; using Theriapolis.Core.World.Polylines; namespace Theriapolis.Tools.Commands; /// /// worldgen-dump --seed <n> --out <file.png> [--data-dir <dir>] /// /// Runs the full pipeline headless and exports a PNG of the biome map overlaid /// with rivers (blue), roads (tan), rail (dark), and settlement icons. /// public static class WorldgenDump { public static int Run(string[] args) { ulong seed = 12345; string outPath = "world.png"; string dataDir = ResolveDataDir(); bool showViolations = false; for (int i = 0; i < args.Length; i++) { switch (args[i].ToLowerInvariant()) { case "--seed": if (i + 1 < args.Length) { string raw = args[++i]; if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) seed = Convert.ToUInt64(raw[2..], 16); else seed = ulong.Parse(raw); } break; case "--out": if (i + 1 < args.Length) outPath = args[++i]; break; case "--data-dir": if (i + 1 < args.Length) dataDir = args[++i]; break; case "--show-violations": showViolations = true; break; } } Console.WriteLine($"[worldgen-dump] seed=0x{seed:X} out={outPath} data-dir={dataDir}"); if (!Directory.Exists(dataDir)) { Console.Error.WriteLine($"Data directory not found: {dataDir}"); return 1; } var ctx = new WorldGenContext(seed, dataDir) { ProgressCallback = (name, frac) => Console.Write($"\r {name,-28} {frac * 100f:F0}% "), Log = msg => Console.WriteLine(msg), }; WorldGenerator.RunAll(ctx); Console.WriteLine(); // Collect biome colours from the loaded BiomeDef array var colorMap = BuildColorMap(ctx.World.BiomeDefs!); int W = C.WORLD_WIDTH_TILES; int H = C.WORLD_HEIGHT_TILES; using var image = new Image(W, H); // ── 1. Biome base layer ─────────────────────────────────────────────── for (int ty = 0; ty < H; ty++) for (int tx = 0; tx < W; tx++) { var biome = ctx.World.Tiles[tx, ty].Biome; if (!colorMap.TryGetValue(biome, out var px)) px = new Rgb24(255, 0, 255); image[tx, ty] = px; } if (showViolations) OverlayViolations(image, ctx, W, H); // ── 2. Roads ───────────────────────────────────────────────────────── foreach (var road in ctx.World.Roads) { var color = road.RoadClassification switch { RoadType.Highway => new Rgb24(210, 180, 80), RoadType.PostRoad => new Rgb24(180, 155, 70), _ => new Rgb24(150, 130, 90), }; DrawPolyline(image, road, color, W, H); } // ── 3. Rivers ───────────────────────────────────────────────────────── foreach (var river in ctx.World.Rivers) { var color = river.RiverClassification switch { RiverClass.MajorRiver => new Rgb24(40, 100, 200), RiverClass.River => new Rgb24(60, 120, 200), _ => new Rgb24(100, 150, 220), }; DrawPolyline(image, river, color, W, H); } // ── 4. Rail ─────────────────────────────────────────────────────────── var railColor = new Rgb24(80, 65, 50); foreach (var rail in ctx.World.Rails) DrawPolyline(image, rail, railColor, W, H); // ── 4b. Bridges ────────────────────────────────────────────────────── var bridgeColor = new Rgb24(160, 140, 100); foreach (var bridge in ctx.World.Bridges) { int bx = (int)(bridge.WorldPixelX / C.WORLD_TILE_PIXELS); int by = (int)(bridge.WorldPixelY / C.WORLD_TILE_PIXELS); // Draw a small cross at the bridge location for (int d = -1; d <= 1; d++) { int px = bx + d, py = by; if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor; px = bx; py = by + d; if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor; } } // ── 5. Settlements ──────────────────────────────────────────────────── foreach (var s in ctx.World.Settlements) { var (color, radius) = s.Tier switch { 1 => (new Rgb24(255, 215, 0), 4), // gold, capital 2 => (new Rgb24(230, 230, 230), 3), // white, city 3 => (new Rgb24(150, 200, 255), 2), // blue, town 4 => (new Rgb24(200, 200, 200), 1), // grey, village _ => (new Rgb24(200, 60, 60), 1), // red, PoI }; DrawDot(image, s.TileX, s.TileY, color, radius, W, H); } // ── 6. Biome coverage stats ──────────────────────────────────────────── PrintBiomeCoverage(ctx, W, H); // ── 7. Settlement summary ───────────────────────────────────────────── PrintSettlementSummary(ctx); image.Save(outPath); Console.WriteLine($"[worldgen-dump] Saved {outPath} ({W}×{H} px)"); return 0; } private static void DrawPolyline(Image img, Polyline poly, Rgb24 color, int W, int H) { var pts = poly.Points; if (pts.Count < 2) return; for (int i = 0; i < pts.Count - 1; i++) { int x0 = (int)(pts[i].X / C.WORLD_TILE_PIXELS); int y0 = (int)(pts[i].Y / C.WORLD_TILE_PIXELS); int x1 = (int)(pts[i + 1].X / C.WORLD_TILE_PIXELS); int y1 = (int)(pts[i + 1].Y / C.WORLD_TILE_PIXELS); BresenhamLine(img, x0, y0, x1, y1, color, W, H); } } private static void BresenhamLine(Image img, int x0, int y0, int x1, int y1, Rgb24 color, int W, int H) { int dx = Math.Abs(x1 - x0), dy = Math.Abs(y1 - y0); int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; int err = dx - dy; while (true) { if ((uint)x0 < (uint)W && (uint)y0 < (uint)H) img[x0, y0] = color; if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } private static void DrawDot(Image img, int cx, int cy, Rgb24 color, int radius, int W, int H) { for (int dy = -radius; dy <= radius; dy++) for (int dx = -radius; dx <= radius; dx++) { if (dx * dx + dy * dy > radius * radius) continue; int nx = cx + dx, ny = cy + dy; if ((uint)nx < (uint)W && (uint)ny < (uint)H) img[nx, ny] = color; } } private static void OverlayViolations(Image image, WorldGenContext ctx, int W, int H) { var viols = BorderDistortionGenStage.FindStraightViolations(ctx); var byLen = viols.OrderByDescending(v => v.len).ToList(); Console.WriteLine($"[worldgen-dump] Total straight-run violations: {viols.Count}"); var buckets = new (int min, int max, int count)[] { (6, 7, 0), (8, 9, 0), (10, 11, 0), (12, 14, 0), (15, 19, 0), (20, 29, 0), (30, 49, 0), (50, 99, 0), }; foreach (var v in viols) { for (int i = 0; i < buckets.Length; i++) { if (v.len >= buckets[i].min && v.len <= buckets[i].max) { buckets[i].count++; break; } } } Console.WriteLine($"[worldgen-dump] Run length distribution:"); foreach (var b in buckets) Console.WriteLine($" len {b.min,2}-{b.max,2}: {b.count,5}"); Console.WriteLine($"[worldgen-dump] Top 25 longest runs:"); for (int i = 0; i < Math.Min(25, byLen.Count); i++) { var v = byLen[i]; string orient = (v.dx, v.dy) switch { (1, 0) => "→", (0, 1) => "↓", (1, 1) => "↘", (1,-1) => "↗", _ => "?", }; Console.WriteLine($" #{i+1,2} ({v.x,4},{v.y,4}) {orient} len={v.len}"); } var red = new Rgb24(255, 0, 0); int overlayCount = Math.Min(50, byLen.Count); for (int i = 0; i < overlayCount; i++) { var v = byLen[i]; int px = v.x, py = v.y; for (int step = 0; step < v.len; step++) { if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = red; px += v.dx; py += v.dy; } } } private static void PrintBiomeCoverage(WorldGenContext ctx, int W, int H) { var biomeCounts = new Dictionary(); int total = W * H; for (int ty = 0; ty < H; ty++) for (int tx = 0; tx < W; tx++) { var b = ctx.World.Tiles[tx, ty].Biome; biomeCounts.TryGetValue(b, out int c); biomeCounts[b] = c + 1; } int oceanCount = biomeCounts.GetValueOrDefault(BiomeId.Ocean); int landTotal = total - oceanCount; Console.WriteLine($"[worldgen-dump] Biome coverage: ocean={100.0 * oceanCount / total:F1}%, land tiles={landTotal}"); foreach (var kv in biomeCounts.OrderByDescending(kv => kv.Value)) { if (kv.Key == BiomeId.Ocean) continue; double pct = landTotal > 0 ? 100.0 * kv.Value / landTotal : 0; Console.WriteLine($" {kv.Key,-22} {pct,6:F2}% ({kv.Value} tiles)"); } } private static void PrintSettlementSummary(WorldGenContext ctx) { var ss = ctx.World.Settlements; if (ss.Count == 0) return; Console.WriteLine($"[worldgen-dump] Settlements: {ss.Count} total"); for (int tier = 1; tier <= 4; tier++) { var ts = ss.Where(s => s.Tier == tier && !s.IsPoi).ToList(); Console.WriteLine($" Tier {tier}: {ts.Count}"); foreach (var s in ts) Console.WriteLine($" [{s.TileX,4},{s.TileY,4}] {s.Name,-24} {s.Economy,-14} wealth={s.WealthLevel:F2}"); } int poiCount = ss.Count(s => s.IsPoi); Console.WriteLine($" PoIs: {poiCount}"); Console.WriteLine($"[worldgen-dump] Rivers: {ctx.World.Rivers.Count}, Roads: {ctx.World.Roads.Count}, Rails: {ctx.World.Rails.Count}, Bridges: {ctx.World.Bridges.Count}"); } private static Dictionary BuildColorMap(BiomeDef[] defs) { var map = new Dictionary(); foreach (var def in defs) { var biomeId = ParseBiomeId(def.Id); var (r, g, b) = def.ParsedColor(); map[biomeId] = new Rgb24(r, g, b); } return map; } private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch { "ocean" => BiomeId.Ocean, "tundra" => BiomeId.Tundra, "boreal" => BiomeId.Boreal, "temperate_deciduous" => BiomeId.TemperateDeciduous, "temperate_grassland" => BiomeId.TemperateGrassland, "mountain_alpine" => BiomeId.MountainAlpine, "mountain_forested" => BiomeId.MountainForested, "subtropical_forest" => BiomeId.SubtropicalForest, "wetland" => BiomeId.Wetland, "coastal" => BiomeId.Coastal, "river_valley" => BiomeId.RiverValley, "scrubland" => BiomeId.Scrubland, "desert_cold" => BiomeId.DesertCold, "forest_edge" => BiomeId.ForestEdge, "foothills" => BiomeId.Foothills, "marsh_edge" => BiomeId.MarshEdge, "beach" => BiomeId.Beach, "cliff" => BiomeId.Cliff, "tidal_flat" => BiomeId.TidalFlat, "mangrove" => BiomeId.Mangrove, _ => BiomeId.TemperateGrassland, }; private static string ResolveDataDir() { string local = Path.Combine(AppContext.BaseDirectory, "Data"); if (Directory.Exists(local)) return local; string? dir = AppContext.BaseDirectory.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); for (int i = 0; i < 6; i++) { if (dir is null) break; string candidate = Path.Combine(dir, "Content", "Data"); if (Directory.Exists(candidate)) return candidate; dir = Path.GetDirectoryName(dir); } return local; } }