Phase 0: clean project skeleton for the v2 rewrite

Stands up the CODE_REPO the rewrite is written into (D-049, D-058). Godot 4.7.2 /
.NET 8 / Godot.NET.Sdk 4.7.2. Nothing is generated, meshed, or ported — this is the
shell and the two data contracts.

Four layers, each with its boundary stated in a directory README:
  Core/    math + data only, and engine-free — depends on nothing above it
  Server/  authoritative logic                (empty this phase)
  Client/  rendering                          (empty this phase)
  Tools/   the offline generator — may NOT reference Client   (Phase 1 fills it)

Contracts (compiling stubs, no algorithms):
  - Column model (D-053): a 2D grid of columns, each a stack of (material, thickness)
    runs, any material at any depth. Air is the TOP RUN — there is no air-vs-solid
    height branch, and no surface-height accessor exists to reintroduce one. Water is
    not a band; it stays an overlay.
  - Material schemas (D-054): terrain and building as two append-only registries,
    bridged by a recipe seam that is deliberately EMPTY. Identity is a registry key,
    never a serialized ordinal. Mesh style is per-ORIGIN and is not a material field.

Rails, so Phase 1 inherits them rather than rediscovering them:
  - GenerationScale: MapSize is a parameter, everything derives from it, nothing uses
    a raw pixel number. Tightening over the reference — decorrelation offsets are
    declared in map widths, not pixels (chat1/00 §4.2).
  - ToolingPaths: config/blueprint/output paths all env-overridable, resolved in one
    place, so a batch cannot touch the developer's live files.
  - FileSafety: the permanent no-deletion rules as throws rather than sentences.

user:// isolation: project name and use_custom_user_dir both pin the runtime dir to
~/.local/share/islaApocalypse-v2/, away from the old prototype's preserved seeds and
batches. Tools/Scenes/UserDirProbe.tscn confirms it rather than assuming it.
This commit is contained in:
Stewart Howe 2026-08-19 20:21:12 -04:00
commit 9107b0822b
36 changed files with 1453 additions and 0 deletions

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.svg text

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# ---- Godot 4.x ------------------------------------------------------------
# The import/script cache. Regenerated on import; never committed.
.godot/
# Exported builds.
/build/
/export/
*.translation
# ---- .NET / C# ------------------------------------------------------------
bin/
obj/
.mono/
*.user
*.userprefs
*.suo
*.swp
[Dd]ebug/
[Rr]elease/
msbuild.binlog
# ---- OS / editor ----------------------------------------------------------
.DS_Store
Thumbs.db
.idea/
.vs/
.vscode/
# ---- ⚠ NEVER COMMIT GENERATED WORLD DATA ---------------------------------
# Blueprints, batches and snapshots are OUTPUT — large, regenerable, and they live under the
# project's user:// directory, not here. If any of these ever appear inside the repo, something
# resolved a path wrong; fix the path, do not commit the file.
# → Core/Scripts/ToolingPaths.cs, Tools/README.md
*.blueprint
batches/*/
!batches/README.md
# Godot's C# project editor rewrites the .csproj on import and leaves a backup beside it.
# An artifact of the editor, not a source file. (Not deleted — file-safety rule: no deletions.)
*.csproj.old

22
Client/README.md Normal file
View file

@ -0,0 +1,22 @@
# Client — rendering and presentation
**Holds:** rendering, meshing, UI, input, everything the player sees.
**Boundary:** may use `Core/`. Must not depend on `Server/` internals.
> ### ⚠ Nothing in `Tools/` may reference this folder. The wall runs one way.
> → `Tools/README.md`, `Design - Tooling - Offline Generation.md`.
## Empty this phase — deliberately
Phase 0 has no renderer. The mesher (SN/DC smooth terrain + greedy-cube builds with seam-blend,
→ D-052, `Design - Rendering - Mesher.md`) is a much later phase, and building it before the data is
right is explicitly the trap the rewrite exists to undo (D-056: *"the data is perfected before the
mesher"*).
## One thing already decided, so it is not re-litigated later
**Mesh style is per-ORIGIN, not per-material.** Natural runs mesh smooth, player-placed runs mesh as
cubes, and the same material can be both. The flag the mesher reads is `RunOrigin` on the run — there
is no mesh-style field on any material row, and adding one would be a design regression.
`Core/Scripts/Column.cs`, D-054.

1
Client/Scripts/.gitkeep Normal file
View file

@ -0,0 +1 @@
# Placeholder so the empty layer folder is tracked. Delete when the first script lands.

55
Core/README.md Normal file
View file

@ -0,0 +1,55 @@
# Core — math and data only
**Holds:** the column model, the material schemas, the scaling discipline, tooling path
resolution and the file-safety rails. Constants and contracts.
**Boundary: Core depends on nothing above it.** Not on `Server/`, not on `Client/`, not on
`Tools/`. The dependency arrow points one way into this folder and never out of it.
> ### ⚠ Additional boundary, tighter than the layer rule: **Core is engine-free.**
>
> No `using Godot;` anywhere in this folder. Core is plain C# — no `Node`, no `GD.Print`, no
> `ProjectSettings`, no engine types in any signature.
>
> **Two reasons, both load-bearing.** (1) D-049 commits to *"C# for fast iteration, with C++-ready
> seams… port path-by-path when settled and hot"* — the hot paths that get ported first are exactly
> the ones in here, and a type that names `Godot.Vector2` cannot cross that seam. (2) It keeps the
> column model testable and reviewable without standing up an engine.
>
> Where Core genuinely needs something the engine knows — the resolved `user://` directory — the
> engine layer **hands it in** (`ToolingPaths.Configure`), rather than Core reaching for it.
> If you find yourself wanting an engine type here, the type belongs one layer up.
## What is in here
| File | What it is |
|---|---|
| `Scripts/Column.cs` | ★★ **The load-bearing contract** (D-053). A column as run-length bands; `MaterialRun`; `RunOrigin`. Read its header before touching anything that stores world data. |
| `Scripts/ColumnGrid.cs` | The 2D grid of columns. Shape only — chunking and streaming are later phases. |
| `Scripts/MaterialKey.cs` | Material **identity** — a registry key, never an ordinal. |
| `Scripts/TerrainMaterial.cs` | A row in the terrain schema (D-054). |
| `Scripts/BuildingMaterial.cs` | A row in the building schema (D-054). |
| `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. |
| `Scripts/RecipeRegistry.cs` | The transformation seam — **deliberately empty**. |
| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets. |
| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. |
| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. |
## The three rules this layer exists to make unbreakable
1. **Air is the top run.** There is no "air vs solid" height branch, anywhere, ever.
`Scripts/Column.cs`.
2. **Properties come from the registry, never from storage.** A run stores an identity.
`Scripts/MaterialRegistry.cs`.
3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`.
`Scripts/GenerationScale.cs`.
## Not here, and not by accident
- **No water.** Water is an overlay over the columns (levels-not-cells), never a band.
- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049).
- **No algorithms.** Phase 0 is shape. Stratigraphy, feature passes, meshing and run-splitting on
dig are later phases.
`Design - Data - Column Model.md`, `Design - Data - Material Schema.md`,
`Design - Tooling - Scaling Discipline.md`

View file

@ -0,0 +1,64 @@
namespace IslaApocalypse.Core
{
/// <summary>
/// A row in the BUILDING material schema — what players construct with.
/// → `Design - Data - Material Schema.md` §2, D-054.
///
/// ⚠ DELIBERATELY A SEPARATE SCHEMA FROM <see cref="TerrainMaterial"/>, NOT A TAG ON ONE TABLE.
/// Natural stone and a placed stone block are not the same thing tagged differently: one is a
/// resource you harvest, the other is something you craft and build with. One tagged table was
/// considered and REJECTED — it blurs the resource→product relationship that IS the survival
/// loop, and invites the two concerns to bleed into each other the way biomes bled into terrain.
/// The bridge between the two schemas is <see cref="RecipeRegistry"/>, not a shared row type.
///
/// ⚠ NO MESH-STYLE FIELD HERE EITHER — same rule, same reason. See <see cref="TerrainMaterial"/>.
///
/// APPEND-ONLY. Adding a material is one new row.
/// </summary>
public sealed class BuildingMaterial
{
/// <summary>Identity. A registry key, not an ordinal.</summary>
public MaterialKey Key { get; }
/// <summary>Human-facing name. Presentation only.</summary>
public string DisplayName { get; }
/// <summary>
/// How far this material carries structural support from an anchor, before decay.
/// → `Design - Systems - Structural Integrity.md` (D-055: per-material reach).
/// </summary>
public float SupportReach { get; }
/// <summary>How fast support falls off across that reach. 0 = no decay, 1 = immediate.</summary>
public float SupportDecay { get; }
/// <summary>Weight the material imposes on what carries it.</summary>
public float Weight { get; }
/// <summary>Damage the material absorbs before failing.</summary>
public float Durability { get; }
/// <summary>
/// Runtime-only dense index. ⚠⚠ NEVER SERIALIZED — see <see cref="TerrainMaterial.RuntimeIndex"/>.
/// </summary>
public int RuntimeIndex { get; internal set; } = -1;
public BuildingMaterial(
MaterialKey key,
string displayName,
float supportReach,
float supportDecay,
float weight,
float durability)
{
Key = key;
DisplayName = displayName;
SupportReach = supportReach;
SupportDecay = supportDecay;
Weight = weight;
Durability = durability;
}
public override string ToString() => $"BuildingMaterial({Key})";
}
}

View file

@ -0,0 +1 @@
uid://dxqo466b6ns0l

170
Core/Scripts/Column.cs Normal file
View file

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>
/// Where a run came FROM. Not a material property — a fact about provenance.
///
/// ⭐ THIS, AND ONLY THIS, DECIDES MESH STYLE: natural runs mesh smooth (SN/DC), player-placed
/// runs mesh as cubes, and the seam-blend handles the join. The same material can be both —
/// which is exactly why mesh style is NOT a field on the material row.
/// → D-054 ("material does not override mesh style"), `Design - Rendering - Mesher.md`, D-052.
///
/// No mesher exists this phase. The flag exists so the mesher has something true to read.
/// </summary>
public enum RunOrigin : byte
{
/// <summary>Produced by world generation — stratigraphy, or a 3D feature pass.</summary>
Natural = 0,
/// <summary>Placed by a player.</summary>
PlayerPlaced = 1,
}
/// <summary>
/// One run-length band in a column: a material, a thickness, and where it came from.
/// → `Design - Data - Column Model.md`, D-053.
///
/// ⚠ A RUN STORES A MATERIAL IDENTITY AND NOTHING ELSE ABOUT THE MATERIAL.
/// Hardness, yield, support strength — every property — comes from
/// <see cref="MaterialRegistry"/> by lookup on <see cref="Material"/>. Nothing is stored
/// per-run. That is properties-from-type, and it is what makes materials, support and
/// mod-ability clean. → `Design - Data - Material Schema.md`.
///
/// <see cref="Origin"/> is not an exception to that rule: it is not a property OF the material,
/// it is a property of THIS RUN.
/// </summary>
public readonly struct MaterialRun
{
/// <summary>The material's registry key. → <see cref="MaterialKey"/>.</summary>
public readonly MaterialKey Material;
/// <summary>Thickness in voxels, bottom to top. Strictly positive.</summary>
public readonly int Thickness;
/// <summary>Provenance. Decides mesh style; see <see cref="RunOrigin"/>.</summary>
public readonly RunOrigin Origin;
public MaterialRun(MaterialKey material, int thickness, RunOrigin origin = RunOrigin.Natural)
{
if (material.IsNone)
throw new ArgumentException("A run must name a material. Air is a material (core.air), not an absence.", nameof(material));
if (thickness <= 0)
throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "A run's thickness must be strictly positive. A zero-thickness run is not a thing; omit it.");
Material = material;
Thickness = thickness;
Origin = origin;
}
public override string ToString() => $"{Material} x{Thickness} ({Origin})";
}
/// <summary>
/// ★★ THE LOAD-BEARING CONTRACT — one horizontal cell of the world, as a vertical stack of
/// run-length bands, bottom to top. → `Design - Data - Column Model.md`, D-053.
///
/// <code>
/// bedrock 050
/// limestone 5060
/// air 6070 ← a cave: an air run mid-column
/// limestone 7084
/// iron 8486 ← an ore vein: a material run inside limestone
/// limestone 8690
/// clay 9098
/// topsoil 98100
/// air 100+ ← everything above the surface is simply the top air run
/// </code>
///
/// ═══ TWO GENERALIZATIONS THIS SHAPE BUYS ═══
///
/// 1. ANY MATERIAL AT ANY DEPTH — not "one material per depth zone". A run can be any material
/// at any position, which is what lets 3D feature passes write into columns: a cave is an
/// AIR run mid-stack, an ore vein is an ore run inside rock. No cave or ore code exists this
/// phase; the SHAPE that makes them trivial does.
///
/// 2. SURFACE MATERIAL IS CLIMATE-DRIVEN, not a fixed top layer. The topmost solid run's
/// material is f(elevation, temperature, precipitation) from the 2D maps, so snow caps and
/// bare peaks fall out for free. ⭐ Snow is a surface MATERIAL, not a biome — biomes are a
/// separate classification layer. No stratigraphy exists this phase.
///
/// ═══ ⚠⚠ THE BEHAVIOURAL RULE — THE ONE THIS TYPE EXISTS TO ENFORCE ═══
///
/// NEVER BRANCH ON "AIR VS SOLID" FROM HEIGHT AS A SPECIAL CASE.
///
/// The prototype's mistake began exactly there — panicking about "is this air or solid, based
/// on the heightmap". In the band model there is NO height-triggered branching: everything
/// above the surface is simply the top AIR run. Air is not a special height rule; it is just
/// the topmost band, a registry row like any other (<see cref="MaterialRegistry.Air"/>).
///
/// This type deliberately exposes NO surface-height, ground-level, or is-solid-at accessor.
/// Every one of those is the old mistake wearing a helper's clothes. If you find yourself
/// wanting one, you are about to reintroduce the bug this model was built to make impossible.
///
/// ═══ ⚠ WHAT IS NOT HERE, AND MUST NOT BE ═══
///
/// • WATER IS NOT A BAND. Water is a separate OVERLAY over the columns — the levels-not-cells
/// model (sea, lakes, rivers). It is never stored as a run. Nothing about water belongs in
/// this type. → `Design - Water - Runtime Water.md`, `Design - Water - Sea Level Model.md`.
///
/// • NO PER-RUN PROPERTIES. Runs store identity; the registry answers everything else.
///
/// • NO ALGORITHMS. Phase 0 is the shape only. Run splitting on dig, merging, stability
/// storage, chunking for streaming, and the mesher are all later phases. The minimal API
/// below — construct, append, iterate — is the whole surface on purpose.
///
/// ⚠ A LIVE INVARIANT constrains how the shaping/hydrology/water passes use this model (D-046):
/// classify-height and render-height must stay consistent. Its home is
/// `Design - Pipeline - Generation Order.md` § LIVE INVARIANT — read it there before writing
/// anything that reads or writes columns from both height views.
/// </summary>
public sealed class Column
{
private readonly List<MaterialRun> _runs;
public Column(int expectedRuns = 8)
{
_runs = new List<MaterialRun>(expectedRuns);
}
/// <summary>The runs, bottom to top. Read-only: mutation goes through <see cref="Append"/>.</summary>
public IReadOnlyList<MaterialRun> Runs => _runs;
public int RunCount => _runs.Count;
/// <summary>
/// Append a run ON TOP of the stack. Runs are built bottom-up, in order.
///
/// No merging of like-material neighbours, no splitting, no insertion mid-stack. Those are
/// later-phase operations with their own correctness questions; this phase builds the shape.
/// </summary>
public void Append(MaterialRun run)
{
_runs.Add(run);
}
/// <summary>Append a run by its parts. Convenience over <see cref="Append(MaterialRun)"/>.</summary>
public void Append(MaterialKey material, int thickness, RunOrigin origin = RunOrigin.Natural)
=> Append(new MaterialRun(material, thickness, origin));
/// <summary>
/// Total stacked thickness of every run, including air.
///
/// ⚠ THIS IS NOT A SURFACE HEIGHT AND MUST NOT BE USED AS ONE. It is the top of the column
/// including its air run. There is deliberately no "height of the solid part" here — see
/// the behavioural rule in this type's summary.
/// </summary>
public int TotalThickness
{
get
{
int t = 0;
for (int i = 0; i < _runs.Count; i++) t += _runs[i].Thickness;
return t;
}
}
public override string ToString() => $"Column({_runs.Count} runs, {TotalThickness} thick)";
}
}

View file

@ -0,0 +1 @@
uid://bui0ydptx6ndk

View file

@ -0,0 +1,54 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// The world as a 2D GRID OF COLUMNS — one per horizontal cell.
/// → `Design - Data - Column Model.md`, D-053.
///
/// The grid is square and its side is a GENERATION PARAMETER, never a constant. It is carried
/// as a <see cref="GenerationScale"/> so that every derived distance in the project is forced
/// through the one scaling rule. → `Design - Tooling - Scaling Discipline.md`.
///
/// ⚠ SHAPE ONLY, PHASE 0. Chunking for streaming, region files, serialization and the mesher
/// interface are all later phases. A flat array is the honest Phase 0 representation: it says
/// "grid of columns" and commits to nothing about how it will be paged.
///
/// ⚠ NOTHING ABOUT WATER LIVES HERE. Water is an overlay over the grid, not a layer in it.
/// </summary>
public sealed class ColumnGrid
{
private readonly Column[] _columns;
/// <summary>The scale this world was generated at. Side length, scale factor, derived distances.</summary>
public GenerationScale Scale { get; }
/// <summary>Grid side in columns. 1 column = 1 metre (D-002, 1:1 scale).</summary>
public int Side => Scale.MapSize;
public ColumnGrid(GenerationScale scale)
{
Scale = scale;
_columns = new Column[(long)scale.MapSize * scale.MapSize <= int.MaxValue
? scale.MapSize * scale.MapSize
: throw new ArgumentOutOfRangeException(nameof(scale), scale.MapSize,
"MapSize squared exceeds a single flat array. Chunked storage is a later phase — see the class comment.")];
}
/// <summary>The column at (x, y). Null until one is placed — Phase 0 allocates no columns.</summary>
public Column this[int x, int y]
{
get => _columns[Index(x, y)];
set => _columns[Index(x, y)] = value;
}
private int Index(int x, int y)
{
if ((uint)x >= (uint)Side) throw new ArgumentOutOfRangeException(nameof(x), x, $"x outside [0,{Side}).");
if ((uint)y >= (uint)Side) throw new ArgumentOutOfRangeException(nameof(y), y, $"y outside [0,{Side}).");
return y * Side + x;
}
public override string ToString() => $"ColumnGrid({Side}x{Side})";
}
}

View file

@ -0,0 +1 @@
uid://dgcj88njh3wvl

View file

@ -0,0 +1,85 @@
using System;
using System.IO;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⚠⚠ THE PERMANENT FILE-SAFETY RULES, ENFORCED IN CODE.
/// → `Design - Tooling - Iteration and Batching.md` § FILE-SAFETY rules — permanent.
///
/// ═══ THE RULES ═══
///
/// 1. NO DELETION in the runtime root or anywhere under batches/.
/// 2. INTERMEDIATES PERSIST in a scratch/ subfolder that is NEVER cleaned.
/// 3. THE DEVELOPER'S PLACED FILES ARE NEVER TOUCHED.
/// 4. ANY DELETION IS NAMED EXPLICITLY IN THE RUN REPORT.
///
/// ═══ WHY THIS IS CODE AND NOT A README LINE ═══
///
/// These were born from a real incident: an executor admitted it had been deleting the
/// developer's staged test blueprint. It was owned and fixed IN CODE. A rule that depends on an
/// executor remembering it will eventually meet an executor who does not — so the rule is a
/// throw, not a sentence.
///
/// ═══ HOW TO USE IT ═══
///
/// There is no Delete() convenience here on purpose. Any code that removes a file calls
/// <see cref="AssertDeletable"/> first, with a reason, and the reason goes in the run report.
/// Deleting without asking is the thing being prevented; making it one line easier to ask is
/// the whole mechanism.
///
/// Nothing deletes anything this phase.
/// </summary>
public static class FileSafety
{
/// <summary>
/// Refuse the deletion unless it is provably outside every protected root, and record why.
/// Throws <see cref="UnauthorizedAccessException"/> when the path is protected.
/// </summary>
/// <param name="path">The file or directory a caller intends to remove.</param>
/// <param name="reason">
/// Why. Required, non-blank — it is what the run report has to print. "cleanup" is not a
/// reason; "regenerable legacy blueprint, 22 GB, nothing reads it" is.
/// </param>
public static void AssertDeletable(string path, string reason)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("A path is required.", nameof(path));
if (string.IsNullOrWhiteSpace(reason))
throw new ArgumentException(
"A deletion needs a stated reason — it must be named explicitly in the run report. " +
"If you cannot name one, that is the answer.", nameof(reason));
string full = Path.GetFullPath(path);
foreach (string protectedRoot in ProtectedRoots())
{
if (IsWithin(full, protectedRoot))
throw new UnauthorizedAccessException(
$"REFUSED: '{full}' is inside a protected root ('{protectedRoot}'). " +
"No deletion is permitted in the runtime root or under batches/ — intermediates persist. " +
$"(Stated reason was: {reason})");
}
}
/// <summary>
/// The roots nothing may delete from. The batches root and the resolved output/user roots —
/// i.e. everything a generation run and a human's browsing of it depend on.
/// </summary>
public static string[] ProtectedRoots() => new[]
{
Path.GetFullPath(ToolingPaths.BatchesRoot),
Path.GetFullPath(ToolingPaths.OutputDir),
Path.GetFullPath(ToolingPaths.BlueprintPath),
Path.GetFullPath(ToolingPaths.UserDataDir),
};
private static bool IsWithin(string candidate, string root)
{
string c = candidate.TrimEnd(Path.DirectorySeparatorChar);
string r = root.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(c, r, StringComparison.Ordinal)
|| c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.Ordinal);
}
}
}

View file

@ -0,0 +1 @@
uid://jdpjwyumfo76

View file

@ -0,0 +1,119 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐ THE SCALING DISCIPLINE, IN CODE. → `Design - Tooling - Scaling Discipline.md`, D-002.
///
/// ═══ THE RULE ═══
///
/// NOTHING IN THE GENERATOR USES A RAW PIXEL NUMBER.
/// Every distance, radius, threshold and noise frequency is a FRACTION OF MapSize,
/// or derived from ScaleFactor.
///
/// ═══ WHY IT IS A RULE AND NOT A PREFERENCE ═══
///
/// These failures are SILENT. A hardcoded pixel count does not throw — the generator still
/// runs and still produces a map, and the map is just subtly wrong in ways that are hard to
/// attribute. The prototype learned this the expensive way: moving to 1:1 scale made the world
/// four times wider in pixels while the noise settings stayed put, and the terrain became
/// "TV static" — the same features packed into a quarter of the space. The same transition
/// broke a long tail of checks like "look 10 pixels away to see if there's water", which
/// quietly started measuring a quarter of the distance they used to.
///
/// In the developer's own words: "These kind of numbers I'm getting tired of changing because
/// of map size. We need to make sure these scale!"
///
/// ═══ ⚠ ONE BASELINE, DELIBERATELY ═══
///
/// The prototype ran TWO reference scales side by side: /1024 for noise and map drawing,
/// /4096 for town counts and road repulsion. Neither was wrong, but a reader — or a resize —
/// had to know which applied where, and the design doc's standing instruction is to "pick
/// deliberately and say which". This repo picks: ONE baseline, 1024, for everything. If a
/// second is ever genuinely needed it gets its own named type and its own reason, not a
/// bare divisor at a call site.
///
/// ═══ ⚠⚠ THE TIGHTENING OVER THE REFERENCE — NORMALIZED ADDITIVE OFFSETS ═══
///
/// The reference decorrelated noise layers with offsets in RAW PIXELS — GetNoise2D(x + 1000, …),
/// GetNoise2D(x * 1.5f + 5000, …). Because frequency already carries a 1/MapSize normalization,
/// a pixel-space offset does NOT hold still across map sizes: 1000 px is 1.0 normalized units
/// at 4K but 0.5 at 8K, so a resize silently samples a DIFFERENT SLICE of the noise field. The
/// feature scale is preserved; the REALIZATION is not. The same seed at two map sizes gives two
/// different worlds, for no reason anyone wrote down. (Found by the chat1/00 reference
/// inventory, §4.2 — where it also corrected the vault's standing diagnosis that the detail
/// noises were unscaled. They are scaled; it is the OFFSETS that are not.)
///
/// So in this repo: DECORRELATION OFFSETS ARE DECLARED IN MAP WIDTHS, via
/// <see cref="OffsetInMapWidths"/>. Never write a bare pixel constant into a noise coordinate.
///
/// No noise is built this phase. The convention is established before the generator exists so
/// that nothing is ever added unscaled — which is the only time this rule is cheap to hold.
/// </summary>
public readonly struct GenerationScale
{
/// <summary>
/// The reference map width the scaling baseline is anchored to. The world's noise looked
/// right at 1024 columns; every larger map stretches its features back out to match.
/// </summary>
public const int BaselineMapSize = 1024;
/// <summary>
/// The map's side in columns. 1 column = 1 metre (D-002, 1:1 scale).
///
/// ⚠ A GENERATION PARAMETER. Never a constant, never baked in. The rewrite's default target
/// is 10k x 10k, configurable 8k12k (D-056) — but that is a DEFAULT chosen by config, and
/// no code below this line may assume it.
/// </summary>
public readonly int MapSize;
public GenerationScale(int mapSize)
{
if (mapSize < BaselineMapSize)
throw new ArgumentOutOfRangeException(nameof(mapSize), mapSize,
$"MapSize must be at least the {BaselineMapSize} baseline.");
MapSize = mapSize;
}
/// <summary>
/// MapSize / 1024. Divide a baseline-tuned noise frequency by this to hold feature size
/// constant in METRES as the map grows. A mountain range is the same physical size at any
/// map profile — which is what makes the map-size setting safe to change at all.
/// </summary>
public float ScaleFactor => MapSize / (float)BaselineMapSize;
/// <summary>
/// A distance, from a fraction of the map. Use this instead of writing a pixel count.
/// <c>Fraction(0.75f)</c> is "three quarters of the way across", at every map size.
/// </summary>
public float Fraction(float fractionOfMap) => fractionOfMap * MapSize;
/// <summary>
/// The inverse: what fraction of the map a distance in columns represents. For turning a
/// measured pixel distance back into a scale-free constant before you commit it to code.
/// </summary>
public float AsFraction(float distanceInColumns) => distanceInColumns / MapSize;
/// <summary>
/// A noise frequency, from one tuned at the 1024 baseline.
/// <c>NoiseFrequency(0.004f)</c> reproduces the reference's base terrain frequency at any
/// map size. → the reference's <c>0.004f / scaleFactor</c>, chat1/00 §2.1.
/// </summary>
public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor;
/// <summary>
/// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS.
///
/// <c>OffsetInMapWidths(0.25f)</c> shifts the sample by a quarter of the map at EVERY map
/// size — so two noise layers stay exactly as decorrelated at 12K as they were at 8K, and
/// the same seed produces the same world shape at any size.
///
/// ⚠ This is the one place a decorrelation offset may come from. A bare pixel constant in a
/// noise coordinate is the bug described in this type's summary; there is no correct value
/// for one.
/// </summary>
public float OffsetInMapWidths(float mapWidths) => mapWidths * MapSize;
public override string ToString() => $"GenerationScale(MapSize={MapSize}, ScaleFactor={ScaleFactor:F3})";
}
}

View file

@ -0,0 +1 @@
uid://bnpvgf20ybmhd

View file

@ -0,0 +1,47 @@
using System;
namespace IslaApocalypse.Core
{
/// <summary>
/// A material's IDENTITY. → `Design - Data - Material Schema.md`, D-054.
///
/// ⚠⚠ IDENTITY IS A REGISTRY KEY, NEVER A POSITIONAL ORDINAL.
///
/// This is deliberately a stable string key and not an enum. The prototype's blueprint format
/// keyed on enum ordinals, which made the enum's DECLARATION ORDER a wire format: inserting a
/// value in the middle silently reinterpreted every world already generated
/// (→ `Design - Tooling - Offline Generation.md`: "anything that changes its format or its enum
/// ordinals breaks every world already generated"). The registry is also the mod surface
/// (→ D-011, D-043) — a mod adds rows, and rows cannot be given ordinals by a third party.
///
/// So: adding a material is one new row, appended anywhere, in any order, by anyone, forever.
///
/// No serialization is built this phase. This type exists so that when it is, the identity it
/// writes is already the key rather than a position.
/// </summary>
public readonly struct MaterialKey : IEquatable<MaterialKey>
{
/// <summary>The stable identity. Lowercase, dot-namespaced: "core.limestone", "mod.basalt".</summary>
public readonly string Value;
public MaterialKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("A material key may not be null or blank.", nameof(value));
Value = value;
}
/// <summary>The unset key. Distinguishable from any real row; never registered.</summary>
public static readonly MaterialKey None = default;
public bool IsNone => Value == null;
public bool Equals(MaterialKey other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object obj) => obj is MaterialKey k && Equals(k);
public override int GetHashCode() => Value == null ? 0 : StringComparer.Ordinal.GetHashCode(Value);
public override string ToString() => Value ?? "<none>";
public static bool operator ==(MaterialKey a, MaterialKey b) => a.Equals(b);
public static bool operator !=(MaterialKey a, MaterialKey b) => !a.Equals(b);
}
}

View file

@ -0,0 +1 @@
uid://dw6ee00eqjwp1

View file

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>
/// The two material schemas, as APPEND-ONLY registries. → `Design - Data - Material Schema.md`, D-054.
///
/// ⭐ NO MATERIAL LOGIC IS HARDCODED ANYWHERE — everything reads from here.
/// This is the specific discipline that makes deferring the full material list SAFE, unlike
/// biomes, which were hardcoded and coupled. Understand the shape now; fill the rows forever.
///
/// ⚠ THE SEED ROWS BELOW ARE NOT THE MATERIAL LIST. They are the few that are certain
/// (limestone is in — karst substrate). Do NOT enumerate the full list now; design materials
/// incrementally as building proceeds. Adding one is one new row, appended, nothing else.
///
/// ⚠ REGISTRATION ORDER IS NOT MEANINGFUL. It sets <c>RuntimeIndex</c>, which is never
/// serialized. Reordering these lines must not change a single persisted byte, now or ever.
///
/// Phase 0: rows are seeded in code. Loading them from a data file is a later phase — the shape
/// here (identity + properties, no logic) is what makes that a loader change and nothing more.
/// </summary>
public static class MaterialRegistry
{
// ---- terrain --------------------------------------------------------
private static readonly Dictionary<MaterialKey, TerrainMaterial> _terrain = new();
private static readonly List<TerrainMaterial> _terrainByIndex = new();
// ---- building -------------------------------------------------------
private static readonly Dictionary<MaterialKey, BuildingMaterial> _building = new();
private static readonly List<BuildingMaterial> _buildingByIndex = new();
/// <summary>
/// AIR. The one key the column model leans on structurally: everything above the surface is
/// the top AIR run, and a cave is an air run mid-column. It is a material row like any
/// other — there is no "is air" branch anywhere. → `Design - Data - Column Model.md`.
/// </summary>
public static readonly MaterialKey Air = new("core.air");
static MaterialRegistry()
{
// ===== TERRAIN — seed rows, NOT final =====
// key display hardness yield per-voxel support
RegisterTerrain("core.air", "Air", 0.0f, MaterialKey.None, 0, 0.0f);
RegisterTerrain("core.sand", "Sand", 0.2f, "core.sand", 1, 0.1f);
RegisterTerrain("core.dirt", "Dirt", 0.3f, "core.dirt", 1, 0.2f);
RegisterTerrain("core.clay", "Clay", 0.4f, "core.clay", 1, 0.3f);
RegisterTerrain("core.limestone", "Limestone", 0.7f, "core.limestone", 1, 0.7f); // karst substrate
RegisterTerrain("core.stone", "Stone", 0.9f, "core.stone", 1, 0.9f);
RegisterTerrain("core.bedrock", "Bedrock", 1.0f, MaterialKey.None, 0, 1.0f); // unmineable floor
// ===== BUILDING — seed rows, NOT final =====
// key display reach decay weight durability
RegisterBuilding("build.thatch", "Thatch/Palm", 1.5f, 0.60f, 0.2f, 0.2f);
RegisterBuilding("build.wood", "Wood Plank", 4.0f, 0.30f, 0.5f, 0.5f);
RegisterBuilding("build.stone", "Worked Stone", 6.0f, 0.20f, 1.0f, 0.8f);
RegisterBuilding("build.concrete", "Concrete", 9.0f, 0.12f, 1.1f, 0.9f);
RegisterBuilding("build.steel", "Steel", 14.0f, 0.06f, 0.9f, 1.0f);
}
// ---- registration (append-only; no removal, by design) --------------
private static void RegisterTerrain(string key, string name, float hardness, MaterialKey yieldKey, int yieldPer, float support)
=> RegisterTerrain(new TerrainMaterial(new MaterialKey(key), name, hardness, yieldKey, yieldPer, support));
private static void RegisterTerrain(string key, string name, float hardness, string yieldKey, int yieldPer, float support)
=> RegisterTerrain(key, name, hardness, new MaterialKey(yieldKey), yieldPer, support);
private static void RegisterBuilding(string key, string name, float reach, float decay, float weight, float durability)
=> RegisterBuilding(new BuildingMaterial(new MaterialKey(key), name, reach, decay, weight, durability));
/// <summary>Append a terrain material. Public so mods and data loaders add rows the same way seeds do.</summary>
public static void RegisterTerrain(TerrainMaterial material)
{
if (material == null) throw new ArgumentNullException(nameof(material));
if (_terrain.ContainsKey(material.Key))
throw new InvalidOperationException($"Terrain material '{material.Key}' is already registered. The registry is append-only; keys are unique.");
material.RuntimeIndex = _terrainByIndex.Count;
_terrainByIndex.Add(material);
_terrain.Add(material.Key, material);
}
/// <summary>Append a building material.</summary>
public static void RegisterBuilding(BuildingMaterial material)
{
if (material == null) throw new ArgumentNullException(nameof(material));
if (_building.ContainsKey(material.Key))
throw new InvalidOperationException($"Building material '{material.Key}' is already registered. The registry is append-only; keys are unique.");
material.RuntimeIndex = _buildingByIndex.Count;
_buildingByIndex.Add(material);
_building.Add(material.Key, material);
}
// ---- lookup ---------------------------------------------------------
public static TerrainMaterial Terrain(MaterialKey key)
=> _terrain.TryGetValue(key, out var m)
? m
: throw new KeyNotFoundException($"No terrain material registered for key '{key}'.");
public static BuildingMaterial Building(MaterialKey key)
=> _building.TryGetValue(key, out var m)
? m
: throw new KeyNotFoundException($"No building material registered for key '{key}'.");
public static bool TryTerrain(MaterialKey key, out TerrainMaterial material) => _terrain.TryGetValue(key, out material);
public static bool TryBuilding(MaterialKey key, out BuildingMaterial material) => _building.TryGetValue(key, out material);
public static bool IsRegisteredTerrain(MaterialKey key) => _terrain.ContainsKey(key);
public static IReadOnlyList<TerrainMaterial> AllTerrain => _terrainByIndex;
public static IReadOnlyList<BuildingMaterial> AllBuilding => _buildingByIndex;
public static int TerrainCount => _terrainByIndex.Count;
public static int BuildingCount => _buildingByIndex.Count;
}
}

View file

@ -0,0 +1 @@
uid://dtkpl88w2n3q5

View file

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐ THE TRANSFORMATION / RECIPE SEAM — the bridge between the two material schemas.
/// → `Design - Data - Material Schema.md` §3, D-054.
///
/// Terrain materials are RESOURCES that refine and combine into BUILDING materials —
/// e.g. sand + clay + rock → concrete. That relationship is the survival loop, and keeping the
/// two schemas distinct with a transformation between them is what stops it collapsing into
/// "the same material tagged differently".
///
/// ⚠⚠ THIS REGISTRY IS DELIBERATELY EMPTY, AND THAT IS THE POINT.
///
/// The recipes themselves are DEFERRED. Designing the resource tree now would be a
/// biomes-premature-style over-commitment — the exact mistake the rewrite exists to undo.
/// But the SEAM EXISTS NOW: the architecture has the hole, so filling it later is new ROWS and
/// never a rewrite.
///
/// Do not add recipes here without a design decision that says the tree is ready.
/// </summary>
public static class RecipeRegistry
{
private static readonly List<Recipe> _recipes = new();
// ===== NO SEED ROWS. See the class comment — the emptiness is the design. =====
/// <summary>Append a recipe. Append-only, same as the material registries.</summary>
public static void Register(Recipe recipe)
{
if (recipe == null) throw new ArgumentNullException(nameof(recipe));
_recipes.Add(recipe);
}
public static IReadOnlyList<Recipe> All => _recipes;
public static int Count => _recipes.Count;
}
/// <summary>
/// One transformation: some quantity of terrain-derived inputs produces a building material.
///
/// ⚠ SHAPE ONLY. The exact recipe FORMAT is an open note in the design doc — inputs may grow
/// tools, stations, time or byproducts. This is the minimum that makes the seam real enough to
/// compile against, not a committed format.
/// </summary>
public sealed class Recipe
{
/// <summary>Input material keys and their amounts. Terrain-derived resources.</summary>
public IReadOnlyList<RecipeInput> Inputs { get; }
/// <summary>The building material produced.</summary>
public MaterialKey Output { get; }
/// <summary>How much of <see cref="Output"/> one application yields.</summary>
public int OutputAmount { get; }
public Recipe(IReadOnlyList<RecipeInput> inputs, MaterialKey output, int outputAmount)
{
Inputs = inputs ?? throw new ArgumentNullException(nameof(inputs));
Output = output;
OutputAmount = outputAmount;
}
}
/// <summary>One input line of a <see cref="Recipe"/>.</summary>
public readonly struct RecipeInput
{
public readonly MaterialKey Material;
public readonly int Amount;
public RecipeInput(MaterialKey material, int amount)
{
Material = material;
Amount = amount;
}
}
}

View file

@ -0,0 +1 @@
uid://ghic8rgrpnre

View file

@ -0,0 +1,76 @@
namespace IslaApocalypse.Core
{
/// <summary>
/// A row in the TERRAIN material schema — what the natural world is made of.
/// → `Design - Data - Material Schema.md` §1, D-054.
///
/// PROPERTIES-FROM-TYPE: a column run stores only a <see cref="MaterialKey"/>. Every property
/// lives here and is reached by registry lookup. Nothing is ever stored per-run.
/// → `Design - Data - Column Model.md`.
///
/// ⚠ THERE IS NO MESH-STYLE FIELD ON THIS ROW, AND THERE MUST NEVER BE ONE.
/// Mesh style is per-ORIGIN, not per-material: natural terrain meshes smooth, player-placed
/// blocks mesh as cubes, and the same material can be both. The origin flag lives on the run
/// (<see cref="RunOrigin"/>), which is where the mesher reads it.
/// → D-054 open notes ("material does not override mesh style"), `Design - Rendering - Mesher.md`.
///
/// APPEND-ONLY. Adding a material is one new row in <see cref="MaterialRegistry"/> — never a
/// code change anywhere else. No material logic is hardcoded anywhere.
/// </summary>
public sealed class TerrainMaterial
{
/// <summary>Identity. A registry key, not an ordinal. → <see cref="MaterialKey"/>.</summary>
public MaterialKey Key { get; }
/// <summary>Human-facing name. Presentation only — never an identity, never a lookup key.</summary>
public string DisplayName { get; }
/// <summary>Mining difficulty. Units deliberately unfixed this phase; relative for now.</summary>
public float Hardness { get; }
/// <summary>
/// What a mined voxel gives. SEAM: the resource/item schema is deferred, so this currently
/// names a material key. When resources become their own schema this becomes a resource key
/// — one field's type, not a redesign.
/// </summary>
public MaterialKey YieldKey { get; }
/// <summary>How much <see cref="YieldKey"/> one mined voxel gives.</summary>
public int YieldPerVoxel { get; }
/// <summary>
/// How much structural support this material carries as NATURAL terrain.
/// Consumed by `Design - Systems - Structural Integrity.md` (stability scalar, D-055).
/// Natural terrain is a LAZY anchor there — this is the value it anchors with.
/// </summary>
public float NaturalSupportStrength { get; }
/// <summary>
/// Runtime-only dense index, assigned by the registry in registration order.
///
/// ⚠⚠ NEVER SERIALIZE THIS. NEVER SEND IT OVER A WIRE. NEVER STORE IT IN A BLUEPRINT.
/// It exists solely so hot loops can index dense arrays instead of hashing strings. It
/// changes whenever registration order changes — which a mod adding a row will do. The
/// identity that persists is <see cref="Key"/>, always.
/// </summary>
public int RuntimeIndex { get; internal set; } = -1;
public TerrainMaterial(
MaterialKey key,
string displayName,
float hardness,
MaterialKey yieldKey,
int yieldPerVoxel,
float naturalSupportStrength)
{
Key = key;
DisplayName = displayName;
Hardness = hardness;
YieldKey = yieldKey;
YieldPerVoxel = yieldPerVoxel;
NaturalSupportStrength = naturalSupportStrength;
}
public override string ToString() => $"TerrainMaterial({Key})";
}
}

View file

@ -0,0 +1 @@
uid://b13bbprjpu3jl

View file

@ -0,0 +1,114 @@
using System;
using System.IO;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐ EVERY PATH THE TOOLING READS OR WRITES, RESOLVED IN ONE PLACE, OVERRIDABLE BY ENVIRONMENT.
/// → `Design - Tooling - Iteration and Batching.md` § "Tooling must be safe BY CODE, not by care".
///
/// ═══ WHY THIS TYPE EXISTS ═══
///
/// So a batch run CANNOT read over or write to the developer's live config, staged blueprints,
/// or output directory. Not "should not" — cannot, because there is no other way to obtain a
/// path, and each one has an environment override that a batch script sets before it starts.
///
/// > ⭐ THE POINT IS THAT IT IS ENFORCED BY CODE, NOT BY CARE.
/// > A rule that depends on an executor remembering it will eventually meet an executor who
/// > does not.
///
/// ⚠ This is not hypothetical. These rules were born from a real incident: an executor admitted
/// it had been deleting the developer's staged test blueprint. It was owned and fixed IN CODE.
/// That is why these are rules and not guidance. → <see cref="FileSafety"/>.
///
/// ═══ THE OVERRIDES ═══
///
/// ISLA_CONFIG_PATH the generation config file default: user://config.json
/// ISLA_BLUEPRINT_PATH the blueprint read/written default: user://blueprints
/// ISLA_OUTPUT_DIR generation output (maps, batches) default: user://output
///
/// ⚠ user:// RESOLUTION. Defaults sit under the project's own user data directory, which this
/// project pins away from the old prototype's — see project.godot's user:// isolation block.
/// Core is engine-free by design, so the caller supplies the resolved user directory (from
/// Godot's OS.GetUserDataDir()) via <see cref="Configure"/>. Until it does, the defaults resolve
/// under the process working directory, which is wrong for a real run and loud enough to notice.
///
/// No generator exists this phase. The rails are laid before it so it inherits them.
/// </summary>
public static class ToolingPaths
{
public const string ConfigPathVar = "ISLA_CONFIG_PATH";
public const string BlueprintPathVar = "ISLA_BLUEPRINT_PATH";
public const string OutputDirVar = "ISLA_OUTPUT_DIR";
private static string _userDataDir;
/// <summary>
/// Hand Core the engine-resolved user data directory. Called once at startup by whichever
/// layer owns the engine (Tools or Server); Core never asks Godot for it itself.
/// </summary>
public static void Configure(string userDataDir)
{
if (string.IsNullOrWhiteSpace(userDataDir))
throw new ArgumentException("A user data directory is required.", nameof(userDataDir));
_userDataDir = userDataDir;
}
/// <summary>The resolved user data directory, or the working directory if none was configured.</summary>
public static string UserDataDir => _userDataDir ?? Directory.GetCurrentDirectory();
/// <summary>Whether <see cref="Configure"/> has been called. Tooling should assert this before a run.</summary>
public static bool IsConfigured => _userDataDir != null;
/// <summary>The generation config file. Override: ISLA_CONFIG_PATH.</summary>
public static string ConfigPath =>
Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json");
/// <summary>Where blueprints are read from and written to. Override: ISLA_BLUEPRINT_PATH.</summary>
public static string BlueprintPath =>
Override(BlueprintPathVar) ?? Path.Combine(UserDataDir, "blueprints");
/// <summary>Where a generation run writes its output. Override: ISLA_OUTPUT_DIR.</summary>
public static string OutputDir =>
Override(OutputDirVar) ?? Path.Combine(UserDataDir, "output");
/// <summary>
/// The batches root. → `Design - Tooling - Iteration and Batching.md`:
/// <c>batches/NN_&lt;name&gt;/&lt;seed&gt;_&lt;variant&gt;/</c>, each batch carrying an
/// INDEX.md and a persistent scratch/. A/B comparisons are browsed by a human, and a flat
/// directory of same-named PNGs is not browsable.
///
/// ⚠ PROTECTED FROM DELETION. → <see cref="FileSafety"/>.
/// </summary>
public static string BatchesRoot => Path.Combine(OutputDir, "batches");
/// <summary>
/// The scratch subfolder of a batch. INTERMEDIATES PERSIST HERE AND ARE NEVER CLEANED —
/// the whole point is that a run's intermediates survive it, so a surprising result can be
/// investigated instead of regenerated.
/// </summary>
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
/// <summary>
/// A batch directory: <c>batches/NN_&lt;name&gt;/&lt;seed&gt;_&lt;variant&gt;/</c>.
/// </summary>
public static string BatchDir(int batchNumber, string batchName, long seed, string variant)
=> Path.Combine(BatchesRoot, $"{batchNumber:D2}_{batchName}", $"{seed}_{variant}");
private static string Override(string variable)
{
string v = Environment.GetEnvironmentVariable(variable);
return string.IsNullOrWhiteSpace(v) ? null : v;
}
/// <summary>Every resolved path, for a run report's header. Print this; it is cheap and it has caught things.</summary>
public static string Describe() =>
$"user data : {UserDataDir}{(IsConfigured ? "" : " NOT CONFIGURED falling back to CWD")}\n" +
$"config : {ConfigPath}{Marker(ConfigPathVar)}\n" +
$"blueprints: {BlueprintPath}{Marker(BlueprintPathVar)}\n" +
$"output : {OutputDir}{Marker(OutputDirVar)}\n" +
$"batches : {BatchesRoot}";
private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : "";
}
}

View file

@ -0,0 +1 @@
uid://dbgrxcbahn163

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# Isla Apocalypse — v2
The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline,
loaded by everyone.
**Status: Phase 0 — foundation.** This repo is a *skeleton*. It builds, it resolves its own runtime
directory, and it carries the two data contracts and the tooling rails. **It generates nothing.**
---
## ⚠ Two repos. Never one.
| Repo | Role |
|---|---|
| **`~/celerNexus/islaApocalypse-v2/`** *(this)* | The rewrite. **Every line of new code is written here.** |
| **`~/celerNexus/islaApocalypse/`** | ⛔ **READ-ONLY.** The pre-rewrite prototype, archived at `ab78883`, tag `pre-rewrite-reference`. **Ported FROM; never written to.** |
| **`~/celerNexus/islaApocalypse-vault-v0.1/`** | The design vault — **the source of truth for WHAT and WHY.** The vault leads. |
**The old codebase is a spent quarry** (D-050): mined, never tracked, never synced, never canon'd.
Writing rewrite code into it would corrupt the very thing being ported from.
## ⚠ Runtime data isolation — do not weaken
This project pins its `user://` directory **away from the old prototype's**, which holds the
preserved reference seeds, blueprints and batches:
```
user:// → ~/.local/share/islaApocalypse-v2/ ← this project
~/.local/share/godot/app_userdata/islaApocalypse/ ← ⛔ the OLD prototype. Never touch.
```
Pinned two ways in `project.godot` (distinct project name **and** `use_custom_user_dir`). After any
change to that block, re-run the probe:
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn
```
## Layout
```
Core/ math + data only, ENGINE-FREE — depends on nothing above it
Server/ authoritative logic — may use Core (empty: Phase 0)
Client/ rendering — may use Core (empty: Phase 0)
Tools/ the offline generator — ⚠ may NOT use Client (Phase 1 fills it)
```
Each layer has a `README.md` stating its role and its boundary. Read the one for the layer you are
about to write in — they carry the reasons, not just the rules.
## The contracts this phase establishes
- **★★ The column model** (D-053) — the world as a 2D grid of columns, each a stack of
`(material, thickness)` runs. **Air is the top run; there is no air-vs-solid height branch.**
`Core/Scripts/Column.cs`
- **The material schemas** (D-054) — terrain and building, two schemas bridged by a transformation
seam that is **deliberately empty.** Identity is a registry key, never an ordinal. Mesh style is
per-origin, never a material property. → `Core/Scripts/MaterialRegistry.cs`
- **The scaling discipline** — nothing uses a raw pixel number.
`Core/Scripts/GenerationScale.cs`
- **Tooling safety** — every path env-overridable, deletion refused by code.
`Core/Scripts/ToolingPaths.cs`, `Core/Scripts/FileSafety.cs`
## Build
```bash
dotnet build
Godot_v4.7.2-stable_mono_linux.x86_64 --headless --import --path .
```
**Godot 4.7.2** (mono) · **.NET 8** (`net8.0`) · `Godot.NET.Sdk/4.7.2`
> The reference repo is Godot 4.7.1. This repo deliberately targets **4.7.2** — clean rewrite,
> current tooling. 4.7.1 is the *port source*, not a constraint on new code.
## What this phase is NOT
No generation, no mesher, no stratigraphy, no biomes, no water, no algorithms of any kind. The
pipeline is **2D-maps-first** (D-056): stages 15 are flat maps, gotten completely right before a
single 3D vertex exists. Building the mesher before the data is right is the specific trap this
rewrite exists to undo.

20
Server/README.md Normal file
View file

@ -0,0 +1,20 @@
# Server — authoritative logic
**Holds:** the authoritative simulation. What the world *does*, decided in one place.
**Boundary:** may use `Core/`. Must not depend on `Client/`.
## Empty this phase — deliberately
Phase 0 stands up the skeleton; nothing simulates yet. The folder exists so the first authoritative
system has an obvious home and does not get written into `Core/` or a scene.
## When it fills
The world is generated **once, offline**, and the runtime **loads** it — the server never
regenerates terrain on start (→ D-028, `Design - Tooling - Offline Generation.md`). Every
participant reads the *same* immutable blueprint rather than re-deriving it, because two machines
that independently compute terrain will eventually disagree about where a mountain is, and in a
voxel game that disagreement is a player falling through the world.
So: **Server loads blueprints. Server does not generate.** Generation is `Tools/`.

1
Server/Scripts/.gitkeep Normal file
View file

@ -0,0 +1 @@
# Placeholder so the empty layer folder is tracked. Delete when the first script lands.

124
Tools/README.md Normal file
View file

@ -0,0 +1,124 @@
# Tools — the offline generator
**Holds:** the world generator and its diagnostics. Everything that produces a blueprint, and
nothing that consumes one at play time.
**Boundary — the tools wall:**
> ### ⚠⚠ TOOLS MAY NOT REFERENCE `Client/`. THE DEPENDENCY RUNS ONE WAY, OR THE WALL IS NOT A WALL.
>
> No UI, no player controllers, no shaders, no rendering code. `Tools/` may use `Core/`.
>
> **Two reasons, both from `Design - Tooling - Offline Generation.md`:** a lighter shipped client,
> and — less obviously — **not handing players the world-generation logic.** Shipping the generator
> lets it be reverse-engineered into an unfair map preview, with the whole island's layout, every
> settlement and every route known before anyone sets foot on the beach.
>
> **Tools emit data; they never write to a live save.** A generator that could touch live server
> state is a corruption risk for no benefit.
## Empty this phase — Phase 1 fills it
The only thing here now is `Scenes/UserDirProbe.tscn` + `Scripts/UserDirProbe.cs`, a print-and-quit
diagnostic that confirms this project's `user://` directory is its own and not the old prototype's.
It generates nothing.
---
## ⭐ The conventions Phase 1's generator inherits
These were **earned, not assumed** — each one came out of the prototype's terrain arc.
`Design - Tooling - Iteration and Batching.md`.
### 1. `xvfb-run` for unattended generation — NOT `--headless`
> ### ⚠ `--headless` HANGS on a real generation run.
>
> The snapshot capture **awaits render frames, and there is no frame loop without a display.**
> **Measured, not assumed.** A run left on `--headless` does not fail loudly; it sits there.
```bash
xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 --path ~/celerNexus/islaApocalypse-v2 <scene>
```
A windowed run on the desktop also invites being closed mid-run by whoever is using the machine.
**The one exception is a job that awaits no frames** — like `UserDirProbe`, which is why that one
documents `--headless` explicitly:
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn
```
**If a job awaits a frame, it needs `xvfb-run`. When unsure, use `xvfb-run`** — it is correct in
both cases and costs nothing.
### 2. Environment overrides — safe BY CODE, not by care
Every path a tool reads or writes resolves through `Core/Scripts/ToolingPaths.cs`, and each has an
environment override, so a batch **cannot** read over or write to the developer's live files:
| Variable | Overrides | Default |
|---|---|---|
| `ISLA_CONFIG_PATH` | the generation config file | `user://config.json` |
| `ISLA_BLUEPRINT_PATH` | blueprints read/written | `user://blueprints` |
| `ISLA_OUTPUT_DIR` | generation output (batches live under it) | `user://output` |
> ⭐ **The point is that it is enforced by CODE, not by care.** A rule that depends on an executor
> remembering it will eventually meet an executor who does not.
There is no second way to obtain these paths. Do not add one.
### 3. ⚠ File safety — permanent rules
Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises.
1. **No deletion in the runtime root or anywhere under `batches/`.**
2. **Intermediates persist** in a `scratch/` subfolder that is **never cleaned.**
3. **The developer's placed files are never touched.**
4. **Any deletion is named explicitly in the run report.**
> ⚠ **These came from a real incident:** an executor admitted it had been deleting the developer's
> staged test blueprint. It was owned and fixed **in code.** That is why these are rules and not
> guidance.
### 4. Batch layout
```
batches/NN_<name>/<seed>_<variant>/
batches/NN_<name>/INDEX.md
batches/NN_<name>/scratch/ ← persistent; never cleaned
```
**A/B comparisons are browsed by a human, and a flat directory of same-named PNGs is not
browsable.** The `INDEX.md` is what makes a batch readable a week later.
**Batches are OUTPUT.** They live under `ISLA_OUTPUT_DIR` (i.e. under `user://`), **not in this
repo.** The `batches/` folder here carries the convention, not the data. No batches are run this
phase.
### 5. Iteration levers, for when the generator exists
- **Skip the road pass for all terrain and water iteration.** In the prototype this cut a run from
~26 min to ~80 s — a **19× cut**, and the difference between iterating on terrain and not
iterating on terrain. Blueprints from such a run carry present-but-empty road sections, which is
**legitimate, not corrupt.**
- **⭐ Build the oracle before the taste-iteration, not after.** Because the classify path was
pinned to *uncurved* height, every shaping iteration had a hard automatic correctness check
(biome and water maps md5-identical). Five rounds of taste-iteration were safe **because
correctness was not being judged by eye.** *Where a future phase has a subjective gate, ask first
what the automatic invariant is.*
- **Config-gate every shaping change**, with the legacy behaviour surviving on the other side. It
keeps changes comparable as an A/B pair, and it means a rejected change costs a flipped default
rather than a reverted commit.
- **The lever is variants per batch, not speed per pass.** Two Phase B tasks ran ~2 hours each,
which looked like the pipeline getting slower. It was not — a single pass stayed at ~2 minutes.
The cost was A/B/ablation multiplication. **When a batch is genuinely wide, say so up front.**
### 6. Scaling discipline
**Nothing uses a raw pixel number.** Every distance, radius, threshold and noise frequency comes
from `Core/Scripts/GenerationScale.cs`. Read its header before adding any constant — including the
tightening on **normalized additive noise offsets**, which is a forward-guard for the Phase 1 noise
port. → `Design - Tooling - Scaling Discipline.md`.

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla0"]
[ext_resource type="Script" path="res://Tools/Scripts/UserDirProbe.cs" id="1_probe"]
[node name="UserDirProbe" type="Node"]
script = ExtResource("1_probe")

View file

@ -0,0 +1,68 @@
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// A print-and-quit diagnostic: resolves and reports this project's user:// directory, then
/// exits. It generates nothing and writes nothing.
///
/// ═══ WHY IT EXISTS ═══
///
/// The old prototype is named "islaApocalypse" and owns
/// ~/.local/share/godot/app_userdata/islaApocalypse/, which holds the PRESERVED reference
/// seeds, blueprints and batches. A v2 project sharing that name would read and clobber them
/// at runtime. project.godot pins the isolation two ways (distinct name + custom user dir);
/// this probe CONFIRMS the pin actually resolves, rather than trusting that it does.
///
/// Run it after any change to project.godot's [application] block:
///
/// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn
///
/// --headless is fine HERE — it hangs only on the snapshot capture's render-frame awaits, and
/// this awaits nothing. → Tools/README.md, `Design - Tooling - Iteration and Batching.md`.
/// </summary>
public partial class UserDirProbe : Node
{
public override void _Ready()
{
string userDir = OS.GetUserDataDir();
// Hand Core the engine-resolved directory — Core is engine-free and never asks Godot itself.
ToolingPaths.Configure(userDir);
GD.Print("==================================================================");
GD.Print(" user:// PROBE — islaApocalypse-v2");
GD.Print("==================================================================");
GD.Print($"project name : {ProjectSettings.GetSetting("application/config/name")}");
GD.Print($"custom user dir : {ProjectSettings.GetSetting("application/config/use_custom_user_dir")} " +
$"(\"{ProjectSettings.GetSetting("application/config/custom_user_dir_name")}\")");
GD.Print($"user:// resolves : {userDir}");
GD.Print($"globalized : {ProjectSettings.GlobalizePath("user://")}");
GD.Print("------------------------------------------------------------------");
GD.Print(" ISOLATION CHECK");
GD.Print("------------------------------------------------------------------");
// The one path this project must never resolve to.
const string forbidden = "app_userdata/islaApocalypse";
bool clash = userDir.Replace('\\', '/').TrimEnd('/').EndsWith(forbidden, System.StringComparison.Ordinal);
GD.Print(clash
? " ✗ FAIL — resolves to the OLD prototype's dir. Do not run anything else."
: " ✓ PASS — not the old prototype's app_userdata/islaApocalypse/.");
GD.Print("------------------------------------------------------------------");
GD.Print(" RESOLVED TOOLING PATHS (env-overridable)");
GD.Print("------------------------------------------------------------------");
GD.Print(ToolingPaths.Describe());
GD.Print("------------------------------------------------------------------");
GD.Print($" Core self-check: {MaterialRegistry.TerrainCount} terrain materials, " +
$"{MaterialRegistry.BuildingCount} building materials, " +
$"{RecipeRegistry.Count} recipes (0 is correct — the seam is deliberately empty).");
GD.Print($" Scale self-check: {new GenerationScale(10240)}");
GD.Print("==================================================================");
GetTree().Quit(clash ? 1 : 0);
}
}
}

View file

@ -0,0 +1 @@
uid://bhqfoi5jqjg2b

26
Tools/batches/README.md Normal file
View file

@ -0,0 +1,26 @@
# batches/ — convention only; the data lives under `user://`
This folder documents the batch layout. **It does not hold batches.**
Real batch output is written under `ISLA_OUTPUT_DIR` (default `user://output/batches`), resolved by
`Core/Scripts/ToolingPaths.cs`. Generated worlds are large, regenerable, and do not belong in git.
## Layout
```
batches/NN_<name>/ NN = the task number that ran it
├── INDEX.md what varied, what to look at, what was concluded
├── scratch/ intermediates — PERSISTENT, never cleaned
└── <seed>_<variant>/ one directory per generated world
```
`INDEX.md` is not optional. A/B comparisons are browsed by a human, and a flat directory of
same-named PNGs is not browsable.
## ⚠ No deletions here. Ever.
`FileSafety.AssertDeletable` refuses any path under the batches root, and it refuses without a
stated reason regardless. **Intermediates persist.** Any deletion that does happen elsewhere is
named explicitly in the run report.
`Tools/README.md`, `Design - Tooling - Iteration and Batching.md`

21
islaApocalypse-v2.csproj Normal file
View file

@ -0,0 +1,21 @@
<Project Sdk="Godot.NET.Sdk/4.7.2">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>IslaApocalypse</RootNamespace>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Phase 0: the shell must build clean. Warnings are not errors yet — that is a call for a
later phase, once the generator exists and the warning surface is known. -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<!-- net8.0 assemblies on a machine carrying only the .NET 10 runtime. Without this the
generated runtimeconfig pins Microsoft.NETCore.App 8.0.0 and rolls forward by MINOR
only, so the app refuses to start with "You must install .NET 8". The TARGET stays
net8.0 as pinned by the task; this only widens what may HOST it. -->
<RollForward>Major</RollForward>
</PropertyGroup>
</Project>

18
islaApocalypse-v2.sln Normal file
View file

@ -0,0 +1,18 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "islaApocalypse-v2", "islaApocalypse-v2.csproj", "{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportRelease|Any CPU = ExportRelease|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
EndGlobalSection
EndGlobal

30
project.godot Normal file
View file

@ -0,0 +1,30 @@
; Engine configuration file.
; Isla Apocalypse — v2 rewrite (D-049). Phase 0 skeleton.
;
; ⚠ user:// ISOLATION — DO NOT WEAKEN. The old prototype is named "islaApocalypse" and owns
; ~/.local/share/godot/app_userdata/islaApocalypse/, which holds the PRESERVED reference seeds,
; blueprints and batches. This project must never resolve there.
; 1. application/config/name is distinct ("islaApocalypse-v2"), so name-derivation alone
; would already give a separate dir.
; 2. use_custom_user_dir + custom_user_dir_name PIN it explicitly, so the isolation survives
; any future display-name change rather than depending on derivation.
; Changing either line moves the runtime data directory. Confirm the resolved path before you do.
config_version=5
[application]
config/name="islaApocalypse-v2"
config/description="Isla Apocalypse — the v2 rewrite (D-049). Phase 0: clean skeleton."
config/version="0.1.0"
config/use_custom_user_dir=true
config/custom_user_dir_name="islaApocalypse-v2"
config/features=PackedStringArray("4.7", "C#", "Forward Plus")
[dotnet]
project/assembly_name="islaApocalypse-v2"
[rendering]
renderer/rendering_method="forward_plus"