feat: ISLA_BLUEPRINT_PATH + ISLA_EXPORT_DIR so tooling never touches the runtime root (terrain-water task 25, FILE SAFETY)

The task-25 file-safety rules make this structural rather than procedural. Two
env overrides, both loud on use:

  ISLA_BLUEPRINT_PATH — MapDataParser.LoadMapData loads an explicit blueprint
    FILE instead of the seed-named one in the runtime root, so capture tooling
    reads straight from a batch folder.
  ISLA_EXPORT_DIR     — MapGenerator writes EVERYTHING a run produces (both
    blueprints and every stage snapshot) into a task folder instead of the root.

Why this is needed and not merely tidy: generation ALWAYS wrote
user://MapData_Seed_<seed>.dat, and my task-22/23/24 capture scripts copied a
blueprint over that path and then rm'd it — which destroyed the blueprint the
developer had staged there to view a world in 3D. With these overrides a run
touches nothing in the root, so there is nothing to clean up afterwards and no
delete anywhere in the drivers. Verified this task: the developer's staged
MapData_Seed_1280587109.dat kept md5 dc2bc61b3eb7 across four generations and a
3D capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-11 21:11:14 -04:00
parent 3cbe4c7967
commit 3fd14c0389
2 changed files with 51 additions and 6 deletions

View file

@ -163,6 +163,24 @@ namespace IslaApocalypse.Core
{ {
public static WorldBlueprint LoadMapData(string seedStr) public static WorldBlueprint LoadMapData(string seedStr)
{ {
// ISLA_BLUEPRINT_PATH (task 25, FILE SAFETY): load an explicit blueprint
// FILE instead of the seed-named one in the runtime root. Capture and A/B
// tooling points this straight at a batch folder, so it never has to copy
// a blueprint over — or delete — the file the developer has staged at the
// root to view a world in 3D. Loud, so a forgotten env var cannot be
// mistaken for the root blueprint.
string envPath = OS.GetEnvironment("ISLA_BLUEPRINT_PATH");
if (!string.IsNullOrEmpty(envPath))
{
if (File.Exists(envPath))
{
GD.Print($"[MapDataParser] ⚠ ISLA_BLUEPRINT_PATH override: loading '{envPath}' " +
"(NOT the runtime-root blueprint).");
return LoadMapDataFromPath(envPath);
}
GD.PrintErr($"[MapDataParser] ISLA_BLUEPRINT_PATH set but '{envPath}' does not exist — " +
"falling back to the runtime-root blueprint.");
}
string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat");
return LoadMapDataFromPath(filePath); return LoadMapDataFromPath(filePath);
} }

View file

@ -335,12 +335,12 @@ public partial class MapGenerator : TextureRect
// 6. Pull the image from the invisible monitor and save it! // 6. Pull the image from the invisible monitor and save it!
Image capture = offscreenVP.GetTexture().GetImage(); Image capture = offscreenVP.GetTexture().GetImage();
string seedStr = _noise.Seed.ToString(); string seedStr = _noise.Seed.ToString();
string fileName = $"user://Map_Seed_{seedStr}_{label}.png"; string fileName = System.IO.Path.Combine(OutputDir(), $"Map_Seed_{seedStr}_{label}.png");
Error saveResult = capture.SavePng(fileName); Error saveResult = capture.SavePng(fileName);
if (saveResult == Error.Ok) if (saveResult == Error.Ok)
GD.Print($"{T()} Map saved: {ProjectSettings.GlobalizePath(fileName)}"); GD.Print($"{T()} Map saved: {fileName}");
else else
GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}"); GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}");
@ -348,18 +348,40 @@ public partial class MapGenerator : TextureRect
offscreenVP.QueueFree(); offscreenVP.QueueFree();
} }
/// <summary>
/// Where generated artifacts go. Normally the runtime root (`user://`), but
/// ISLA_EXPORT_DIR redirects EVERYTHING this run writes — blueprints and stage
/// snapshots — into a task folder instead (task 25, FILE SAFETY). Batch and A/B
/// tooling sets it so a generation can never overwrite the blueprint the
/// developer has staged at the root to view a world in 3D, and so no cleanup
/// move/delete is needed afterwards. Loud on use.
/// </summary>
private string OutputDir()
{
string dir = OS.GetEnvironment("ISLA_EXPORT_DIR");
if (!string.IsNullOrEmpty(dir))
{
if (System.IO.Directory.Exists(dir)) return dir;
GD.PrintErr($"[MapGenerator] ISLA_EXPORT_DIR '{dir}' does not exist — writing to the runtime root instead.");
}
return ProjectSettings.GlobalizePath("user://");
}
private void ExportMapData() private void ExportMapData()
{ {
string seedStr = _noise.Seed.ToString(); string seedStr = _noise.Seed.ToString();
string outDir = OutputDir();
if (outDir != ProjectSettings.GlobalizePath("user://"))
GD.Print($"{T()} ⚠ ISLA_EXPORT_DIR override: writing artifacts to '{outDir}' (NOT the runtime root).");
// PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md), // PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md),
// under the name the server looks for. // under the name the server looks for.
string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); string v2Path = System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}.dat");
BlueprintWriter.WriteV2(v2Path, BuildBlueprint()); BlueprintWriter.WriteV2(v2Path, BuildBlueprint());
// SAFETY NET: the legacy v1 format beside it, until the developer has lived // SAFETY NET: the legacy v1 format beside it, until the developer has lived
// with v2 across several regenerations. Removal is a future task. // with v2 across several regenerations. Removal is a future task.
ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat")); ExportMapDataV1(System.IO.Path.Combine(outDir, $"MapData_Seed_{seedStr}_v1.dat"));
} }
/// <summary> /// <summary>
@ -869,6 +891,9 @@ public partial class MapGenerator : TextureRect
SeaMarginM = ConfigManager.RiverSeaMargin, SeaMarginM = ConfigManager.RiverSeaMargin,
TribWaterMinFlowPx = ConfigManager.RiverTribWaterMinFlow, TribWaterMinFlowPx = ConfigManager.RiverTribWaterMinFlow,
TribTaperPx = ConfigManager.RiverTribTaperPx, TribTaperPx = ConfigManager.RiverTribTaperPx,
FillFraction = ConfigManager.RiverFillFraction,
BankFlare = ConfigManager.RiverBankFlare,
BankMaxCutM = ConfigManager.RiverBankMaxCutM,
LakeMinTargetPx = ConfigManager.RiverLakeMinTargetPx LakeMinTargetPx = ConfigManager.RiverLakeMinTargetPx
}; };
var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater, var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
@ -898,7 +923,8 @@ public partial class MapGenerator : TextureRect
nextBodyId, st.Carved, seaMap, seaFlat, nextBodyId, st.Carved, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y, _impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore, _impactRadius * ConfigManager.CraterErosionCore,
ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM); ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM,
ConfigManager.RiverFillFraction);
long riverWetPx = 0; long riverWetPx = 0;
foreach (var reach in reaches) foreach (var reach in reaches)
{ {
@ -921,7 +947,8 @@ public partial class MapGenerator : TextureRect
GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches ({tribReaches} on tributaries) " + GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches ({tribReaches} on tributaries) " +
$"across {mainCarved} mains + {tribCarved} tributaries, {riverWetPx} wet px " + $"across {mainCarved} mains + {tribCarved} tributaries, {riverWetPx} wet px " +
$"({tribWet} tributary), step drop {ConfigManager.RiverStepDropM:F2} m, depth {ConfigManager.RiverWaterDepthM:F1} m, " + $"({tribWet} tributary), step drop {ConfigManager.RiverStepDropM:F2} m, depth {ConfigManager.RiverWaterDepthM:F1} m, " +
$"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px."); $"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px, " +
$"fill {ConfigManager.RiverFillFraction:P0} of bed depth (min {ConfigManager.RiverWaterDepthM:F1} m), bank flare {ConfigManager.RiverBankFlare:F1}x (shoulder cut cap {ConfigManager.RiverBankMaxCutM:F1} m).");
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +