islaApocalypse/Core/Scripts/BLUEPRINT_FORMAT.md
2026-08-08 00:29:34 -04:00

13 KiB
Raw Blame History

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::Biomeappend-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.
TCRV / 0x56524354 50 B: u16 curveVersion · input knots f32 t1..t4 · f32 spikeMax · output bands f32 sea · f32 orangeCeil · f32 redCeil · f32 plateauLo · f32 plateauHi · f32 peakCap · f32 tailSlope Optional — present iff the height-redistribution curve shaped this blueprint's HGTS (config TerrainCurve: "v2"); absent = raw legacy profile. spikeMax semantics by version: v1 = the pooled calibration max (identical every seed); v2 = the SEED'S own effective raw pre-curve maximum — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. Metadata only: exported heights are already curved; nothing re-applies the map. The curve lives in Tools/Scripts/HeightCurve.cs (calibration provenance in its header + the task-05/06 reports).
WBID / 0x44494257 MapSize² × u16 water-body id, same pixel order as HGTS Optional (absent = no water data, e.g. a legacy re-encode). 0 = no water, 1 = the ocean body, 2..N = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the same 4-connectivity as CalculateTrueOcean. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set by construction (shared predicates). Length must equal 2·MapSize².
WBTB / 0x42544257 i32 count, then per body (20 B): u16 id · u8 type (0 ocean, 1 lake) · u8 salinity (0 fresh, 1 salt) · f32 surfaceLevel · i32 pixelCount · f32 centroidX · f32 centroidY Optional, paired with WBID. surfaceLevel is a documented TRANSITIONAL rule: one flat level per body — GetSeaLevel at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. Salinity is a provisional default (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic.
WSRF / 0x46525357 MapSize² × u16 quantized water-surface elevation, same pixel order Optional, paired with WBID. 0 is the reserved no-water sentinel; a real level L (raw height units) encodes as 1 + round(L × 32768) so it can never encode to 0; decode (q 1)/32768 (BlueprintFormat.EncodeWaterLevel/DecodeWaterLevel). Covers [0 … ~1.99997] raw at 1/32768 raw ≈ 7.7 mm of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where WBID is nonzero; the value is the pixel's body level.
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. Road sections may legitimately be present but empty (zero paths): the SkipRoads config toggle exports a road-less iteration blueprint.
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 grids dominate: without water sections 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. With the water sections the per-pixel cost is 9·MapSize² (adds 2 B WBID + 2 B WSRF) ≈ 576 MiB at 8K plus a trivial WBTB (20 B per body).

Config knobs that shape blueprint content (not part of the byte format)

  • SeaLevelModel ("flat" | "field") + SeaLevelValue (float; used when flat) — D-033: selects what MapGenerator.GetSeaLevel returns. "flat" (the default, value 0.15) is one scalar sea level everywhere; "field" is the legacy latitude Lerp (0.26 north → 0.15 south). Generator-only — all nine GetSeaLevel call sites inherit the model; nothing at runtime computes sea level. The model in force shapes the coastline, masks, biomes, water bodies, and WBTB levels of the blueprint being generated; the file itself does not record which model produced it (the WBTB levels are the observable).
  • SkipRoads (bool, default false) — exports with present-but-empty road sections (see the road-section table note).
  • TerrainCurve ("v2" | "off", default "v2"; "v1" retired by the task-06 recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder: 75 % orange coverage, 50 m plateau, per-seed-normalized 420 m peaks) shapes HGTS. Biome/water classification is curve-invariant by construction (it classifies the retained uncurved heights); town positions and everything 3D follow the curved terrain. When on, TCRV records the effective parameters including the per-seed spikeMax.

Deliberately NOT a section: basins (BSIN)

The priority-flood pit-fill that runs in the water-bodies stage is diagnostics only and serializes nothing. Rationale, on the record so nobody adds a basin section later without meeting the argument: basin geometry is a pure function of the heightmap, and it goes stale the moment the coast-smoothing change set (or any terrain change) touches heights — a serialized BSIN would be a cache with a silent invalidation hazard. The rivers stage recomputes basins fresh at generation time (measured ~12 s at 8K — cheap enough to never cache).

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 MapSizeMapSize² × (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.