using Godot; using System; using System.IO; namespace IslaApocalypse.Core { /// /// Writes a WorldBlueprint to disk in the v2 tagged-section container /// (layout: Core/Scripts/BLUEPRINT_FORMAT.md; constants: BlueprintFormat). /// /// Deliberately written against the blueprint data types, not the generator's /// internal arrays, so it is callable outside a generation run — the map generator /// and the round-trip harness are both just callers. /// /// Provenance (timestamp, git hash) is stamped at write time — it describes the /// file being written, not whatever was in blueprint.Params. If blueprint.Params is /// null (a re-encode of a legacy v1 file), the sentinel values from BlueprintFormat /// are written instead of real generation inputs. /// public static class BlueprintWriter { public static void WriteV2(string absolutePath, WorldBlueprint bp) { ulong started = Time.GetTicksMsec(); using (FileStream stream = File.Open(absolutePath, FileMode.Create)) using (BinaryWriter writer = new BinaryWriter(stream)) { // Header: raw "ISLA" magic + u32 version. Little-endian throughout. writer.Write(BlueprintFormat.MAGIC); writer.Write(BlueprintFormat.VERSION); WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); if (bp.TerrainCurve != null) WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve)); WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); // Water sections are written only when the blueprint carries water data // (a legacy v1 re-encode has none — the sections are simply absent). if (bp.WaterBodyIds != null) { WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_IDS, w => WriteWaterBodyIds(w, bp)); WriteSection(writer, BlueprintFormat.TAG_WATER_BODY_TABLE, w => WriteWaterBodyTable(w, bp)); WriteSection(writer, BlueprintFormat.TAG_WATER_SURFACE, w => WriteWaterSurface(w, bp)); } WriteSection(writer, BlueprintFormat.TAG_TOWNS, w => WriteTowns(w, bp)); WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways)); WriteSection(writer, BlueprintFormat.TAG_ROADS_BRANCH, w => WriteRoadTier(w, bp.BranchRoads)); WriteSection(writer, BlueprintFormat.TAG_ROADS_RUGGED, w => WriteRoadTier(w, bp.RuggedRoads)); WriteSection(writer, BlueprintFormat.TAG_ROADS_TRAIL, w => WriteRoadTier(w, bp.TrailRoads)); } double seconds = (Time.GetTicksMsec() - started) / 1000.0; Vector2 impact = bp.Params?.ImpactCenter ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); GD.Print($"[BlueprintWriter] v2 blueprint written in {seconds:F1}s to: {absolutePath} " + $"(seed {bp.Params?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED}, " + $"MapSize {bp.MapSize}, impactCenter ({impact.X:F1},{impact.Y:F1}))"); } /// /// Frames one section: [u32 tag][u64 payload-length][payload]. The length is /// back-patched after the payload is written, so payload writers never have to /// pre-compute their own size. /// private static void WriteSection(BinaryWriter writer, uint tag, Action payload) { writer.Write(tag); long lengthPos = writer.BaseStream.Position; writer.Write((ulong)0); long payloadStart = writer.BaseStream.Position; payload(writer); long payloadEnd = writer.BaseStream.Position; writer.BaseStream.Position = lengthPos; writer.Write((ulong)(payloadEnd - payloadStart)); writer.BaseStream.Position = payloadEnd; } private static void WriteParams(BinaryWriter writer, WorldBlueprint bp) { BlueprintParams p = bp.Params; writer.Write(p?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED); writer.Write(bp.MapSize); // always real — the blueprint knows its own size writer.Write(p?.CraterRadius ?? BlueprintFormat.SENTINEL_CRATER_RADIUS); writer.Write(p?.DensityMultiplier ?? BlueprintFormat.SENTINEL_DENSITY_MULTIPLIER); Vector2 impact = p?.ImpactCenter ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); writer.Write(impact.X); writer.Write(impact.Y); // Provenance, stamped now (length-prefixed .NET strings — fine inside a // length-framed section; only the file HEADER must avoid them). writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")); writer.Write(TryGetGitShortHash()); } private static void WriteHeights(BinaryWriter writer, WorldBlueprint bp) { // X outer / Y inner — the v1 convention, kept: the second index is the map's // north/south axis and the server consumes it as world Z. int n = bp.MapSize; for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) writer.Write(bp.HeightMap[x, y]); } private static void WriteBiomes(BinaryWriter writer, WorldBlueprint bp) { int n = bp.MapSize; for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { int ordinal = (int)bp.BiomeMap[x, y]; if (ordinal < 0 || ordinal > byte.MaxValue) throw new InvalidDataException( $"[BlueprintWriter] Biome ordinal {ordinal} at ({x},{y}) does not fit in a byte."); writer.Write((byte)ordinal); } } } private static void WriteTerrainCurve(BinaryWriter writer, TerrainCurveInfo c) { writer.Write(c.Version); writer.Write(c.T1); writer.Write(c.T2); writer.Write(c.T3); writer.Write(c.T4); writer.Write(c.SpikeMax); writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil); writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap); writer.Write(c.TailSlope); // v4+ extension: shelf-modulation parameters. if (c.Version >= 4) { writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp); writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax); writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands); writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset); writer.Write(c.StrengthSeedOffset); } // v5+ extension: knot preset id + K5/K6. if (c.Version >= 5) { writer.Write(c.PresetId); writer.Write(c.K5); writer.Write(c.K6); } } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) { int n = bp.MapSize; for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) writer.Write(bp.WaterBodyIds[x, y]); } private static void WriteWaterBodyTable(BinaryWriter writer, WorldBlueprint bp) { writer.Write(bp.WaterBodies.Count); foreach (WaterBodyInfo body in bp.WaterBodies) { writer.Write(body.Id); writer.Write(body.Type); writer.Write(body.Salinity); writer.Write(body.SurfaceLevel); writer.Write(body.PixelCount); writer.Write(body.Centroid.X); writer.Write(body.Centroid.Y); } } private static void WriteWaterSurface(BinaryWriter writer, WorldBlueprint bp) { int n = bp.MapSize; // A parsed blueprint carries the quantized surface verbatim; a freshly // generated one derives it from body ids + per-body levels. if (bp.WaterSurfaceQ != null) { for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) writer.Write(bp.WaterSurfaceQ[x, y]); return; } // id -> encoded level lookup (ids are small and dense: 1..N) int maxId = 0; foreach (WaterBodyInfo body in bp.WaterBodies) if (body.Id > maxId) maxId = body.Id; ushort[] encoded = new ushort[maxId + 1]; foreach (WaterBodyInfo body in bp.WaterBodies) encoded[body.Id] = BlueprintFormat.EncodeWaterLevel(body.SurfaceLevel); for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { ushort id = bp.WaterBodyIds[x, y]; writer.Write(id == 0 ? (ushort)0 : encoded[id]); } } } private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) { writer.Write(bp.Towns.Count); foreach (TownLocation town in bp.Towns) { int tier = (int)town.Tier; if (tier < 0 || tier > byte.MaxValue) throw new InvalidDataException( $"[BlueprintWriter] TownTier ordinal {tier} does not fit in a byte."); writer.Write(town.Position.X); writer.Write(town.Position.Y); writer.Write((byte)tier); writer.Write(town.IsHighwayNode ? (byte)1 : (byte)0); } } private static void WriteRoadTier(BinaryWriter writer, System.Collections.Generic.List paths) { writer.Write(paths.Count); foreach (Vector2[] path in paths) { writer.Write(path.Length); foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } } } /// /// Best-effort short hash of the generator repo (reads res://.git directly, no /// git invocation). Returns "" when unavailable — e.g. an exported build. /// private static string TryGetGitShortHash() { try { string gitDir = Path.Combine(ProjectSettings.GlobalizePath("res://"), ".git"); string head = File.ReadAllText(Path.Combine(gitDir, "HEAD")).Trim(); if (head.StartsWith("ref: ")) { string refPath = Path.Combine(gitDir, head.Substring(5)); if (!File.Exists(refPath)) return ""; head = File.ReadAllText(refPath).Trim(); } return head.Length >= 8 ? head.Substring(0, 8) : head; } catch { return ""; } } } }