Compare commits
4 commits
ad541dfbd8
...
fb14b0693b
| Author | SHA1 | Date | |
|---|---|---|---|
| fb14b0693b | |||
| a7e6c17422 | |||
| 8532d88771 | |||
| c305fd6565 |
18 changed files with 920 additions and 32 deletions
|
|
@ -8,16 +8,24 @@ define what things are and how to calculate them; they never remember what is cu
|
|||
|
||||
### `MapDataParser.cs` — the data bridge
|
||||
Deserializes the binary `.dat` blueprint written by `/Tools` into a `WorldBlueprint` held in RAM:
|
||||
map size, the float heightmap, the biome map, town locations, and **all four tiers** of A\* road
|
||||
vectors (Highways, Branch Roads, Rugged Roads, Trails).
|
||||
map size, the float heightmap, the biome map, town locations, **all four tiers** of A\* road
|
||||
vectors (Highways, Branch Roads, Rugged Roads, Trails), and — from v2 files — the embedded
|
||||
generation params (seed, sizes, impact centre, provenance) and each town's highway-node flag.
|
||||
|
||||
Read order is fixed and must match the writer exactly: magic string `"ISLA_V1"` → map size →
|
||||
per-pixel `{height float, biome int}` in x-major order → towns → the four road tiers in order.
|
||||
**Two formats are live** (byte-accurate contract: `Core/Scripts/BLUEPRINT_FORMAT.md`). The parser
|
||||
dispatches on the file's first byte:
|
||||
- **v2 (primary)** — raw `ISLA` magic, u32 version gate, then tagged sections
|
||||
`[u32 tag][u64 length][payload]`. Unknown tags are skipped by length, so future sections are
|
||||
invisible to older readers. Validated on read: version, MapSize bounds, section lengths against
|
||||
the file, biome/tier ordinal ranges.
|
||||
- **v1 (legacy)** — the positional `"ISLA_V1"` format, still written beside v2 as `_v1.dat` and
|
||||
still loadable (with a deprecation warning) until a future removal task.
|
||||
|
||||
⚠ **Wire-format hazard.** Biome and town-tier enums are serialized as their **ordinal values**, with
|
||||
no version gate and no range validation on read. **Never reorder or insert members** in `Enums.cs` —
|
||||
append only, at the end. Reordering silently reinterprets every pixel of every existing `.dat`.
|
||||
(`RoadTier` is exempt: road tiers are stored in separate file sections, so that enum never hits disk.)
|
||||
⚠ **Wire-format hazard.** Biome and town-tier enums are serialized as their **ordinal values**
|
||||
(u8 in v2, i32 in v1). **Never reorder or insert members** in `Enums.cs` — append only, at the end.
|
||||
v2's range checks make drift fail loudly at parse time; legacy v1 has no validation and silently
|
||||
reinterprets every pixel. (`RoadTier` is exempt: road tiers are stored in separate file sections —
|
||||
tagged in v2, positional in v1 — so that enum never hits disk.)
|
||||
|
||||
### `ChunkData.cs` + `Constants.cs` — voxel containers and tuning
|
||||
- **Chunk dimensions:** `24 × 24` horizontal, `256` vertical (`Constants.cs`).
|
||||
|
|
|
|||
116
Core/Scripts/BLUEPRINT_FORMAT.md
Normal file
116
Core/Scripts/BLUEPRINT_FORMAT.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# BLUEPRINT_FORMAT — the `.dat` container, v2 (and the v1 legacy summary)
|
||||
|
||||
The `.dat` blueprint is the sole handoff from the offline generator (`/Tools`) to the server
|
||||
(`/Server`). This document is the byte-accurate contract. Code authority: constants in
|
||||
`BlueprintFormat.cs`, writer in `BlueprintWriter.cs`, reader in `MapDataParser.cs`. On any
|
||||
disagreement between this file and the code, the code wins and this file must be corrected.
|
||||
|
||||
- **Byte order: little-endian throughout** (BinaryWriter/BinaryReader platform default; stated here
|
||||
because the format itself records no endianness marker).
|
||||
- **File naming:** `user://MapData_Seed_<seed>.dat`, where `<seed>` is the generator's **resolved**
|
||||
noise seed. The server looks the file up by config `WorldSeed` — a config seed of 0 ("randomize")
|
||||
therefore never finds the file it just generated (historical hazard H4; config-side, unchanged).
|
||||
- The generator currently **dual-writes**: v2 under the primary name, legacy v1 beside it as
|
||||
`MapData_Seed_<seed>_v1.dat`. The v1 fallback is scheduled for removal in a future task.
|
||||
|
||||
## Format detection
|
||||
|
||||
The reader dispatches on the **first byte** of the file:
|
||||
|
||||
| First byte | Format | Why it's unambiguous |
|
||||
|---|---|---|
|
||||
| `0x49` (`'I'`) | **v2** — opens with the raw 4 bytes `ISLA` | v2's magic is raw bytes, deliberately not a .NET string |
|
||||
| `0x07` | **v1 (legacy)** — opens with the length-prefixed .NET string `"ISLA_V1"` (prefix byte 7) | v1 loads intact, with a deprecation warning |
|
||||
| anything else | rejected loudly, `null` return | |
|
||||
|
||||
## v2 layout
|
||||
|
||||
```
|
||||
[4 B] magic: raw bytes 'I','S','L','A' (== little-endian u32 0x414C5349)
|
||||
[4 B] format version: u32 = 2 (any other value -> loud reject, null)
|
||||
[...] sections, sequentially, until EOF
|
||||
```
|
||||
|
||||
### Section framing
|
||||
|
||||
Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
|
||||
|
||||
- **Reader rule: known tag → parse; unknown tag → skip payload-length bytes and continue.** This is
|
||||
the forward-compatibility property the v2 redesign exists to buy: a reader that predates a section
|
||||
(a future water layer, for instance) loads the file and never sees it.
|
||||
- The u64 length is deliberate headroom (a u32 caps a section at 4 GiB; a 16K-map height section
|
||||
would already be 1 GiB, and future per-pixel sections should never have to shard on length).
|
||||
- Tag values are FourCC codes stored as little-endian u32, so the on-disk bytes read as ASCII in a
|
||||
hex dump. **All tags are registered in `BlueprintFormat.cs` and in the table below — one place
|
||||
each. Never reuse a retired tag value.**
|
||||
- **Rules enforced by the reader:** the params section must be **first**; duplicate tags are an
|
||||
error; every declared length is checked against the remaining file before the payload is read; a
|
||||
parsed section must consume exactly its declared length.
|
||||
|
||||
### Registered sections (v2, current content)
|
||||
|
||||
| Tag (ASCII / u32) | Payload | Notes |
|
||||
|---|---|---|
|
||||
| `PRMS` / `0x534D5250` | `i32 WorldSeed` · `i32 MapSize` · `f32 CraterRadius` · `f32 DensityMultiplier` · `f32 ImpactCenterX` · `f32 ImpactCenterY` · `str GeneratedUtc` · `str GeneratorGitHash` | Mandatory, first. The **resolved** generation inputs — the file is self-describing; the server cross-checks seed and MapSize against config and warns loudly on desync. `str` = .NET length-prefixed UTF-8 (fine *inside* a length-framed section; only the file header must avoid it). `CraterRadius` is f32 because `ConfigManager.CraterRadius` is a float in code. `GeneratorGitHash` is the repo short hash or `""`. |
|
||||
| `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. |
|
||||
| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. |
|
||||
| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. |
|
||||
| `RDHW` / `0x57484452` | `i32 pathCount`, then per path: `i32 pointCount` + `pointCount` × (`f32 X` · `f32 Y`) | Highway tier. **Tags, not file position, identify the tier** — the v1 order-fragility is gone. |
|
||||
| `RDBR` / `0x52424452` | same layout | Branch tier. |
|
||||
| `RDRG` / `0x47524452` | same layout | Rugged tier. |
|
||||
| `RDTL` / `0x4C544452` | same layout | Trail tier. |
|
||||
|
||||
Missing `TOWN`/road sections load as empty lists with a warning; missing `PRMS`/`HGTS`/`BIOM` is an
|
||||
error. Section order after `PRMS` is not significant (the reference writer emits the table order).
|
||||
|
||||
### Reader validation (v2 path)
|
||||
|
||||
1. Magic and version gate — loud, specific errors; `null` return (the caller's null-check aborts
|
||||
world load cleanly).
|
||||
2. `MapSize` sanity bound **before any allocation**: `256 ≤ MapSize ≤ 32768`.
|
||||
3. Every section length checked against the remaining file length; truncation is a loud error, not
|
||||
a read-past-end crash.
|
||||
4. Section parsers must consume exactly their declared length.
|
||||
5. Biome and town-tier ordinals range-checked against the known enum counts.
|
||||
6. After load, `ServerChunkManager` compares embedded `WorldSeed`/`MapSize` against
|
||||
`ServerConfig.json` and logs a prominent desync warning on mismatch (warning, not abort).
|
||||
|
||||
### Sentinel params (re-encoded files)
|
||||
|
||||
A v2 file produced by re-encoding a v1 source (e.g. the round-trip harness) cannot know the
|
||||
original generation inputs. It carries: `WorldSeed = 0`, `CraterRadius = -1`, `DensityMultiplier =
|
||||
-1`, `ImpactCenter = (-1, -1)` (`MapSize` is always real). The seed cross-check skips sentinel
|
||||
seed 0. Provenance (`GeneratedUtc`, `GeneratorGitHash`) is stamped at **write** time and describes
|
||||
the file, not the original generation.
|
||||
|
||||
### Size
|
||||
|
||||
`total = 8 (header) + Σ per section (12 + payload)`. The pixel grid dominates: `5·MapSize²` bytes
|
||||
(4 height + 1 biome) ≈ **320 MiB at 8K**, vs v1's `8·MapSize²` ≈ 512 MiB — the u8 biome section
|
||||
saves ~192 MiB at 8K.
|
||||
|
||||
### Adding a new section (the intended extension path)
|
||||
|
||||
1. Register a fresh FourCC in `BlueprintFormat.cs` and in the table above.
|
||||
2. Emit it from `BlueprintWriter.WriteV2` (any position after `PRMS`).
|
||||
3. Parse it in `MapDataParser.LoadV2`'s tag dispatch.
|
||||
4. Old readers skip it automatically; **no version bump is needed for additive sections.** Bump
|
||||
`VERSION` only for changes that alter the meaning of *existing* bytes.
|
||||
|
||||
## v1 legacy summary (still readable, still dual-written, removal pending)
|
||||
|
||||
Positional and untagged — every field's meaning derives from its offset; no lengths, no checksums,
|
||||
no skip capability. Layout: length-prefixed string `"ISLA_V1"` → `i32 MapSize` → `MapSize²` ×
|
||||
(`f32 height` · **`i32` biome ordinal**) X-outer/Y-inner → `i32 townCount` + per town (`f32 X` ·
|
||||
`f32 Y` · `i32 tier`) → four road blocks **identified by position** (Highway → Branch → Rugged →
|
||||
Trail), each `i32 pathCount` + per path `i32 pointCount` + points. The v1 reader stops after the
|
||||
Trail block and ignores trailing bytes. No validation beyond the header string. It does not carry
|
||||
generation params, the impact centre, or the highway-node flag.
|
||||
|
||||
## Shared wire-format rule (both formats)
|
||||
|
||||
⚠ **`Biome` and `TownTier` ordinals are the serialized values** (u8 in v2, i32 in v1). The enums in
|
||||
`Enums.cs` are declared without explicit numeric values, so their ordinals are positional:
|
||||
**append new members only at the end; never reorder or insert.** v2 adds parse-time range checks,
|
||||
which turn enum drift from silent reinterpretation into a loud load failure — but the append-only
|
||||
rule is still what keeps old files *meaning* the same thing.
|
||||
54
Core/Scripts/BlueprintFormat.cs
Normal file
54
Core/Scripts/BlueprintFormat.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace IslaApocalypse.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// The single registry of constants for the v2 blueprint container — magic, version,
|
||||
/// section tags, validation bounds, and the sentinel values used when a v2 file is
|
||||
/// re-encoded from a v1 source that cannot supply real generation parameters.
|
||||
///
|
||||
/// The byte-accurate layout lives in Core/Scripts/BLUEPRINT_FORMAT.md. Writer:
|
||||
/// BlueprintWriter.WriteV2. Reader: MapDataParser (v2 branch). Tags are FourCC codes
|
||||
/// stored as little-endian u32, so the raw bytes on disk read as ASCII ("PRMS", "HGTS",
|
||||
/// ...) in a hex dump.
|
||||
/// </summary>
|
||||
public static class BlueprintFormat
|
||||
{
|
||||
// "ISLA" as raw bytes 'I','S','L','A' == little-endian u32 0x414C5349.
|
||||
// Deliberately NOT a length-prefixed .NET string: the v1 header starts with the
|
||||
// 7-bit length prefix 0x07, so the first byte alone (0x49 vs 0x07) identifies the
|
||||
// format and the reader can dispatch without ambiguity.
|
||||
public const uint MAGIC = 0x414C5349;
|
||||
public const uint VERSION = 2;
|
||||
|
||||
// Section tags (FourCC, little-endian). Reader rule: known tag -> parse,
|
||||
// unknown tag -> skip by length and continue. Never reuse a retired tag value.
|
||||
public const uint TAG_PARAMS = 0x534D5250; // "PRMS"
|
||||
public const uint TAG_HEIGHTS = 0x53544748; // "HGTS"
|
||||
public const uint TAG_BIOMES = 0x4D4F4942; // "BIOM"
|
||||
public const uint TAG_TOWNS = 0x4E574F54; // "TOWN"
|
||||
public const uint TAG_ROADS_HIGHWAY = 0x57484452; // "RDHW"
|
||||
public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR"
|
||||
public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG"
|
||||
public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL"
|
||||
|
||||
// MapSize sanity bounds, checked before any allocation on read.
|
||||
public const int MIN_MAP_SIZE = 256;
|
||||
public const int MAX_MAP_SIZE = 32768;
|
||||
|
||||
// Sentinels written into the params section when the source blueprint came from a
|
||||
// v1 file (a re-encode cannot know the original generation inputs). WorldSeed 0 is
|
||||
// also the config "randomize" sentinel, which no real resolved seed ever is.
|
||||
public const int SENTINEL_WORLD_SEED = 0;
|
||||
public const float SENTINEL_CRATER_RADIUS = -1f;
|
||||
public const float SENTINEL_DENSITY_MULTIPLIER = -1f;
|
||||
public const float SENTINEL_IMPACT_CENTER = -1f; // both components
|
||||
|
||||
/// <summary>Renders a tag as its 4 ASCII characters, for log messages.</summary>
|
||||
public static string TagToString(uint tag)
|
||||
{
|
||||
return new string(new[] {
|
||||
(char)(tag & 0xFF), (char)((tag >> 8) & 0xFF),
|
||||
(char)((tag >> 16) & 0xFF), (char)((tag >> 24) & 0xFF)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/BlueprintFormat.cs.uid
Normal file
1
Core/Scripts/BlueprintFormat.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cfhjrihiyqtau
|
||||
165
Core/Scripts/BlueprintWriter.cs
Normal file
165
Core/Scripts/BlueprintWriter.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
using Godot;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace IslaApocalypse.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
|
||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(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}))");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void WriteSection(BinaryWriter writer, uint tag, Action<BinaryWriter> 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 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<Vector2[]> 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); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort short hash of the generator repo (reads res://.git directly, no
|
||||
/// git invocation). Returns "" when unavailable — e.g. an exported build.
|
||||
/// </summary>
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Core/Scripts/BlueprintWriter.cs.uid
Normal file
1
Core/Scripts/BlueprintWriter.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://vevh7pfrbcf7
|
||||
|
|
@ -11,6 +11,29 @@ namespace IslaApocalypse.Core
|
|||
{
|
||||
public Vector2 Position;
|
||||
public TownTier Tier;
|
||||
|
||||
// Whether the generator marked this settlement as a highway node (it drives the
|
||||
// road topology at generation time). Carried by the v2 blueprint; always false
|
||||
// when loading a legacy v1 file, which never stored it. Nothing server-side
|
||||
// consumes it yet — it is carried and exposed, not acted on.
|
||||
public bool IsHighwayNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The resolved generation inputs embedded in a v2 blueprint, so a file is
|
||||
/// self-describing and config desync is detectable. Null on a legacy v1 load.
|
||||
/// A v2 file re-encoded from a v1 source carries the BlueprintFormat sentinel
|
||||
/// values instead of real inputs.
|
||||
/// </summary>
|
||||
public class BlueprintParams
|
||||
{
|
||||
public int WorldSeed; // the generator's RESOLVED noise seed (names the file)
|
||||
public int MapSize;
|
||||
public float CraterRadius;
|
||||
public float DensityMultiplier;
|
||||
public Vector2 ImpactCenter; // the crater's _impactCenter, in map pixels
|
||||
public string GeneratedUtc = ""; // stamped at write time, ISO-8601
|
||||
public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown
|
||||
}
|
||||
|
||||
public class WorldBlueprint
|
||||
|
|
@ -19,12 +42,17 @@ namespace IslaApocalypse.Core
|
|||
public float[,] HeightMap;
|
||||
public Biome[,] BiomeMap;
|
||||
public List<TownLocation> Towns = new List<TownLocation>();
|
||||
|
||||
|
||||
// All 4 road tiers!
|
||||
public List<Vector2[]> Highways = new List<Vector2[]>();
|
||||
public List<Vector2[]> BranchRoads = new List<Vector2[]>();
|
||||
public List<Vector2[]> RuggedRoads = new List<Vector2[]>();
|
||||
public List<Vector2[]> TrailRoads = new List<Vector2[]>();
|
||||
|
||||
// Which on-disk format this blueprint was parsed from (1 or 2), and the embedded
|
||||
// generation parameters. Params is null when the source was a legacy v1 file.
|
||||
public int FormatVersion = 1;
|
||||
public BlueprintParams Params;
|
||||
}
|
||||
|
||||
// 2. The Parser Utility
|
||||
|
|
@ -33,18 +61,262 @@ namespace IslaApocalypse.Core
|
|||
public static WorldBlueprint LoadMapData(string seedStr)
|
||||
{
|
||||
string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
|
||||
|
||||
return LoadMapDataFromPath(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a blueprint from an explicit absolute path. Same result as LoadMapData;
|
||||
/// exists so tooling (the round-trip harness) can load files that do not follow
|
||||
/// the seed-derived naming.
|
||||
/// </summary>
|
||||
public static WorldBlueprint LoadMapDataFromPath(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
WorldBlueprint blueprint = new WorldBlueprint();
|
||||
|
||||
using (FileStream stream = File.OpenRead(filePath))
|
||||
{
|
||||
using (BinaryReader reader = new BinaryReader(stream))
|
||||
{
|
||||
// Format dispatch by the very first byte: v2 opens with raw "ISLA"
|
||||
// (0x49 'I'), v1 opens with 0x07 — the 7-bit length prefix of its
|
||||
// length-prefixed "ISLA_V1" string. Unambiguous by construction.
|
||||
int firstByte = stream.ReadByte();
|
||||
stream.Position = 0;
|
||||
|
||||
if (firstByte == 0x49)
|
||||
return LoadV2(reader);
|
||||
|
||||
if (firstByte == 0x07)
|
||||
{
|
||||
GD.Print("[MapDataParser] ⚠ DEPRECATED: loading a legacy v1 blueprint — regenerate to produce v2.");
|
||||
return LoadV1(reader);
|
||||
}
|
||||
|
||||
GD.PrintErr($"[MapDataParser] ERROR: {filePath} is neither a v2 nor a v1 blueprint (first byte 0x{firstByte:X2}).");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// v2 PARSE PATH — the tagged-section container.
|
||||
// Layout: Core/Scripts/BLUEPRINT_FORMAT.md. Constants: BlueprintFormat.
|
||||
// Reader rule: known tag -> parse, unknown tag -> skip by length.
|
||||
// ===============================================================
|
||||
private static WorldBlueprint LoadV2(BinaryReader reader)
|
||||
{
|
||||
Stream stream = reader.BaseStream;
|
||||
long fileLength = stream.Length;
|
||||
|
||||
uint magic = reader.ReadUInt32();
|
||||
if (magic != BlueprintFormat.MAGIC)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: bad v2 magic 0x{magic:X8} (expected \"ISLA\").");
|
||||
return null;
|
||||
}
|
||||
uint version = reader.ReadUInt32();
|
||||
if (version != BlueprintFormat.VERSION)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: unsupported blueprint format version {version} " +
|
||||
$"(this build reads version {BlueprintFormat.VERSION}). Refusing to load.");
|
||||
return null;
|
||||
}
|
||||
|
||||
WorldBlueprint blueprint = new WorldBlueprint { FormatVersion = 2 };
|
||||
var seenTags = new HashSet<uint>();
|
||||
bool isFirstSection = true;
|
||||
|
||||
while (stream.Position < fileLength)
|
||||
{
|
||||
if (fileLength - stream.Position < 12)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: truncated section header at offset {stream.Position}.");
|
||||
return null;
|
||||
}
|
||||
|
||||
uint tag = reader.ReadUInt32();
|
||||
ulong payloadLength = reader.ReadUInt64();
|
||||
long payloadStart = stream.Position;
|
||||
|
||||
if (payloadLength > (ulong)(fileLength - payloadStart))
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: section '{BlueprintFormat.TagToString(tag)}' declares " +
|
||||
$"{payloadLength} bytes but only {fileLength - payloadStart} remain. Truncated or corrupt file.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isFirstSection && tag != BlueprintFormat.TAG_PARAMS)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: first section is '{BlueprintFormat.TagToString(tag)}' — " +
|
||||
"the params section must come first.");
|
||||
return null;
|
||||
}
|
||||
isFirstSection = false;
|
||||
|
||||
if (!seenTags.Add(tag))
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: duplicate section '{BlueprintFormat.TagToString(tag)}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
bool sectionOk;
|
||||
if (tag == BlueprintFormat.TAG_PARAMS) sectionOk = ParseParams(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_HEIGHTS) sectionOk = ParseHeights(reader, blueprint, payloadLength);
|
||||
else if (tag == BlueprintFormat.TAG_BIOMES) sectionOk = ParseBiomes(reader, blueprint, payloadLength);
|
||||
else if (tag == BlueprintFormat.TAG_TOWNS) sectionOk = ParseTowns(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_ROADS_HIGHWAY) sectionOk = ParseRoadTier(reader, blueprint.Highways);
|
||||
else if (tag == BlueprintFormat.TAG_ROADS_BRANCH) sectionOk = ParseRoadTier(reader, blueprint.BranchRoads);
|
||||
else if (tag == BlueprintFormat.TAG_ROADS_RUGGED) sectionOk = ParseRoadTier(reader, blueprint.RuggedRoads);
|
||||
else if (tag == BlueprintFormat.TAG_ROADS_TRAIL) sectionOk = ParseRoadTier(reader, blueprint.TrailRoads);
|
||||
else
|
||||
{
|
||||
// The property the redesign exists to buy: future sections (water,
|
||||
// anything) are invisible to a reader that predates them.
|
||||
GD.Print($"[MapDataParser] Skipping unknown section '{BlueprintFormat.TagToString(tag)}' ({payloadLength} bytes).");
|
||||
stream.Seek((long)payloadLength, SeekOrigin.Current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sectionOk) return null;
|
||||
|
||||
if (stream.Position != payloadStart + (long)payloadLength)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: section '{BlueprintFormat.TagToString(tag)}' parsed " +
|
||||
$"{stream.Position - payloadStart} bytes but declared {payloadLength}.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (blueprint.Params == null || blueprint.HeightMap == null || blueprint.BiomeMap == null)
|
||||
{
|
||||
GD.PrintErr("[MapDataParser] ERROR: v2 blueprint is missing a mandatory section (params/heights/biomes).");
|
||||
return null;
|
||||
}
|
||||
if (!seenTags.Contains(BlueprintFormat.TAG_TOWNS))
|
||||
GD.Print("[MapDataParser] ⚠ v2 blueprint has no towns section — town list is empty.");
|
||||
if (!seenTags.Contains(BlueprintFormat.TAG_ROADS_HIGHWAY) && !seenTags.Contains(BlueprintFormat.TAG_ROADS_BRANCH) &&
|
||||
!seenTags.Contains(BlueprintFormat.TAG_ROADS_RUGGED) && !seenTags.Contains(BlueprintFormat.TAG_ROADS_TRAIL))
|
||||
GD.Print("[MapDataParser] ⚠ v2 blueprint has no road sections — road lists are empty.");
|
||||
|
||||
GD.Print($"[MapDataParser] Successfully loaded v2 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}, " +
|
||||
$"seed {blueprint.Params.WorldSeed}, generated {blueprint.Params.GeneratedUtc}.");
|
||||
return blueprint;
|
||||
}
|
||||
|
||||
private static bool ParseParams(BinaryReader reader, WorldBlueprint blueprint)
|
||||
{
|
||||
var p = new BlueprintParams();
|
||||
p.WorldSeed = reader.ReadInt32();
|
||||
p.MapSize = reader.ReadInt32();
|
||||
p.CraterRadius = reader.ReadSingle();
|
||||
p.DensityMultiplier = reader.ReadSingle();
|
||||
p.ImpactCenter = new Vector2(reader.ReadSingle(), reader.ReadSingle());
|
||||
p.GeneratedUtc = reader.ReadString();
|
||||
p.GeneratorGitHash = reader.ReadString();
|
||||
|
||||
if (p.MapSize < BlueprintFormat.MIN_MAP_SIZE || p.MapSize > BlueprintFormat.MAX_MAP_SIZE)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: MapSize {p.MapSize} outside sane bounds " +
|
||||
$"[{BlueprintFormat.MIN_MAP_SIZE}, {BlueprintFormat.MAX_MAP_SIZE}]. Refusing to allocate.");
|
||||
return false;
|
||||
}
|
||||
|
||||
blueprint.Params = p;
|
||||
blueprint.MapSize = p.MapSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseHeights(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
|
||||
{
|
||||
int n = blueprint.MapSize;
|
||||
if (payloadLength != 4UL * (ulong)n * (ulong)n)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: heights section is {payloadLength} bytes, expected {4UL * (ulong)n * (ulong)n}.");
|
||||
return false;
|
||||
}
|
||||
blueprint.HeightMap = new float[n, n];
|
||||
for (int x = 0; x < n; x++)
|
||||
for (int y = 0; y < n; y++)
|
||||
blueprint.HeightMap[x, y] = reader.ReadSingle();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseBiomes(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
|
||||
{
|
||||
int n = blueprint.MapSize;
|
||||
if (payloadLength != (ulong)n * (ulong)n)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: biomes section is {payloadLength} bytes, expected {(ulong)n * (ulong)n}.");
|
||||
return false;
|
||||
}
|
||||
int biomeCount = System.Enum.GetValues<Biome>().Length;
|
||||
blueprint.BiomeMap = new Biome[n, n];
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
byte ordinal = reader.ReadByte();
|
||||
if (ordinal >= biomeCount)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: biome ordinal {ordinal} at ({x},{y}) is out of range " +
|
||||
$"(known biomes: 0..{biomeCount - 1}). Corrupt file or enum drift.");
|
||||
return false;
|
||||
}
|
||||
blueprint.BiomeMap[x, y] = (Biome)ordinal;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseTowns(BinaryReader reader, WorldBlueprint blueprint)
|
||||
{
|
||||
int tierCount = System.Enum.GetValues<TownTier>().Length;
|
||||
int townCount = reader.ReadInt32();
|
||||
for (int i = 0; i < townCount; i++)
|
||||
{
|
||||
TownLocation town = new TownLocation();
|
||||
town.Position = new Vector2(reader.ReadSingle(), reader.ReadSingle());
|
||||
byte tier = reader.ReadByte();
|
||||
if (tier >= tierCount)
|
||||
{
|
||||
GD.PrintErr($"[MapDataParser] ERROR: town {i} tier ordinal {tier} is out of range " +
|
||||
$"(known tiers: 0..{tierCount - 1}).");
|
||||
return false;
|
||||
}
|
||||
town.Tier = (TownTier)tier;
|
||||
town.IsHighwayNode = reader.ReadByte() != 0;
|
||||
blueprint.Towns.Add(town);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||
{
|
||||
int pathCount = reader.ReadInt32();
|
||||
for (int i = 0; i < pathCount; i++)
|
||||
{
|
||||
int pathLength = reader.ReadInt32();
|
||||
Vector2[] path = new Vector2[pathLength];
|
||||
for (int p = 0; p < pathLength; p++) { path[p] = new Vector2(reader.ReadSingle(), reader.ReadSingle()); }
|
||||
into.Add(path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// LEGACY v1 PARSE PATH — the original "ISLA_V1" positional format.
|
||||
// Kept intact and untouched; do not extend it. New content belongs
|
||||
// in the v2 tagged-section format.
|
||||
// ===============================================================
|
||||
private static WorldBlueprint LoadV1(BinaryReader reader)
|
||||
{
|
||||
WorldBlueprint blueprint = new WorldBlueprint();
|
||||
|
||||
{
|
||||
{
|
||||
// 1. Header Validation (CRITICAL: Must read string first to align bytes!)
|
||||
string header = reader.ReadString();
|
||||
|
|
@ -122,7 +394,7 @@ namespace IslaApocalypse.Core
|
|||
}
|
||||
}
|
||||
|
||||
GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}.");
|
||||
GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}.");
|
||||
return blueprint;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,25 @@ rendering.
|
|||
### Shared identifiers
|
||||
- **`Enums.cs`** — `Biome`, `TownTier`, `MapHalf`, `RoadTier`.
|
||||
|
||||
⚠ **`Biome` and `TownTier` are the `.dat` wire format.** They are declared without explicit values,
|
||||
so each member's ordinal *is* the number written to disk, with no version gate and no validation on
|
||||
read. **Append only, at the end — never reorder or insert.** Doing so silently reinterprets every
|
||||
pixel and every settlement in every existing blueprint.
|
||||
⚠ **`Biome` and `TownTier` are the `.dat` wire format** (u8 in the v2 container, i32 in legacy
|
||||
v1). They are declared without explicit values, so each member's ordinal *is* the number written
|
||||
to disk. **Append only, at the end — never reorder or insert.** The v2 reader range-checks
|
||||
ordinals so drift fails loudly at parse time, but only the append-only rule keeps old files
|
||||
*meaning* the same thing. Legacy v1 has no gate and no validation at all.
|
||||
`RoadTier` is exempt: road tiers live in separate sections of the file, so it is never serialized.
|
||||
|
||||
### World data
|
||||
- **`MapDataParser.cs`** — decodes the `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map,
|
||||
towns, four road tiers).
|
||||
- **`BLUEPRINT_FORMAT.md`** — the byte-accurate `.dat` container contract (v2 tagged sections +
|
||||
the v1 legacy summary). Read this before touching the writer or parser.
|
||||
- **`BlueprintFormat.cs`** — the single registry of v2 constants: magic, version gate, section
|
||||
tags (FourCC), validation bounds, re-encode sentinels.
|
||||
- **`BlueprintWriter.cs`** — writes a `WorldBlueprint` as a v2 file. Blueprint-typed on purpose:
|
||||
the map generator and the round-trip harness are both just callers.
|
||||
- **`MapDataParser.cs`** — decodes a `.dat` blueprint into a `WorldBlueprint` (heightmap, biome map,
|
||||
towns, four road tiers, and — v2 only — the embedded generation params and per-town highway-node
|
||||
flag). Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
|
||||
bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a
|
||||
deprecation warning.
|
||||
- **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the
|
||||
mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs.
|
||||
- **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ future multiplayer setup this is the side that dictates terrain and ships chunk
|
|||
Attached to the `World` root node of `Scenes/Main.tscn`. Runs the whole 3D world at boot.
|
||||
|
||||
### Startup
|
||||
1. Loads `ServerConfig.json` and the seed's `.dat` blueprint.
|
||||
2. **Finds the Capitol** in the parsed town list and uses it as the world origin point.
|
||||
3. Converts its pixel position to chunk coordinates (`pixel / CHUNK_SIZE`).
|
||||
4. Builds a `(2 × ChunkRadius)²` grid of chunks around it — **synchronously, all at boot**.
|
||||
5. Teleports the `Camera3D` to 120 m above the Capitol, looking down.
|
||||
1. Loads `ServerConfig.json` and the seed's `.dat` blueprint (v2 or legacy v1 — the parser
|
||||
dispatches automatically; see `Core/Scripts/BLUEPRINT_FORMAT.md`).
|
||||
2. **Cross-checks the blueprint's embedded params** (v2 only) against the config and logs a
|
||||
prominent `BLUEPRINT/CONFIG DESYNC` warning if the seed or MapSize disagree — a config edited
|
||||
after generation is loud now, not silent.
|
||||
3. **Finds the Capitol** in the parsed town list and uses it as the world origin point.
|
||||
4. Converts its pixel position to chunk coordinates (`pixel / CHUNK_SIZE`).
|
||||
5. Builds a `(2 × ChunkRadius)²` grid of chunks around it — **synchronously, all at boot**.
|
||||
6. Teleports the `Camera3D` to 120 m above the Capitol, looking down.
|
||||
|
||||
⚠ **Two things to know about startup.** The chunk grid is built in one blocking pass with no
|
||||
streaming or unloading, so `ChunkRadius` directly controls boot cost — 32 means 4,096 chunks and
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ namespace IslaApocalypse.Server
|
|||
|
||||
if (_blueprint != null)
|
||||
{
|
||||
// Params cross-check (v2 blueprints only): the file carries its resolved
|
||||
// generation inputs, so a config edited after generation is detectable —
|
||||
// canon H7's silent desync becomes loud. Warning, not an abort: the world
|
||||
// still loads; the developer decides what to do about the mismatch.
|
||||
if (_blueprint.Params != null)
|
||||
{
|
||||
var p = _blueprint.Params;
|
||||
if (p.WorldSeed != BlueprintFormat.SENTINEL_WORLD_SEED && p.WorldSeed != ConfigManager.WorldSeed)
|
||||
GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint was generated with seed " +
|
||||
$"{p.WorldSeed} but ServerConfig.json says {ConfigManager.WorldSeed}. " +
|
||||
"The world you load is not the world this config describes.");
|
||||
if (p.MapSize != ConfigManager.MapSize)
|
||||
GD.PrintErr($"[Server] ⚠⚠ BLUEPRINT/CONFIG DESYNC: blueprint MapSize {p.MapSize} " +
|
||||
$"vs config MapSize {ConfigManager.MapSize}.");
|
||||
}
|
||||
|
||||
GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
|
||||
|
||||
// 2. Find the Capitol in the parsed data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"WorldSeed": 1063685222,
|
||||
"WorldSeed": 1409879727,
|
||||
"MapProfile": "8K",
|
||||
"TownDensity": "Normal",
|
||||
"ChunkRadius": 32
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ so they only help between paths. Expect a regeneration to take minutes, or to ne
|
|||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `MapData_Seed_<seed>.dat` | The binary blueprint the server reads (~512 MB at 8K) |
|
||||
| `MapData_Seed_<seed>.dat` | The binary blueprint the server reads — **v2 tagged container** (~336 MB at 8K; contract in `Core/Scripts/BLUEPRINT_FORMAT.md`) |
|
||||
| `MapData_Seed_<seed>_v1.dat` | The same content in the legacy v1 format (~512 MB), dual-written as a safety net until a future removal task |
|
||||
| `Map_Seed_<seed>.png` | Visual snapshot of the map, for reviewing and picking seeds |
|
||||
|
||||
On Linux: `~/.local/share/godot/app_userdata/islaApocolypse/`.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ tiny or black PNG.
|
|||
Note the generator writes the `.dat` on its own — the capture wrapper only matters for the
|
||||
full-resolution image.
|
||||
|
||||
## `RoundTripHarness.tscn`
|
||||
|
||||
The blueprint format regression test (script: `Tools/Scripts/RoundTripHarness.cs`). Run it
|
||||
headless from a terminal — it never generates, it round-trips an existing blueprint through the
|
||||
real parser and v2 writer and asserts semantic equality:
|
||||
|
||||
```
|
||||
Godot --headless --path <repo> res://Tools/Scenes/RoundTripHarness.tscn
|
||||
```
|
||||
|
||||
Defaults to the preserved reference blueprint (`user://reference_1409879727/`); override with the
|
||||
`HARNESS_V1_PATH` / `HARNESS_V2_PATH` environment variables. Exit code 0 = pass.
|
||||
|
||||
## Generating a new world
|
||||
|
||||
1. Open `MapPreview.tscn` (or `Scenes/MapCaptureTool.tscn` for the full-resolution PNG).
|
||||
|
|
|
|||
6
Tools/Scenes/RoundTripHarness.tscn
Normal file
6
Tools/Scenes/RoundTripHarness.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://Tools/Scripts/RoundTripHarness.cs" id="1_rth"]
|
||||
|
||||
[node name="RoundTripHarness" type="Node"]
|
||||
script = ExtResource("1_rth")
|
||||
|
|
@ -180,9 +180,62 @@ public partial class MapGenerator : TextureRect
|
|||
private void ExportMapData()
|
||||
{
|
||||
string seedStr = _noise.Seed.ToString();
|
||||
string filePath = $"user://MapData_Seed_{seedStr}.dat";
|
||||
|
||||
using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create))
|
||||
|
||||
// PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md),
|
||||
// under the name the server looks for.
|
||||
string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
|
||||
BlueprintWriter.WriteV2(v2Path, BuildBlueprint());
|
||||
|
||||
// SAFETY NET: the legacy v1 format beside it, until the developer has lived
|
||||
// with v2 across several regenerations. Removal is a future task.
|
||||
ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved
|
||||
/// generation params) for BlueprintWriter. Arrays are shared, not copied.
|
||||
/// </summary>
|
||||
private WorldBlueprint BuildBlueprint()
|
||||
{
|
||||
var blueprint = new WorldBlueprint
|
||||
{
|
||||
MapSize = MapSize,
|
||||
HeightMap = _heightMap,
|
||||
BiomeMap = _biomeMap,
|
||||
Highways = _highwayPaths,
|
||||
BranchRoads = _branchPaths,
|
||||
RuggedRoads = _ruggedPaths,
|
||||
TrailRoads = _trailPaths,
|
||||
FormatVersion = 2,
|
||||
Params = new BlueprintParams
|
||||
{
|
||||
WorldSeed = _noise.Seed, // the RESOLVED seed — also names the file
|
||||
MapSize = MapSize,
|
||||
CraterRadius = _impactRadius,
|
||||
DensityMultiplier = ConfigManager.DensityMultiplier,
|
||||
ImpactCenter = _impactCenter
|
||||
}
|
||||
};
|
||||
foreach (var town in _towns)
|
||||
{
|
||||
blueprint.Towns.Add(new TownLocation
|
||||
{
|
||||
Position = town.Position,
|
||||
Tier = town.Tier,
|
||||
IsHighwayNode = town.IsHighwayNode
|
||||
});
|
||||
}
|
||||
return blueprint;
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// LEGACY v1 WRITER — the original "ISLA_V1" positional format.
|
||||
// Kept intact as the dual-write safety net; removal is a future
|
||||
// task. Do not extend it — new content belongs in the v2 writer.
|
||||
// ===============================================================
|
||||
private void ExportMapDataV1(string absolutePath)
|
||||
{
|
||||
using (FileStream stream = File.Open(absolutePath, FileMode.Create))
|
||||
{
|
||||
using (BinaryWriter writer = new BinaryWriter(stream))
|
||||
{
|
||||
|
|
@ -237,7 +290,7 @@ public partial class MapGenerator : TextureRect
|
|||
}
|
||||
}
|
||||
}
|
||||
GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath));
|
||||
GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath);
|
||||
}
|
||||
|
||||
// --- THE ORIGINAL MAP GENERATION CODE (Restored!) ---
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ Generates the entire 2D blueprint. Roughly in order:
|
|||
5. **Biomes and towns** — biome zoning by height and temperature; tiered town placement (Capitol,
|
||||
Hubs, Villages, Outposts, POIs) filtered by slope, water proximity and spacing.
|
||||
6. **Roads** — see below.
|
||||
7. **Export** — writes the `.dat` blueprint, then renders the PNG snapshot.
|
||||
7. **Export** — writes the `.dat` blueprint (dual-write: the v2 tagged container under the primary
|
||||
seed name, plus the legacy v1 format beside it as `_v1.dat` — see
|
||||
`Core/Scripts/BLUEPRINT_FORMAT.md`), then renders the PNG snapshot. The v2 file embeds the
|
||||
resolved generation params (seed, MapSize, crater radius, density, impact centre, provenance).
|
||||
|
||||
### The road network
|
||||
|
||||
|
|
@ -52,7 +55,15 @@ Road colours on the snapshot, useful for identifying a road: **red** = Highway,
|
|||
**dark brown** = Rugged, **light brown** = Trail.
|
||||
|
||||
### Outputs
|
||||
`MapData_Seed_<seed>.dat` and `Map_Seed_<seed>.png`, both to `user://`.
|
||||
`MapData_Seed_<seed>.dat` (v2), `MapData_Seed_<seed>_v1.dat` (legacy dual-write), and
|
||||
`Map_Seed_<seed>.png`, all to `user://`.
|
||||
|
||||
## `RoundTripHarness.cs`
|
||||
|
||||
The blueprint format regression test (scene: `Tools/Scenes/RoundTripHarness.tscn`). Loads a
|
||||
known-good blueprint through the real parser, re-writes it as v2 through the real writer, re-loads
|
||||
it, and asserts semantic equality (heights bitwise, biomes, towns, every road point). Headless,
|
||||
seconds per cycle, no generation. Exit code 0 = pass.
|
||||
|
||||
## Rules
|
||||
1. **No magic numbers.** Distances, radii and thresholds derive from `MapSize` or `scaleFactor`, so
|
||||
|
|
|
|||
156
Tools/Scripts/RoundTripHarness.cs
Normal file
156
Tools/Scripts/RoundTripHarness.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
using Godot;
|
||||
using System.Collections.Generic;
|
||||
using IslaApocalypse.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The blueprint round-trip oracle (terrain-water task 02). Runs headless in seconds,
|
||||
/// no generation, no road pass:
|
||||
///
|
||||
/// 1. load a known-good v1 blueprint (default: the preserved reference copy of seed
|
||||
/// 1409879727) through the real parser,
|
||||
/// 2. write it back out as v2 through the real writer (to a harness-only name — the
|
||||
/// reference file is never overwritten),
|
||||
/// 3. load the v2 file through the real parser,
|
||||
/// 4. assert semantic equality: heights bitwise, biomes equal, towns equal in
|
||||
/// position/tier/count, each road tier point-for-point.
|
||||
///
|
||||
/// Params and the per-town highway flag are EXCLUDED from equality — a v1 source
|
||||
/// cannot supply them; they are verified in the full-generation acceptance run.
|
||||
///
|
||||
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RoundTripHarness.tscn
|
||||
/// Env overrides: HARNESS_V1_PATH (source file), HARNESS_V2_PATH (output file).
|
||||
/// Exit code 0 = PASS, 1 = FAIL. Worth keeping permanently as a format regression test.
|
||||
/// </summary>
|
||||
public partial class RoundTripHarness : Node
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
string v1Path = OS.GetEnvironment("HARNESS_V1_PATH");
|
||||
if (string.IsNullOrEmpty(v1Path))
|
||||
v1Path = ProjectSettings.GlobalizePath("user://reference_1409879727/MapData_Seed_1409879727.dat");
|
||||
string v2Path = OS.GetEnvironment("HARNESS_V2_PATH");
|
||||
if (string.IsNullOrEmpty(v2Path))
|
||||
v2Path = ProjectSettings.GlobalizePath("user://Harness_RoundTrip_v2.dat");
|
||||
|
||||
GD.Print($"[Harness] v1 source: {v1Path}");
|
||||
GD.Print($"[Harness] v2 output: {v2Path}");
|
||||
|
||||
bool pass = false;
|
||||
try
|
||||
{
|
||||
pass = RunRoundTrip(v1Path, v2Path);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
GD.PrintErr($"[Harness] EXCEPTION: {e}");
|
||||
}
|
||||
|
||||
GD.Print(pass ? "[Harness] RESULT: PASS" : "[Harness] RESULT: FAIL");
|
||||
GetTree().Quit(pass ? 0 : 1);
|
||||
}
|
||||
|
||||
private bool RunRoundTrip(string v1Path, string v2Path)
|
||||
{
|
||||
ulong t0 = Time.GetTicksMsec();
|
||||
WorldBlueprint original = MapDataParser.LoadMapDataFromPath(v1Path);
|
||||
ulong t1 = Time.GetTicksMsec();
|
||||
if (original == null) { GD.PrintErr("[Harness] v1 load failed."); return false; }
|
||||
GD.Print($"[Harness] v1 parse: {(t1 - t0) / 1000.0:F1}s (format v{original.FormatVersion})");
|
||||
|
||||
BlueprintWriter.WriteV2(v2Path, original);
|
||||
ulong t2 = Time.GetTicksMsec();
|
||||
GD.Print($"[Harness] v2 write: {(t2 - t1) / 1000.0:F1}s");
|
||||
|
||||
WorldBlueprint reread = MapDataParser.LoadMapDataFromPath(v2Path);
|
||||
ulong t3 = Time.GetTicksMsec();
|
||||
if (reread == null) { GD.PrintErr("[Harness] v2 load failed."); return false; }
|
||||
GD.Print($"[Harness] v2 parse: {(t3 - t2) / 1000.0:F1}s (format v{reread.FormatVersion})");
|
||||
|
||||
return Compare(original, reread);
|
||||
}
|
||||
|
||||
private bool Compare(WorldBlueprint a, WorldBlueprint b)
|
||||
{
|
||||
bool ok = true;
|
||||
|
||||
if (a.MapSize != b.MapSize)
|
||||
{
|
||||
GD.PrintErr($"[Harness] MapSize mismatch: {a.MapSize} vs {b.MapSize}");
|
||||
return false; // nothing below is comparable
|
||||
}
|
||||
|
||||
// Heights: bitwise-identical floats.
|
||||
long heightDiffs = 0;
|
||||
for (int x = 0; x < a.MapSize; x++)
|
||||
for (int y = 0; y < a.MapSize; y++)
|
||||
if (System.BitConverter.SingleToInt32Bits(a.HeightMap[x, y]) !=
|
||||
System.BitConverter.SingleToInt32Bits(b.HeightMap[x, y]))
|
||||
heightDiffs++;
|
||||
if (heightDiffs > 0) { GD.PrintErr($"[Harness] {heightDiffs} height pixels differ bitwise."); ok = false; }
|
||||
|
||||
// Biomes: equal values (i32 -> u8 narrowing is documented and lossless for 0..255).
|
||||
long biomeDiffs = 0;
|
||||
for (int x = 0; x < a.MapSize; x++)
|
||||
for (int y = 0; y < a.MapSize; y++)
|
||||
if (a.BiomeMap[x, y] != b.BiomeMap[x, y])
|
||||
biomeDiffs++;
|
||||
if (biomeDiffs > 0) { GD.PrintErr($"[Harness] {biomeDiffs} biome pixels differ."); ok = false; }
|
||||
|
||||
// Towns: identical position/tier/count (highway flag deliberately not compared).
|
||||
if (a.Towns.Count != b.Towns.Count)
|
||||
{
|
||||
GD.PrintErr($"[Harness] Town count mismatch: {a.Towns.Count} vs {b.Towns.Count}");
|
||||
ok = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < a.Towns.Count; i++)
|
||||
{
|
||||
if (a.Towns[i].Position != b.Towns[i].Position || a.Towns[i].Tier != b.Towns[i].Tier)
|
||||
{
|
||||
GD.PrintErr($"[Harness] Town {i} mismatch: " +
|
||||
$"{a.Towns[i].Position}/{a.Towns[i].Tier} vs {b.Towns[i].Position}/{b.Towns[i].Tier}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok &= CompareRoads("Highway", a.Highways, b.Highways);
|
||||
ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads);
|
||||
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
|
||||
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
||||
|
||||
if (ok)
|
||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||
$"{a.Towns.Count} towns, roads {a.Highways.Count}/{a.BranchRoads.Count}/" +
|
||||
$"{a.RuggedRoads.Count}/{a.TrailRoads.Count} paths.");
|
||||
return ok;
|
||||
}
|
||||
|
||||
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||
{
|
||||
if (a.Count != b.Count)
|
||||
{
|
||||
GD.PrintErr($"[Harness] {tier} path count mismatch: {a.Count} vs {b.Count}");
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < a.Count; i++)
|
||||
{
|
||||
if (a[i].Length != b[i].Length)
|
||||
{
|
||||
GD.PrintErr($"[Harness] {tier} path {i} length mismatch: {a[i].Length} vs {b[i].Length}");
|
||||
return false;
|
||||
}
|
||||
for (int p = 0; p < a[i].Length; p++)
|
||||
{
|
||||
if (System.BitConverter.SingleToInt32Bits(a[i][p].X) != System.BitConverter.SingleToInt32Bits(b[i][p].X) ||
|
||||
System.BitConverter.SingleToInt32Bits(a[i][p].Y) != System.BitConverter.SingleToInt32Bits(b[i][p].Y))
|
||||
{
|
||||
GD.PrintErr($"[Harness] {tier} path {i} point {p} differs: {a[i][p]} vs {b[i][p]}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/RoundTripHarness.cs.uid
Normal file
1
Tools/Scripts/RoundTripHarness.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://76veosvnqe8m
|
||||
Loading…
Reference in a new issue