diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b69a135..f9263f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,7 +98,15 @@ jobs: - name: Run worldgen core checks env: CHECK_SHARD: Worldgen core - run: bash .github/scripts/run_checks.sh tools/worldgen_verify.gd + run: | + bash .github/scripts/run_checks.sh \ + tools/worldgen_verify.gd \ + tools/worldgen_water_verify.gd \ + tools/worldgen_ore_verify.gd \ + tools/worldgen_structure_verify.gd \ + tools/worldgen_poi_verify.gd \ + tools/worldgen_audit_verify.gd \ + tools/worldgen_config_sweep_verify.gd - name: Upload check logs if: failure() @@ -129,7 +137,9 @@ jobs: tools/worldgen_biome_verify.gd \ tools/worldgen_island_verify.gd \ tools/worldgen_river_verify.gd \ - tools/worldgen_lod_verify.gd + tools/worldgen_lod_verify.gd \ + tools/worldgen_spline_verify.gd \ + tools/worldgen_elevated_hydrology_verify.gd - name: Upload check logs if: failure() @@ -155,7 +165,14 @@ jobs: env: CHECK_SHARD: Streaming CHECK_TIMEOUT_SECONDS: "600" - run: bash .github/scripts/run_checks.sh tools/stream_full_verify.gd + run: | + bash .github/scripts/run_checks.sh \ + tools/stream_full_verify.gd \ + tools/stream_soak_verify.gd \ + tools/lod_mode_verify.gd \ + tools/chunk_data_compression_verify.gd \ + tools/world_storage_verify.gd \ + tools/light_invalidation_verify.gd - name: Upload check logs if: failure() diff --git a/AGENTS.md b/AGENTS.md index 633a072..d5d4072 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 fork, v26.2), GDScript only. There is no test framework, linter, typechecker, or CI. Verification is headless scripts under `tools/` (see Verification), then running the game and reading output. +RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 fork, v26.2), GDScript only. There is no unit-test framework, linter, or typechecker. Verification is headless scripts under `tools/` (see Verification), then running the game and reading output; the same checks are sharded in GitHub Actions. ## Engine & tooling @@ -23,10 +23,12 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - Terrain pipeline architecture and tuning guardrails: `world/worldgen/README.md`. -- `voxel_defs.gd`: CHUNK_SIZE 16, WORLD_HEIGHT 128, SEA_LEVEL 32, collision layer 1, padded-neighbor face/AO tables. Shared by the mesher and clouds. -- `voxel_world.gd`: streams chunks around the player (default render_distance 10, unload +2); full detail extends to the render distance (`lod_distance = min(render_distance, MAX_FULL_DETAIL_DISTANCE)` where the cap is 32, so only Extreme distances >32 use LOD). Distant full chunks are committed without collision (`COLLISION_DISTANCE` = 6) and rebuilt on approach to add physics. Full detail everywhere is expensive: RD 32 loads in ~2.5 minutes of worker time and holds ~211 MB of voxel data. `Chunk.lod` tracks the mode; jobs carry it and stale transitions are requeued (also checked against `_stream_center` on commit, like edit versions). Chunk generation and meshing run on `WorkerThreadPool` threads that may only read immutable state (`BlockRegistry`, `TerrainGenerator`, `ChunkMesher`); worker concurrency is half the logical cores (4-8), and only the main thread touches scene nodes. LOD chunks store compact per-column top/sub/water arrays (~3 KB) instead of full voxel data; `break_block()`, `place_block()`, `get_block_world()`, and `_water_place()` refuse LOD chunks. Player edits are kept in `_edited_blocks` and re-applied when chunks regenerate. +- `voxel_defs.gd`: CHUNK_SIZE 16, WORLD_HEIGHT 192, SEA_LEVEL 48, collision layer 1, padded-neighbor face/AO tables. Shared by the mesher and clouds. +- `voxel_world.gd`: streams chunks around the player (default render_distance 10, unload +2). Terrain Detail defaults to Full Detail (`lod_distance = render_distance`) at every distance, including Extreme; opt-in Balanced LOD caps the full-detail radius at `COLLISION_DISTANCE + 2` (8) and uses compact chunks outside it. Extreme Full Detail can take minutes and several GB but never silently enables LOD. Distant full chunks omit collision until approach. Player movement is swept through resident chunk coordinates and stops just inside the truly unloaded boundary; visible LOD/full chunks remain traversable while their approach collision rebuild catches up. All movement pauses while the current chunk's collision is unavailable, and downward motion is suppressed on the entry tick so boosted flight cannot descend through a pending floor. Missing collision is discovered before scheduling, ordered nearest-first, and may use one bounded urgent worker slot so stale distant jobs cannot occupy every normal slot. `Chunk.lod` tracks the mode; jobs carry it and stale transitions are requeued (also checked against `_stream_center` on commit, like edit versions). Chunk generation and meshing run on `WorkerThreadPool` threads that may only read immutable state (`BlockRegistry`, `TerrainGenerator`, `ChunkMesher`); normal worker concurrency is half the logical cores (4-8), plus the one urgent near-player slot, and only the main thread touches scene nodes. LOD chunks store compact per-column top/sub/water arrays (~3 KB) instead of full voxel data; edits refuse LOD chunks. The full-chunk palette/RLE codec remains verifier-covered, but live cold-chunk compaction is disabled: repeated neighbor decoding caused flight stutter and could feed a stale short snapshot to remeshing. Balanced LOD is the supported memory-saving path. + - Stream-center rebuilds enumerate nearest-first rings without sorting the full square, completed collision results commit before distant visuals, and a single-cell edit queues its owner ahead of neighbor-light remeshes. Physics bodies/shapes exist only inside the collision radius; distant visual chunks do not register empty bodies with the physics server. + - Persistence: `world/world_storage.gd` stores versioned metadata and deterministic sparse edits in ZSTD region files. Region I/O and edit hydration stay on the main thread; workers receive immutable per-chunk snapshots. The clean-region cache is capped at 16, dirty regions cannot be evicted, unloaded disk-backed edit buckets are released, and temp/backup replacement preserves a valid recovery backup. Save metadata includes player/inventory/slot/spawn/time/moon/weather; `Main` autosaves every 30 seconds and flushes on exit/new world. - Spawning: `TerrainGenerator.find_spawn_position()` only knows terrain height, so `VoxelWorld.find_safe_spawn()` searches outward in the generated chunk data for a column whose topmost block is solid (not leaves) with two air cells above; `Main` calls it after `setup_player()`. It depends on which chunk jobs have committed, so the result varies run to run — pin `GameConfig.world["seed"]` and use `get_spawn_position()` (deterministic) for reproducible captures. -- `terrain_generator.gd`: FastNoiseLite climate biomes (15, climate-selected), caves, ores, tree stamping. Deterministic per seed, no mutable state after `configure()`. `generate_data(pos, edits, lod)` returns either full voxel data or compact LOD column arrays (`lod_solid_y`/`lod_solid_id`/`lod_sub_id`/`lod_water_y`/`lod_water_level`) with no `data` allocation; LOD columns bake real tree crowns from in-field anchors, mirror the floor-patch pass, and add low scrub so distance matches the full-detail ring. +- `terrain_generator.gd`: FastNoiseLite climate biomes, caves, data-driven ores, and tree/structure stamping. Deterministic per seed, no mutable state after `configure()`. `WorldGenConfig.CURRENT_VERSION` is 13: v8 keeps legacy cave classification, v9 adds dripstone caves/highland boulders, v10 preserves the original default-off spline terrain/elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed elevated hydrology, organic cave regions, large trees, improved lava basins, region POIs, and soft ceiling behavior, v12 replaces the frequent artificial cobblestone outcrop props with low stone pebbles, and v13 removes rigid fallen-log/driftwood decorations. `generate_data(pos, edits, lod)` returns either full voxel data or compact LOD columns; both modes consume the same authoritative inland-water field. - `chunk_mesher.gd`: produces one ArrayMesh per chunk, a separate water mesh, and ConcavePolygonShape3D collision (the last two only for full-detail chunks). `build_lod()` instead takes the compact distance columns and emits one top quad per column plus vertical runs of side quads down to the neighbor's top, sampling compact per-edge top arrays (`LodNeighbors`); heights match the full mesh so seams stay closed, and light is full sky. LOD block faces get cheap occlusion instead of a light volume: top faces darken under taller cardinal neighbors, sides darken with depth, and sides below the soil layer use stone. Full-detail chunks receive `NeighborSample.from_lod()` samples for bordering distance chunks and expand them into the light volume on the worker thread. Face shading, ambient occlusion, and voxel light are baked per vertex. Before meshing, a 3x3-chunk block volume is assembled and flood-filled on the worker thread: the generator's per-column heightmap (`GenResult.heights`) seeds a sky-light pass, then a lateral BFS carries it indoors, and an RGB block-light BFS runs when emissive blocks are present. Per-vertex light is packed into `ARRAY_CUSTOM0` (`ARRAY_CUSTOM_RGBA_FLOAT`: block RGB + sky level) and the texture-array layer into `ARRAY_CUSTOM1` (`ARRAY_CUSTOM_R_FLOAT`); `world/block.gdshader` samples `vec3(UV, layer)`, multiplies sky light into albedo, and adds block light as emission. Any custom-attribute mesh needs the matching `Mesh.ARRAY_FORMAT_CUSTOM*` flags in `arrays_to_mesh()`, and `world/block.gdshader` replaces the old StandardMaterial3D. - `block_registry.gd`: block table `BLOCK_DEFS` rows `[id, name, top, side, bottom, flags]`, packed into a runtime `Texture2DArray` (one 64px layer per texture; per-image tinting, edge fix-up, and mipmaps). `layer_for(block_id, face)` returns the layer the mesher writes per vertex. - To add a block: append a row using an existing texture filename from `assets/placeholders/zigcraft/default/`, then add it to `Main.HOTBAR` / `INITIAL_INVENTORY` if it should be placeable. A missing texture logs a warning and renders magenta. @@ -54,6 +56,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - `soft_shadows` sets directional/positional PCF quality plus `Sun.light_angular_distance` (0 when off, giving hard but stable edges); `taa` sets `Viewport.use_taa` (FSR2 below 100% scale takes over temporal AA automatically); `fsr_scale` at 100% disables FSR2 and renders native; `_apply_graphics()` also forces `Viewport.anisotropic_filtering_level = ANISOTROPY_16X`. - Adding a graphics key: add it to every `GRAPHICS_PRESETS` entry. `GameConfig.load_settings()` only copies saved values for keys that already exist in the preset, so existing user configs automatically pick up the preset default for new keys. - Frame pacing lives in Display settings (`vsync`, `fps_cap`, `dynamic_resolution`, `dynamic_resolution_target`); `GameConfig.apply_frame_pacing()` pushes vsync and `Engine.max_fps` at load and on change, and `Main._update_frame_pacing()` steps `Viewport.scaling_3d_scale` toward the target via the pure `GameConfig.dynamic_resolution_scale()` (50% floor, never above the configured `fsr_scale`; the vsync refresh and FPS cap are part of the budget). Pacing keys survive because `load_settings()` copies every key that exists in `DEFAULT_SETTINGS`. +- Terrain Detail is `lod_mode` in Display settings: 0 Full Detail (default), 1 Balanced LOD. Apply it through `VoxelWorld.set_lod_mode()` so loaded chunks transition safely. ## Player / HUD contract @@ -85,7 +88,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - Parse check after editing scripts or scenes: `redot --editor --headless --path . --quit` (a nonzero "error" count means a script/scene fails to load). - Headless verifiers, from the repo root (`redot --headless --path . --script res://tools/`): - `worldgen_verify.gd` (parity, seams, continuity, roughness), `worldgen_biome_verify.gd` (region size, vegetation density), `worldgen_river_verify.gd` (river width, banks, floodplain, bed), `worldgen_island_verify.gd` (detached large/small island landmasses), `worldgen_lod_verify.gd` (LOD parity, canopies, patches, shading), `worldgen_tree_verify.gd`, `worldgen_cactus_verify.gd`, `audio_verify.gd` (bank/buses/mappings), `weather_verify.gd` (biome precipitation, snow/mist fields, lightning schedule, wind-sway wiring), `ui_flow_verify.gd` (menu flows, cancel order, focus), `ui_scale_verify.gd` (window content scale, text multiplier, live theme refresh), `input_rebind_verify.gd` (action coverage, apply/reset, conflicts, persistence, capture flow), `stream_full_verify.gd` (no LOD inside render distance, lazy collision), `photo_mode_verify.gd` (HUD toggle state, free-camera handoff/look/movement, player artifact and UI-layer hiding, input bindings, screenshot naming), `frame_pacing_verify.gd` (pacing defaults, vsync/FPS application, dynamic-resolution policy), `shadow_proxy_verify.gd` (leaf wind marker, shader `IN_SHADOW_PASS` wiring). + `worldgen_verify.gd` (parity, seams, continuity, roughness), `worldgen_biome_verify.gd` (region size, vegetation density), `worldgen_river_verify.gd` (river width, banks, floodplain, bed), `worldgen_island_verify.gd` (detached large/small island landmasses), `worldgen_lod_verify.gd` (LOD parity, canopies, patches, shading), `worldgen_water_verify.gd` (aquifers), `worldgen_ore_verify.gd`, `worldgen_structure_verify.gd`, `worldgen_poi_verify.gd`, `worldgen_audit_verify.gd`, `worldgen_config_sweep_verify.gd`, `worldgen_spline_verify.gd`, `worldgen_elevated_hydrology_verify.gd`, `worldgen_tree_verify.gd`, `worldgen_cactus_verify.gd`, `world_storage_verify.gd`, `lod_mode_verify.gd`, `chunk_data_compression_verify.gd`, `stream_soak_verify.gd`, `light_invalidation_verify.gd`, `audio_verify.gd`, `weather_verify.gd`, `ui_flow_verify.gd`, `ui_scale_verify.gd`, `input_rebind_verify.gd`, `stream_full_verify.gd`, `photo_mode_verify.gd`, `frame_pacing_verify.gd`, `shadow_proxy_verify.gd`. - `redot --headless --path . res://tools/player_target_verify.tscn` is scene-based because autoload identifiers are unavailable to `--script` harnesses; use a `.tscn` whenever a test references `GameConfig`/`AudioManager`. - `redot --headless --path . res://tools/explosives_verify.tscn` checks flint-and-steel activation, TNT/nuke radii, spherical edit persistence, unbreakable blocks, and item-only placement rejection. - `redot --path . res://tools/shadow_proxy_measure.tscn` needs a rendering display: it loads the real scene with a flat canopy, sweeps the sun, and prints the shadow-edge temporal MAD for the leaf shadow proxy off/off-control/on with and without TAA. diff --git a/ROADMAP.md b/ROADMAP.md index a0fd0fa..838a7c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,8 @@ Property names and file references are included so each item is easy to find. - [x] Full per-option menu — `ui/graphics_panel.tscn` ("Advanced Graphics..." in Settings); sections: Lighting, Shadows, Sky & Atmosphere, Post-Processing, Performance - [x] Per-option overrides on top of a preset with "Custom" indicator + "Reset to Preset" - [x] Graphics settings persist to `user://settings.cfg` (`[graphics] values` dictionary) -- [x] Render distance slider 4-32 chunks — `ui/settings_category_panel.gd` (Display category) raises the cap from 16; full-detail chunks now extend to the render distance itself (`lod_distance = min(render_distance, VoxelWorld.MAX_FULL_DETAIL_DISTANCE)`). An "Extreme" toggle raises the slider to 100 chunks with an inline load-time/memory warning; only distances past the 32-chunk full-detail cap fall back to compact LOD. Collision shapes are built only within `VoxelWorld.COLLISION_DISTANCE` and added on approach, so distant full chunks hold data/mesh but no physics. Authoritative streaming now generates data first and meshes once the neighbor ring is ready, avoiding temporary preview geometry and redundant seam regeneration; RD 10 (441 full chunks, 8 workers) completes in ~11.5 s in `stream_full_verify.gd`. The older pre-pipeline RD 32 ~155 s / RD 16 ~37 s measurements need refreshing +- [x] Render distance slider 4-32 chunks — `ui/settings_category_panel.gd` (Display category) raises the cap from 16. Terrain Detail defaults to Full Detail through the entire selected distance; opt-in Balanced LOD keeps an eight-chunk full-detail radius and uses compact columns beyond it. An "Extreme" toggle raises the slider to 100 chunks with an inline load-time/memory warning but never silently enables LOD. Collision shapes are built only within `VoxelWorld.COLLISION_DISTANCE` and added on approach, so distant full chunks hold data/mesh but no physics. Authoritative streaming generates data first and meshes once the neighbor ring is ready, avoiding temporary preview geometry and redundant seam regeneration. Current RD 10 verification: Full Detail loads 441/441 chunks in ~12 s; Balanced loads 289 full + 152 compact chunks. The older RD 32 ~155 s / RD 16 ~37 s measurements need refreshing +- [x] Extreme-stream responsiveness — desired chunks are enumerated in nearest-first rings without sorting the entire 10k–40k chunk square on every crossed chunk edge; near collision generation/remesh and completed collision commits outrank distant visual work; edited owners outrank neighbor-light remeshes; and only the collision-radius ring owns physics bodies, avoiding thousands of empty `StaticBody3D` registrations. ## Interface - [x] Deepslate & Ember UI overhaul — shared `UITheme`, bundled Iosevka typography, reusable motion and generated controls, dusk voxel title screen, sectioned settings/world creation, responsive inventory, texture-derived isometric block icons, compact telemetry HUD, item hotbar, focus restoration, and nested-modal cancel handling. Architecture notes live in `ui/README.md` @@ -56,7 +57,7 @@ Property names and file references are included so each item is easy to find. ## Geometry / materials - [x] `Texture2DArray` instead of the block atlas — `BlockRegistry` now builds one 64px layer per texture (per-image tint, alpha edge fix-up, mipmaps) and the mesher writes each face's layer into `ARRAY_CUSTOM1` (`ARRAY_CUSTOM_R_FLOAT`); `block.gdshader` samples `vec3(UV, layer)`, so there is no atlas inset and no cross-tile UV/mip bleeding - [ ] Chunk merging / MultiMesh — fewer draw calls for distant chunks -- [x] Chunk LOD meshes — `ChunkMesher.build_lod()` distance meshes (top quad per column + merged exposed material runs, no light volume/AO/collision) for chunks beyond `lod_distance` (the render distance, capped at 32 chunks for Extreme distances); full detail covers the configured render distance and rebuilds LOD<->full on approach/retreat with the same version checks as edits. Distance chunks carry compact per-column top/sub/water arrays (~3 KB) instead of a full 192-block data array (~50 KB), and full chunks expand compact neighbors on worker threads for correct light and culling. Current pinned 5x5/eight-worker benchmark: 34.1 full chunks/s vs 180 compact LOD chunks/s, with ~93 ms full mesh vs ~3.6 ms merged LOD mesh average per concurrent job +- [x] Chunk LOD meshes — `ChunkMesher.build_lod()` distance meshes (top quad per column + merged exposed material runs, no light volume/AO/collision) for chunks beyond `lod_distance`; Full Detail always covers the complete configured distance, while Balanced LOD starts compact chunks beyond eight. Live setting changes rebuild LOD<->full with the same stale-job/version checks as edits. Distance chunks carry compact per-column top/sub/water arrays (~3 KB) instead of a full 192-block data array (~50 KB), and full chunks expand compact neighbors on worker threads for correct light and culling. `tools/lod_mode_verify.gd` checks the policy and a real 441-chunk mixed stream - [x] LOD tree canopies — compact columns bake the actual tree crowns using the same stamp functions as full chunks. Distance collection keeps only anchors inside the padded field and skips site-validity probes, which leave the field and re-enter the sampler (full parity cost ~19 ms per forest chunk, nearly full population). The top two tree blocks become the column's solid/sub pair, so distance forests read as real trees. Measured: LOD generation ~12.2 ms vs ~28.4 ms full, with the canopy pass ~1.2 ms; grove sample 142 canopy columns, open-ocean sample 0. `worldgen_lod_verify.gd` checks forest coverage and ocean exclusion - [x] LOD occlusion shading — distance meshes carry no AO or block-light volume, so far terrain read flat and cliffs showed a single soil stripe to the ground. Top faces now darken under taller cardinal neighbors (`LOD_AO_PER_BLOCK` 0.14, floor 0.52), side faces darken with depth, and sides below `LOD_SOIL_DEPTH` switch to stone (leaf columns stay leaf-textured). Pinned verification: flat spread 0.0000, hilly spread 1.5036, 5273 stone side faces; LOD mesh stays ~14 ms vs ~183 ms full - [x] LOD ground cover — compact columns now carry the same floor patches as full chunks (dirt/mud/gravel from the shared patch pass) plus low scrub bumps on grove edges and clearings (grove strength sampled on a stride-8 lattice, 12% of columns in the 0.45-0.85 band). `worldgen_lod_verify` checks patch propagation (2408 patch columns across a 7x7 grove area) alongside canopy coverage. Full generation ~30.8 ms, LOD ~12.5 ms @@ -70,10 +71,10 @@ Property names and file references are included so each item is easy to find. - [x] Volumetric fog density per time of day — `DayNightCycle.base_volumetric_fog_density` (set from the preset by `Main._apply_graphics()`) is scaled by ×1.25 at night, ×1.6 in the dawn/dusk band, and ×1.5 in rain, so mornings are mistier without washing out the night sky. Headless check pins Medium-preset values at noon 0.006 / dawn 0.0115 / midnight 0.0075; dawn over deep ocean and midnight captures inspected ## Gameplay -- [ ] World saving/loading — persist `VoxelWorld._edited_blocks`, player position/inventory, and world config per world (biggest missing gameplay feature) -- [ ] World management UI — world list on the main menu with create/rename/duplicate/delete/backup plus autosave interval; `GameConfig.world` exists only in memory today -- [ ] Versioned save format and migration — tag saves with a format revision and migrate or reject cleanly, mirroring the worldgen config revision flow (`world/worldgen/world_gen_config.gd` `CURRENT_VERSION`) -- [ ] Persist time, weather, inventory, and player state — `DayNightCycle.time_hours`, `WeatherSystem`, and `Main.inventory` all reset each launch; only `_edited_blocks` is durable today +- [x] World saving/loading — `WorldStorage` stores immutable worldgen metadata and deterministic sparse final block edits in ZSTD region files; `VoxelWorld` hydrates edits on the main thread before worker snapshots and evicts unloaded disk-backed edit buckets. Continue Last World restores the most recent save, and autosave/exit/new-world flows flush dirty regions +- [ ] World management UI — Continue Last World is available, but a browsable world list with rename/duplicate/delete/manual-backup controls and an autosave-interval setting remains +- [x] Versioned save format and migration — metadata and region files carry independent magic/version/layout fields; unsupported future or malformed data is rejected, writes use temp/backup replacement, and corrupt primaries recover without discarding the only valid backup. The clean-region cache is bounded to 16 entries and dirty regions are never evicted +- [x] Persist time, weather, inventory, and player state — save state includes player position/flying, inventory, selected slot, spawn, day/night time, moon phase, and weather - [ ] Survival layer — health/hunger/damage with respawn and damage sources (fall, drowning, lava/fire); `Player` has no health, falling below `FALL_RESET_Y` just teleports - [ ] Tools, hardness, and durability — per-block hardness/break time and tool tiers in `BlockRegistry.BLOCK_DEFS`, tool stacks with durability; mining is instant today (`Player._break_target()` -> `VoxelWorld.break_block()`) - [ ] Item inventory and crafting — stack grid with drag/drop beyond the fixed `Main.HOTBAR`, crafting recipes/table, furnaces, and chests; `Main.inventory` is only an id->count dictionary rendered by `InventoryOverlay` @@ -92,7 +93,7 @@ Property names and file references are included so each item is easy to find. - [ ] Biome and location ambience — cave drips, birdsong, and wind at altitude; the underwater low-pass landed with the underwater ambience pass, and the cold-biome wind bed plus thunder landed with the weather depth item above ## World generation overhaul -Current state: the TerraForged-inspired staged pipeline is live under `world/worldgen/`. It builds immutable padded terrain fields, domain-warped continents and blended profiles, analytic erosion and optional cached hydraulic erosion, river/coast masks, climate-selected biomes, data-driven surfaces, caves/ores, and deterministic cross-chunk decoration. `TerrainGenerator` remains an immutable worker-safe facade. The world is 192 blocks high with sea level 48; generation verification and live F3/F4 diagnostics are available for tuning. +Current state: the TerraForged-inspired staged pipeline is live under `world/worldgen/`. It builds immutable padded terrain fields, domain-warped continents and blended profiles, analytic erosion and optional cached hydraulic erosion, river/coast masks, climate-selected biomes, data-driven surfaces, caves/ores, and deterministic cross-chunk decoration/POIs. `TerrainGenerator` remains an immutable worker-safe facade. The world is 192 blocks high with sea level 48. `WorldGenConfig.CURRENT_VERSION` is 13: v8 preserves the old cave classifier, v9 adds dripstone regions and highland boulders, v10 preserves the original default-off spline terrain and elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed hydrology, organic cave regions, large trees, improved lava basins, region POIs, and a soft terrain ceiling, v12 removes the frequent raised cobblestone outcrop props from natural biome decoration, and v13 removes the rigid fallen-log/driftwood props. ### Research / decisions (do first) - [x] Choose the world-gen reference model — use a custom RedotCraft pipeline inspired by TerraForged 0.3.x, not a literal port. TerraForged's useful pattern is staged data generation (continent/terrain profiles -> erosion/rivers -> climate/biomes -> voxel fill -> surfaces/caves/decorations), but the archived project depends on an unavailable `Engine` module and Minecraft-specific chunk, registry, structure, and decoration APIs. Reimplement the ideas with `FastNoiseLite`, immutable GDScript data, and Redot's worker pool; do not target seed- or output-compatibility. @@ -110,7 +111,12 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] Phase 5 — cave regions: deterministic worm segments, bounded caverns, rare multi-lobed mega-caves, aquifers/lava, ore veins, and sparse cave decoration run after surface fill with surface/river protection. - [x] Phase 6 — optional regional hydraulic erosion: deterministic 64x64 droplet tiles are cached by immutable worldgen configuration and sampled seam-safely. It remains off by default because a cold tile solve is intentionally a high-quality/slower option. - [x] Phase 7 — streaming robustness: every chunk job carries a worldgen config revision, so results generated under an old configuration are discarded and requeued; worker concurrency scales with half the logical cores (4-8) after measuring 8 jobs ~40% faster than 4 while 16 added only ~10% more. -- [x] Phase 8 — compact distance chunks: LOD population writes per-column top/sub/water arrays instead of a full 192-block data array, making far chunks ~3x cheaper to generate and kilobyte-sized. Ground flora, caves, ores, and sparse decorations are omitted from LOD; real tree crowns from in-field anchors are baked into the compact columns (LOD ~12 ms vs ~28 ms full). Full detail now covers the configured render distance (cap 32), so LOD only serves Extreme distances past 32; distant full chunks skip collision until approached. Measured with full detail everywhere and 8 workers: RD 10 ~26 s in-engine, RD 16 ~37 s CPU, RD 32 ~155 s CPU and ~211 MB of voxel data, versus ~80 s at RD 32 with the old LOD shortcut. +- [x] Phase 8 — compact distance chunks: LOD population writes per-column top/sub/water arrays instead of a full 192-block data array, making far chunks several times cheaper to generate and kilobyte-sized. Ground flora, caves, ores, and sparse decorations are omitted from LOD; real tree crowns from in-field anchors are baked into compact columns. Full Detail covers the complete configured distance even in Extreme mode; only explicit Balanced LOD uses compact chunks beyond radius 8. Distant full chunks skip collision until approached; a chunk-grid movement sweep keeps walking, flying, and low-FPS motion just inside the truly unloaded boundary without blocking visible chunks whose collision is still rebuilding, movement pauses if the current chunk loses collision, downward entry motion is suppressed so boosted flight cannot cross a pending floor, near collision work is queued before generation and receives one bounded urgent worker slot during stale-job pressure, and invalid buried save positions recover through the safe-spawn search. +- [x] Phase 9 — compatibility and data catalogs: canonical defaults/ranges moved to `WorldGenConfig`; ordered ore rules moved to `OreCatalog` with SHA-256 fixtures; v8 cave output remains selectable while v9 adds dripstone cave regions and deterministic highland boulders through the global feature lattice. +- [x] Phase 10 — experimental terrain systems: v10 adds default-off monotone cubic continental/profile remapping and fixed-level elevated lakes/reaches. Full and compact generation share authoritative inland-water levels, and dedicated verifiers pin gates, seams, banks, support, and full/LOD parity. +- [x] Phase 11 — targeted performance pass: aquifers use global seam-safe cells, decoration uses call-local packed candidates and a fixed open-addressed ground cache, emissive discovery is one mesher scan, and single edits invalidate only the bounded light footprint. A native/GDExtension port is deferred: current generation is ~57-60 ms full / ~15-16 ms compact and build/distribution complexity outweighs the measured opportunity until phase profiling identifies a dominant native-sized kernel. +- [x] Version 11 audit closeout — compatibility-gated climate variants, 2D spline profiles, downhill elevated-water routes, organic cave regions, large trees, multi-depth lava basins, camps/watchtower ruins, and an asymptotic ceiling landed with focused verifiers and a 14-corner configuration sweep. Terrain scale imports are capped at 2.0 to keep supported combinations inside the 192-block vertical budget. +- [x] Version 12 prop cleanup — snowfields, alpine terrain, and badlands use low stone pebbles instead of frequent raised cobblestone L-shaped outcrops. Version 11 retains its exact catalog for existing-world compatibility; rare alpine boulders and intentional camp/watchtower cobblestone remain. ### Terrain and biomes - [x] Vertical limit fix — 192-block storage, typed heights, profile-specific relief, and sampled cap verification remove the old flat-topped 104-block limit. @@ -124,7 +130,7 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] River and cut naturalization — channels follow a native domain-warped noise so reaches meander, and `_river_distance_at()` converts the corridor to blocks with `|n| / |grad n|`, removing the gradient-dependent pinch/fan of the old mask. The cross-section is dished (pool/riffle bed depth, tapered bank run, floodplain apron) with corridor-space relief damping, and the carve runs on the eroded raw height after hillslope erosion. Pinned `worldgen_river_verify`: bounded-channel width interquartile ratio 1.53-1.65 vs 1.93-2.46 for the old mask, depth mean 1.7-2.0 with 1.7-2.2 spread (old flat pan at exactly 2.0), bank/flood step means 0.5-0.6/0.3-0.4, and a dished center-to-edge depth gap of 1.4-1.5; biome coherence 0.553. Full generation ~34 ms and mesh ~179 ms, both unchanged; Forward+ aerial/bank captures reviewed - [x] Biome transition accuracy — nearest-climate boundaries now expand into explicit ecotone fields, bend toward terrain-suitable ecology (low, gentle wetlands; upland forest/taiga; altitude-favoured snow), and use a slow independent patch field to carry the secondary biome into coherent surface-material and vegetation clusters rather than one-block dithering. Primary interiors remain broad (120-block measured radius); pinned verification records 12.1% ecotone coverage, 3.7% secondary ownership inside transitions, 0.522 neighbor coherence, and 0.131 secondary-patch coherence, with field/point/decorator parity and seam checks - [x] Island generation — effective continentalness now combines the unchanged mainland field with independent sparse 720-block and 135-block offshore peak fields. The coast gate fades both additions before established continents, while the shared terrain/profile/climate pipeline turns their cores into naturally surfaced, vegetated large islands and small islets in full and compact LOD chunks; mainland river gates remain tied to mainland continentalness so arbitrary channels do not cut islands. `worldgen_island_verify.gd` finds detached dry components at both scales and confirms continental interiors are untouched. Pinned generation averages 43.6 ms full / 15.3 ms compact LOD after caching profile reuse in the field smoother -- [ ] Landmark terrain — standout natural features: waterfalls where rivers meet cliffs, ravines, natural arches, and boulder fields. Should read as memorable landmarks rather than adding back procedural noise +- [ ] Landmark terrain — versioned highland boulder fields have landed through the seam-safe feature lattice. Waterfalls where rivers meet cliffs, ravines, and natural arches remain; they should read as memorable landmarks rather than adding back procedural noise - [x] Deep forests and larger regions — `biome_scale` raised again to 3072 (worldgen version 4), groves widened (90% of cells valid, radius 30-42), and grove interiors over strength 0.75 now select `FEATURE_ANCIENT_TREE` (trunk 8-11, radius-3 crowns) with a full understory (cover chance 1.0, +2 tufts) so forests have old-growth cores instead of uniform crowns. Measured 7x7 samples: forest oak 1789, taiga spruce 2068, jungle logs 1677, 63 jungle vines; biome interior radius 113 -> 140 blocks. Full generation ~31 ms, LOD ~12.5 ms - [x] Larger biome regions — default `biome_scale` doubled from 896 to 1792 blocks (worldgen version 3; world-creation slider now reaches 4096), so temperature/moisture fields and their terrain-shaping weights span broad territories. `tools/worldgen_biome_verify.gd` now measures contiguous territory radius: 72 blocks at the old scale vs 115 at 1792, with neighbor coherence 0.609. Restoring the `lowland` gate on wetland basins was required: without it the broader river-mask influence pulled a 119-block mountain river edge down 19 blocks in one step and tripped the terrain-continuity guardrail - [ ] Seasonal drift — slow temperature/moisture variation over long play sessions that shifts foliage tint, snow coverage, and weather weighting; needs a world-time clock beyond `DayNightCycle` @@ -147,7 +153,7 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] Underwater lighting and ambience — `VoxelWorld.get_water_ambience()` reports submersion, depth below the local water surface, and the biome tint; `Main._update_underwater()` blends overlay color/alpha by biome and depth, and feeds `DayNightCycle.set_underwater()`, which darkens sun/ambient, thickens regular and volumetric fog with depth, and drives the `underwater_caustics` global uniform that `world/block.gdshader` projects onto sky-lit upward faces. `AudioManager.set_underwater()` applies a Master-bus low-pass for muffled sound. Tuning after GPU captures: fog add 0.03, volumetric x1.4, light floor 0.32. Pinned-seed captures at coral reef, seagrass meadow, kelp forest, and deep sea confirmed readable biome-specific water and seabed. Follow-up fix for shallow underwater banks reading as bright floating "fins": water light attenuation 2 -> 3 (`BlockRegistry.ATTENUATION_WATER`) and fog add 0.018 -> 0.03; captured at seed 1 / `-168,40,-304` (deep sea, RD 12), the lit banks now fade into the water column instead of glowing white against the abyssal floor ### Structures and points of interest -- [ ] Structures / POIs — deterministic, seam-safe ruins, abandoned camps, mineshafts, and watchtowers placed from global anchors. Must respect player edits and regenerate consistently across chunks; loot and inventory hooks stay with the gameplay pass +- [ ] Structures / POIs — v11 now places deterministic seam-safe abandoned camps and watchtower ruins from >=128-block region cells, clips stamps across chunk boundaries, keeps full/LOD tops in parity, and applies player edits last (`tools/worldgen_poi_verify.gd`). Mineshafts, loot, and inventory hooks remain. ### Caves and underground - [x] Phase 1 — endless hybrid cave network: Minecraft/Luanti-inspired absolute-coordinate spaghetti and cheese density fields provide organic seam-free tunnels/chambers, while a five-depth-band deterministic 48-block graph guarantees that every canonical trunk continues indefinitely. Curved trunks, cross-links, mandatory vertical connectors, and node chambers replace the isolated 2-4-segment worms; surface entrances now join graph nodes instead of ending blindly in stone. @@ -155,9 +161,9 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] Phase 3 — underground features: damp mud/mycelium cave patches, depth-banded ore veins, aquifers, lava pockets, and stone formations. Random deep cobblestone variation was removed so cobblestone remains player-made; mineshafts/ruins remain separate future structure work. - [x] Cave-aware lighting and meshing — `ChunkMesher.build_light_volume()` exposes the temporary 3x3 volume to deterministic fixtures; missing chunks are closed light boundaries rather than daylight leaks, water/leaf side seeds use their real attenuation, flood queues grow instead of silently truncating, and edits/commits invalidate all eight sampled neighbors (including pending-job races). `tools/worldgen_cave_verify.gd` pins sealed darkness, a large opening's finite falloff, colored crystal light, and exact cavern-volume bounds - [x] Cave dressing — the sparse global cave lattice now finds nearby floors/ceilings and places paired dripstone, short columns, damp moss/mycelium, hanging cave growth, sculk/deepstone patches, and small floor-supported pools without returning to a full 3D voxel scan -- [x] Cave biomes — `BiomeCatalog` appends `LUSH_CAVES`/`DEEP_DARK` as a separate coherent 3D region layer (surface IDs stay stable); exposed cave materials and vegetation follow that layer, while `VoxelWorld.get_cave_ambience()` scans to the loaded column top so even tall mega-caves receive distinct green/teal fog and light floors. Cave regions cover most underground territory, and the HUD replaces the surface-biome label while one is active +- [x] Cave biomes — `BiomeCatalog` appends `LUSH_CAVES`/`DEEP_DARK` plus version-9 `DRIPSTONE_CAVES` as a separate coherent 3D region layer (surface IDs stay stable); exposed cave materials and vegetation follow that layer, while `VoxelWorld.get_cave_ambience()` scans to the loaded column top so even tall mega-caves receive distinct ambience. Version 8 retains its original classifier - [x] Geodes and crystal formations — rare global-cell spheres clip seam-safely across chunks with a dark shell, calcite lining, hollow core, amethyst deposits, and emissive crystal buds that automatically seed the RGB block-light flood -- [x] Ore distribution overhaul — deterministic cell-anchored coal, iron, and gold vein segments use depth bands and replace stone after caves are carved. +- [x] Ore distribution overhaul — deterministic cell-anchored coal, iron, and gold vein segments use ordered `OreCatalog` depth rules and replace stone after caves are carved. SHA-256 fixtures preserve exact output through worldgen v10. - [x] Cave performance guardrails — noise carving scans only eligible stone below each column's protected surface and Y100 cap; graph/cavern stamps clip to the target chunk from bounded owner halos; aquifer decisions remain chunk-local. The richer network raises pinned normal full generation from ~44 ms to ~58 ms while compact LOD remains ~16 ms. The cave verifier follows 128 trunk cells and flood-fills a 5x5 independently generated hybrid fixture: 6.9% air with a 32,128-block component spanning the full 80x92x80 volume. ### Verification @@ -172,6 +178,8 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] LOD seam verification — `tools/worldgen_lod_verify.gd` compares compact columns against a decoration-free full chunk (top/sub/water, height map), checks distance-mesh top-face geometry, and meshes a full chunk against compact neighbor samples. - [x] Streaming benchmark — `tools/worldgen_stream_benchmark.gd` records ring-load wall time and throughput at render distances 10/16/32 with 4/8/16 concurrent jobs. - [x] Full-distance streaming verification — `tools/stream_full_verify.gd` asserts there is no LOD inside the configured render distance, collision exists only near the player, and approaching a distant chunk rebuilds it with collision while far shapes are dropped. +- [x] Persistence and mode verification — `tools/world_storage_verify.gd` checks negative coordinates, deterministic bytes, edit priority, corruption recovery, validation, bounded clean-cache lifecycle, and compression; `tools/lod_mode_verify.gd` checks policy transitions and an actual 289-full/152-compact RD 10 stream. +- [x] Cold-chunk memory experiment — the deterministic full-volume palette/RLE codec reduces the verifier fixture from 49,152 to 135 bytes and remains covered by `tools/chunk_data_compression_verify.gd`. Runtime compaction was disabled after live flight exposed neighbor-decode stutter and stale short snapshots during remeshing; Balanced LOD remains the supported memory-saving mode. - [x] Perf budget — recorded at render distance 32 across six representative biomes plus spawn (ocean, snow, jungle, forest, badlands, desert): max ~3.8k draw calls, ~3.1M primitives, ~286 MB process memory, ~1.28 GB VRAM at 60 FPS (2048 test shadow atlas; the production 16384 atlas adds ~0.5 GB). Pinned generation/mesh/streaming benchmarks and F3 EMAs cover CPU cost, and mesher work stays worker-thread safe and deterministic per seed. ## World / simulation diff --git a/autoload/game_config.gd b/autoload/game_config.gd index 6e59d0d..a1bc125 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -4,6 +4,7 @@ const SETTINGS_PATH := "user://settings.cfg" const DEFAULT_SETTINGS := { "render_distance": 10, + "lod_mode": 0, "extreme_render_distance": false, "fov": 76.0, "mouse_sensitivity": 0.0022, @@ -44,6 +45,9 @@ const DYNAMIC_RESOLUTION_MIN_SCALE := 0.5 const DYNAMIC_RESOLUTION_STEP := 0.05 const DYNAMIC_RESOLUTION_DOWN_MARGIN := 1.05 const DYNAMIC_RESOLUTION_UP_MARGIN := 0.85 +const LOD_MODE_FULL := 0 +const LOD_MODE_BALANCED := 1 +const LOD_MODE_NAMES := ["Full Detail", "Balanced LOD"] # Rebindable input actions in menu order. The InputMap's non-`ui_*` actions are # the source of truth: anything missing from this table still gets a row with a @@ -163,25 +167,13 @@ const GRAPHICS_PRESETS := { }, } -const DEFAULT_WORLD := { - "seed": 0, - "world_type": 0, - "terrain_scale": 1.0, - "tree_density": 1.0, - "worldgen_version": 8, - "macro_scale": 384.0, - "biome_scale": 3072.0, - "river_density": 1.0, - "erosion_strength": 0.55, - "regional_erosion": 0.5, - "hydraulic_erosion": false, - "cave_density": 1.0, - "decoration_density": 1.0, -} +var DEFAULT_WORLD: Dictionary = WorldGenConfig.default_dictionary() var settings: Dictionary = DEFAULT_SETTINGS.duplicate() var world: Dictionary = DEFAULT_WORLD.duplicate() var graphics: Dictionary = {} +var active_world_id := "" +var active_world_metadata: Dictionary = {} func _ready() -> void: @@ -204,6 +196,24 @@ func apply_world(config: Dictionary) -> void: world[key] = config.get(key, DEFAULT_WORLD[key]) +func activate_world(metadata: Dictionary) -> bool: + if metadata.is_empty() or typeof(metadata.get("worldgen", null)) != TYPE_DICTIONARY: + return false + active_world_id = String(metadata.get("id", "")) + active_world_metadata = metadata.duplicate(true) + apply_world(metadata["worldgen"]) + return not active_world_id.is_empty() + + +func clear_active_world() -> void: + active_world_id = "" + active_world_metadata.clear() + + +func has_active_world() -> bool: + return not active_world_id.is_empty() + + func get_setting(key: String) -> Variant: return settings.get(key, DEFAULT_SETTINGS.get(key)) @@ -452,6 +462,10 @@ func get_render_distance() -> int: return int(settings.get("render_distance", DEFAULT_SETTINGS["render_distance"])) +func get_lod_mode() -> int: + return clampi(int(settings.get("lod_mode", LOD_MODE_FULL)), LOD_MODE_FULL, LOD_MODE_BALANCED) + + func get_ui_scale() -> float: return clampf(float(settings.get("ui_scale", DEFAULT_SETTINGS["ui_scale"])), UI_SCALE_VALUES[0], UI_SCALE_VALUES[UI_SCALE_VALUES.size() - 1]) diff --git a/game/main.gd b/game/main.gd index 0226738..a713835 100644 --- a/game/main.gd +++ b/game/main.gd @@ -22,6 +22,7 @@ const UNDERWATER_DEEP_COLOR := Color(0.02, 0.08, 0.16) const CAVE_FADE_SECONDS := 0.65 const DYNAMIC_RESOLUTION_INTERVAL := 0.5 const DYNAMIC_RESOLUTION_SMOOTHING := 0.2 +const AUTOSAVE_INTERVAL := 30.0 @onready var world: VoxelWorld = $World @onready var player: Player = $Player @@ -67,12 +68,16 @@ var _hotbar_icon_size := SLOT_ICON var _frame_time := 1.0 / 60.0 var _dynamic_resolution_timer := 0.0 var _dynamic_resolution_active := false +var _world_storage: WorldStorage +var _autosave_time := 0.0 func _ready() -> void: + var saved_state := _prepare_world_storage() UITheme.apply(_hud_root) _style_hud() _apply_config() + world.set_edit_store(_world_storage) _build_hotbar() GameConfig.interface_scale_changed.connect(_apply_hud_text_layout) _build_crosshair() @@ -81,13 +86,25 @@ func _ready() -> void: _inventory_overlay.opened.connect(_on_inventory_opened) _inventory_overlay.closed.connect(_on_inventory_closed) _inventory_overlay.time_selected.connect(_on_inventory_time_selected) - var spawn := world.get_spawn_position() - player.global_position = spawn - player.spawn_position = spawn - world.setup_player(player) - spawn = world.find_safe_spawn(spawn) - player.global_position = spawn - player.spawn_position = spawn + var resumed := not saved_state.is_empty() and player.restore_persistent_state(saved_state.get("player", {})) + if resumed: + world.setup_player(player, true) + # Recover saves written after the player had already entered unloaded or + # incomplete terrain. The synchronous ring above makes this validation + # authoritative without relocating valid cave or airborne saves. + if not world.is_player_volume_clear(player.global_position): + var recovered_spawn := world.find_safe_spawn(player.global_position) + player.global_position = recovered_spawn + player.spawn_position = recovered_spawn + player.velocity = Vector3.ZERO + else: + var spawn := world.get_spawn_position() + player.global_position = spawn + player.spawn_position = spawn + world.setup_player(player) + spawn = world.find_safe_spawn(spawn) + player.global_position = spawn + player.spawn_position = spawn player.setup_world(world) _worldgen_overlay = WorldgenOverlayScene.instantiate() as WorldgenOverlay add_child(_worldgen_overlay) @@ -106,6 +123,7 @@ func _ready() -> void: _weather.lightning.connect(_on_lightning) _weather.ambience_changed.connect(_on_weather_ambience) _inventory_overlay.weather_toggled.connect(_on_weather_toggled) + _restore_session_state(saved_state) player.set_selected_block(HOTBAR[selected_slot]) _update_inventory_display() _show_control_hint() @@ -176,6 +194,7 @@ func _get_shadow_capture_state() -> Dictionary: func _exit_tree() -> void: + _flush_world_save() get_tree().paused = false @@ -207,6 +226,11 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: + if not get_tree().paused: + _autosave_time += delta + if _autosave_time >= AUTOSAVE_INTERVAL: + _autosave_time = 0.0 + _flush_world_save() if status_time > 0.0: status_time -= delta if status_time <= 0.0: @@ -297,11 +321,80 @@ func _update_frame_pacing(delta: float) -> void: func _apply_config() -> void: var render_distance := GameConfig.get_render_distance() - world.configure(GameConfig.world, render_distance) + world.configure(GameConfig.world, render_distance, GameConfig.get_lod_mode()) _apply_graphics() _update_camera_far(render_distance) +func _prepare_world_storage() -> Dictionary: + _world_storage = WorldStorage.new() + var metadata: Dictionary = {} + if GameConfig.has_active_world(): + metadata = _world_storage.open_world(GameConfig.active_world_id) + if metadata.is_empty(): + metadata = _world_storage.create_world(GameConfig.world) + if metadata.is_empty(): + push_warning("World storage is unavailable; continuing without persistence") + _world_storage = null + GameConfig.clear_active_world() + return {} + GameConfig.activate_world(metadata) + return (metadata.get("state", {}) as Dictionary).duplicate(true) + + +func _restore_session_state(state: Dictionary) -> void: + if state.is_empty(): + return + var saved_inventory: Variant = state.get("inventory", []) + if typeof(saved_inventory) == TYPE_ARRAY: + var restored_inventory: Dictionary = {} + for entry in saved_inventory: + if typeof(entry) != TYPE_ARRAY or entry.size() != 2: + continue + var item_id := int(entry[0]) + var count := clampi(int(entry[1]), 0, 9999) + if item_id > 0: + restored_inventory[item_id] = count + inventory = restored_inventory + selected_slot = clampi(int(state.get("selected_slot", 0)), 0, HOTBAR.size() - 1) + var day_state: Variant = state.get("day_night", {}) + if typeof(day_state) == TYPE_DICTIONARY: + _day_night.restore_persistent_state(day_state) + var weather_state: Variant = state.get("weather", {}) + if typeof(weather_state) == TYPE_DICTIONARY: + _weather.restore_persistent_state(weather_state) + + +func _build_persistent_state() -> Dictionary: + var inventory_rows: Array = [] + var item_ids: Array[int] = [] + for key in inventory: + item_ids.append(int(key)) + item_ids.sort() + for item_id in item_ids: + inventory_rows.append([item_id, int(inventory[item_id])]) + return { + "player": player.persistent_state(), + "inventory": inventory_rows, + "selected_slot": selected_slot, + "day_night": _day_night.persistent_state(), + "weather": _weather.persistent_state(), + } + + +func _flush_world_save() -> void: + if _world_storage == null or player == null: + return + var save_error := world.flush_edit_store() + if save_error == OK: + save_error = _world_storage.flush(_build_persistent_state()) + if save_error == OK: + GameConfig.active_world_metadata = _world_storage.metadata.duplicate(true) + else: + push_warning("World save failed with error %d" % save_error) + set_status("Save failed - progress remains in memory") + + func _apply_graphics() -> void: var graphics := GameConfig.get_graphics() _environment.ssao_enabled = bool(graphics["ssao_ssil"]) @@ -687,6 +780,8 @@ func _on_setting_changed(key: String, value: Variant) -> void: var render_distance := int(value) world.set_render_distance(render_distance) _update_camera_far(render_distance) + "lod_mode": + world.set_lod_mode(int(value)) "fov": player.set_fov(float(value)) "graphics_preset": @@ -698,11 +793,14 @@ func _on_setting_changed(key: String, value: Variant) -> void: func _on_new_world() -> void: + _flush_world_save() + GameConfig.clear_active_world() get_tree().paused = false get_tree().change_scene_to_file("res://ui/main_menu.tscn") func _on_quit_game() -> void: + _flush_world_save() GameConfig.save_settings() get_tree().quit() diff --git a/player/player.gd b/player/player.gd index 79341f0..e00771f 100644 --- a/player/player.gd +++ b/player/player.gd @@ -74,6 +74,46 @@ func set_selected_block(block_id: int) -> void: _update_held_block() +func persistent_state() -> Dictionary: + # During scene shutdown children can already be detached when Main performs + # its final save. A detached Node3D has no global transform; Player is a + # direct child of the identity gameplay root, so its local position is the + # correct fallback and avoids get_global_transform() shutdown errors. + var saved_position := global_position if is_inside_tree() else position + return { + "position": [saved_position.x, saved_position.y, saved_position.z], + "yaw": rotation.y, + "pitch": head.rotation.x if head != null else 0.0, + "flying": flying, + "spawn": [spawn_position.x, spawn_position.y, spawn_position.z], + } + + +func restore_persistent_state(state: Dictionary) -> bool: + var position_value: Variant = state.get("position", []) + if typeof(position_value) != TYPE_ARRAY or position_value.size() != 3: + return false + var restored := Vector3(float(position_value[0]), float(position_value[1]), float(position_value[2])) + if not is_finite(restored.x) or not is_finite(restored.y) or not is_finite(restored.z): + return false + global_position = restored + rotation.y = float(state.get("yaw", 0.0)) + if head != null: + head.rotation.x = clampf(float(state.get("pitch", 0.0)), -pitch_limit, pitch_limit) + flying = bool(state.get("flying", false)) + velocity = Vector3.ZERO + var spawn_value: Variant = state.get("spawn", []) + if typeof(spawn_value) == TYPE_ARRAY and spawn_value.size() == 3: + var restored_spawn := Vector3(float(spawn_value[0]), float(spawn_value[1]), float(spawn_value[2])) + if is_finite(restored_spawn.x) and is_finite(restored_spawn.y) and is_finite(restored_spawn.z): + spawn_position = restored_spawn + else: + spawn_position = restored + else: + spawn_position = restored + return true + + ## Detached photo-mode camera: freeze player simulation and hide the targeting ## highlight and the first-person held block so the composition cannot be ## disturbed or the hand model caught in the shot. @@ -126,6 +166,17 @@ func _unhandled_input(event: InputEvent) -> void: func _physics_process(delta: float) -> void: + if global_position.y < FALL_RESET_Y: + global_position = spawn_position + velocity = Vector3.ZERO + _update_target() + # Never simulate movement in a chunk whose collision has not committed yet. + # Extreme streaming and boosted flight can otherwise outrun the nearest-first + # worker queue; a flying player could then descend straight through visible + # terrain while the authoritative collision shape was still being built. + if world != null and not world.is_collision_ready_at(global_position): + velocity = Vector3.ZERO + return var input_vector := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var direction := Vector3(input_vector.x, 0.0, input_vector.y).rotated(Vector3.UP, rotation.y) if direction.length_squared() > 1.0: @@ -151,14 +202,35 @@ func _physics_process(delta: float) -> void: velocity.y = JUMP_VELOCITY else: velocity.y = -0.5 + if world != null: + var intended_position := global_position + velocity * delta + var loaded_fraction := world.loaded_motion_fraction(global_position, intended_position) + if loaded_fraction < 1.0: + var x_fraction := world.loaded_motion_fraction(global_position, + global_position + Vector3(velocity.x * delta, 0.0, 0.0)) + var z_fraction := world.loaded_motion_fraction(global_position, + global_position + Vector3(0.0, 0.0, velocity.z * delta)) + if x_fraction < 1.0 or z_fraction < 1.0: + velocity.x *= x_fraction + velocity.z *= z_fraction + # At an exact corner both cardinal chunks can be ready while the + # diagonal is not. Keep the dominant axis so movement slides along + # the loaded edge instead of entering the missing diagonal chunk. + elif absf(velocity.x) >= absf(velocity.z): + velocity.z = 0.0 + else: + velocity.x = 0.0 + # Entering a rendered chunk is allowed even if its approach collision + # rebuild is one frame behind, but neither gravity nor flight descent may + # spend that frame crossing its uncommitted floor. The next tick pauses all + # movement until collision is ready. + var constrained_position := global_position + velocity * delta + if velocity.y < 0.0 and not world.is_collision_ready_at(constrained_position): + velocity.y = 0.0 move_and_slide() - if global_position.y < FALL_RESET_Y: - global_position = spawn_position - velocity = Vector3.ZERO if camera: var target_fov := base_fov + SPRINT_FOV_BOOST if sprinting and direction.length_squared() > 0.0 else base_fov camera.fov = lerpf(camera.fov, target_fov, clampf(delta * FOV_LERP_SPEED, 0.0, 1.0)) - _update_target() _update_footsteps(delta) diff --git a/tools/chunk_data_compression_verify.gd b/tools/chunk_data_compression_verify.gd new file mode 100644 index 0000000..df9a902 --- /dev/null +++ b/tools/chunk_data_compression_verify.gd @@ -0,0 +1,125 @@ +## Focused checks for distant full-detail chunk palette/RLE storage. +## Run: redot --headless --path . --script res://tools/chunk_data_compression_verify.gd +extends SceneTree + +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var data := _make_varied_data() + var compressed := VoxelWorld.compress_chunk_data(data) + _expect(compressed == VoxelWorld.compress_chunk_data(data), "encoding was not deterministic") + _expect(VoxelWorld.decompress_chunk_data(compressed) == data, "varied block runs did not round-trip") + _expect(compressed.size() < data.size(), "palette/RLE did not reduce representative chunk data") + + var world := VoxelWorld.new() + root.add_child(world) + world._stream_center = Vector2i.ZERO + world.lod_distance = 10 + var remote_pos := Vector2i(7, 0) + var remote := world._create_chunk_nodes(remote_pos) + remote.data = data.duplicate() + remote.heights.resize(VoxelDefs.CHUNK_AREA) + remote.heights[0] = 31 + remote.foliage_tints.resize(VoxelDefs.CHUNK_AREA) + remote.foliage_tints[0] = Color(0.2, 0.7, 0.3) + remote.water_tints.resize(VoxelDefs.CHUNK_AREA) + remote.water_tints[0] = Color(0.1, 0.4, 0.8) + remote.max_y = 31 + world._chunks[remote_pos] = remote + var lod_chunk := VoxelWorld.Chunk.new() + lod_chunk.lod = true + lod_chunk.data = data.duplicate() + world._chunks[Vector2i(8, 0)] = lod_chunk + world._compress_distant_chunks() + _expect(remote.data.is_empty() and not remote.compressed_data.is_empty(), + "distant full-detail chunk was not compacted") + _expect(remote.heights[0] == 31 and remote.foliage_tints[0] == Color(0.2, 0.7, 0.3) \ + and remote.water_tints[0] == Color(0.1, 0.4, 0.8), + "compression changed authoritative heights or tints") + _expect(not lod_chunk.data.is_empty() and lod_chunk.compressed_data.is_empty(), + "LOD chunk was compacted") + print("CHUNK DATA COMPRESSION: raw_bytes=%d compressed_bytes=%d saved_bytes=%d" % [ + data.size(), remote.compressed_data.size(), data.size() - remote.compressed_data.size()]) + + _verify_negative_position(world, data) + _verify_neighbor_snapshot(world, remote_pos, data) + _verify_approach_and_edit(world, remote_pos) + + world.queue_free() + if _failures == 0: + print("CHUNK DATA COMPRESSION VERIFY: PASS") + quit(0) + return + print("CHUNK DATA COMPRESSION VERIFY: FAIL (%d)" % _failures) + quit(1) + + +func _make_varied_data() -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for index in range(192, 768): + data[index] = BlockRegistry.BLOCK_STONE + for index in range(2048, 2176): + data[index] = BlockRegistry.BLOCK_DIRT + for index in range(5000, 5048): + data[index] = BlockRegistry.BLOCK_LOG if index % 3 == 0 else BlockRegistry.BLOCK_LEAVES + for index in range(9000, 9100): + data[index] = BlockRegistry.BLOCK_WATER + return data + + +func _verify_negative_position(world: VoxelWorld, data: PackedByteArray) -> void: + var negative_pos := Vector2i(-8, -7) + var negative := VoxelWorld.Chunk.new() + negative.data = data.duplicate() + world._chunks[negative_pos] = negative + world._compress_distant_chunks() + _expect(negative.data.is_empty() and not negative.compressed_data.is_empty(), + "negative-coordinate full chunk was not compacted") + _expect(world._chunk_for_block(Vector3i(-113, 12, -97)) == negative_pos, + "negative chunk coordinate conversion changed") + _expect(VoxelWorld.decompress_chunk_data(negative.compressed_data) == data, + "negative-coordinate compressed data did not round-trip") + + +func _verify_neighbor_snapshot(world: VoxelWorld, remote_pos: Vector2i, data: PackedByteArray) -> void: + var neighbors := world._gather_neighbors(Vector2i(6, 0)) + var sample: ChunkMesher.NeighborSample = neighbors.get_sample(Vector2i(1, 0)) + _expect(sample != null, "compressed full-detail neighbor was omitted") + _expect(sample != null and sample.data == data, + "neighbor meshing snapshot did not decode immutable full-detail data") + _expect(world._chunks[remote_pos].data.is_empty(), + "neighbor snapshot restored mutable data instead of decoding a copy") + + +func _verify_approach_and_edit(world: VoxelWorld, remote_pos: Vector2i) -> void: + var remote: VoxelWorld.Chunk = world._chunks[remote_pos] + world._stream_center = remote_pos + world._ensure_near_collision() + _expect(not remote.data.is_empty() and remote.compressed_data.is_empty(), + "approaching a collision chunk did not restore mutable data") + var edit_position := Vector3i(remote_pos.x * VoxelDefs.CHUNK_SIZE + 2, 40, 2) + _expect(world.place_block(edit_position, BlockRegistry.BLOCK_STONE), + "edit into restored chunk failed") + _expect(world.get_block_world(edit_position) == BlockRegistry.BLOCK_STONE, + "restored chunk edit was not readable") + world._stream_center = Vector2i.ZERO + world._compress_distant_chunks() + _expect(remote.data.is_empty() and not remote.compressed_data.is_empty(), + "retreated edited chunk was not recompressed") + _expect(world.get_block_world(edit_position) == BlockRegistry.BLOCK_STONE, + "edit did not survive recompression and query restoration") + _expect(world._edited_blocks.get(edit_position, -1) == BlockRegistry.BLOCK_STONE, + "edit persistence changed while chunk data was compacted") + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("chunk_data_compression_verify: %s" % message) diff --git a/tools/chunk_data_compression_verify.gd.uid b/tools/chunk_data_compression_verify.gd.uid new file mode 100644 index 0000000..a7960b2 --- /dev/null +++ b/tools/chunk_data_compression_verify.gd.uid @@ -0,0 +1 @@ +uid://chk7sp0igip38 diff --git a/tools/light_invalidation_verify.gd b/tools/light_invalidation_verify.gd new file mode 100644 index 0000000..b8e8c65 --- /dev/null +++ b/tools/light_invalidation_verify.gd @@ -0,0 +1,122 @@ +extends SceneTree + +## Verifies the selective invalidation predicate used for one-cell edits. +## Batch explosion, fire, and water paths intentionally retain full rings. + +var _failures := 0 + + +func _initialize() -> void: + _verify_enclosed_edits() + _verify_border_edits() + _verify_opaque_swaps() + _verify_attenuation_and_emission_changes() + _verify_negative_coordinates() + _verify_exact_reachable_neighbors() + if _failures == 0: + print("LIGHT INVALIDATION VERIFY: PASS") + else: + print("LIGHT INVALIDATION VERIFY: FAIL (%d checks)" % _failures) + quit(1 if _failures > 0 else 0) + + +func _verify_enclosed_edits() -> void: + var edit := Vector3i(8, 64, 8) + _expect_neighbor(true, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE, edit, + Vector2i(1, 0), "enclosed air/solid reaches cardinal light neighbor") + _expect_neighbor(false, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE, edit, + Vector2i(1, 1), "enclosed air/solid excludes diagonal outside light range") + _expect_neighbor(false, BlockRegistry.BLOCK_WATER, BlockRegistry.BLOCK_LEAVES, edit, + Vector2i(1, 0), "enclosed equal-attenuation edit stays in owner") + + +func _verify_border_edits() -> void: + var edge := Vector3i(15, 64, 8) + _expect_neighbor(true, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_GLASS, edge, + Vector2i(1, 0), "transparent boundary swap preserves direct face visibility") + _expect_neighbor(false, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_GLASS, edge, + Vector2i(0, 1), "transparent edge swap excludes non-touching neighbor") + var corner := Vector3i(15, 64, 15) + _expect_neighbor(true, BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_6, + corner, Vector2i(1, 1), "water level corner swap preserves diagonal water face") + _expect_neighbor(false, BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_6, + corner, Vector2i(-1, -1), "water level corner swap excludes opposite diagonal") + _expect_neighbor(true, BlockRegistry.BLOCK_SEAGRASS, BlockRegistry.BLOCK_AIR, edge, + Vector2i(1, 0), "cross block boundary swap preserves adjacent water faces") + + +func _verify_opaque_swaps() -> void: + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_STONE, BlockRegistry.BLOCK_DIRT), + false, "opaque material swap keeps light attenuation") + _expect_equal(_changes_boundary_visibility(BlockRegistry.BLOCK_STONE, BlockRegistry.BLOCK_DIRT), + false, "opaque material swap keeps boundary faces and AO") + _expect_neighbor(false, BlockRegistry.BLOCK_STONE, BlockRegistry.BLOCK_DIRT, + Vector3i(15, 64, 8), Vector2i(1, 0), "opaque border swap does not rebuild neighbor") + + +func _verify_attenuation_and_emission_changes() -> void: + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE), + true, "air/solid changes attenuation") + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_WATER, BlockRegistry.BLOCK_LEAVES), + false, "water/leaves share non-emissive attenuation") + _expect_equal(_changes_boundary_visibility(BlockRegistry.BLOCK_WATER, BlockRegistry.BLOCK_LEAVES), + true, "water/leaves boundary swap conservatively preserves water faces") + _expect_equal(_changes_boundary_visibility(BlockRegistry.BLOCK_LEAVES, BlockRegistry.BLOCK_SPRUCE_LEAVES), + false, "like-for-like leaves keep boundary face visibility") + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_6), + false, "water flow levels share attenuation") + _expect_equal(_changes_boundary_visibility(BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_6), + true, "water flow level swap preserves face culling") + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_TORCH), + true, "torch emission adds neighbor light") + _expect_equal(_requires_light_neighbor(BlockRegistry.BLOCK_TORCH, BlockRegistry.BLOCK_GLOWSTONE), + true, "torch/glowstone emission color change rebuilds neighbor light") + + +func _verify_negative_coordinates() -> void: + var edit := Vector3i(-1, 64, -1) + _expect_neighbor(true, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE, edit, + Vector2i(0, 0), "negative corner reaches positive diagonal") + _expect_neighbor(false, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE, edit, + Vector2i(-2, -2), "negative corner excludes opposite diagonal") + _expect_neighbor(true, BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_GLASS, edit, + Vector2i(0, -1), "negative boundary detects positive-x geometry neighbor") + + +func _verify_exact_reachable_neighbors() -> void: + var edit := Vector3i(15, 64, 15) + var expected := { + Vector2i(1, 0): true, + Vector2i(0, 1): true, + Vector2i(1, 1): true, + Vector2i(-1, 0): false, + Vector2i(0, -1): false, + Vector2i(-1, -1): false, + } + for candidate in expected: + _expect_neighbor(expected[candidate], BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_STONE, + edit, candidate, "exact reachable neighbor %s" % candidate) + + +func _requires_light_neighbor(old_block_id: int, new_block_id: int) -> bool: + return VoxelWorld._single_edit_requires_light_neighbor_rebuild(old_block_id, new_block_id) + + +func _changes_boundary_visibility(old_block_id: int, new_block_id: int) -> bool: + return VoxelWorld._single_edit_can_change_boundary_visibility(old_block_id, new_block_id) + + +func _expect_neighbor(expected: bool, old_block_id: int, new_block_id: int, + edit: Vector3i, chunk: Vector2i, label: String) -> void: + _expect_equal(VoxelWorld._single_edit_can_invalidate_neighbor(old_block_id, new_block_id, + edit, chunk), expected, label) + + +func _expect_equal(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + _fail("%s: expected %s, got %s" % [label, expected, actual]) + + +func _fail(message: String) -> void: + _failures += 1 + print("LIGHT INVALIDATION VERIFY FAIL: ", message) diff --git a/tools/light_invalidation_verify.gd.uid b/tools/light_invalidation_verify.gd.uid new file mode 100644 index 0000000..20804f7 --- /dev/null +++ b/tools/light_invalidation_verify.gd.uid @@ -0,0 +1 @@ +uid://bvypnx60tgu32 diff --git a/tools/lod_mode_verify.gd b/tools/lod_mode_verify.gd new file mode 100644 index 0000000..3786f96 --- /dev/null +++ b/tools/lod_mode_verify.gd @@ -0,0 +1,89 @@ +extends SceneTree + +const RENDER_DISTANCE := 10 +const MAX_WAIT_TICKS := 240 +const WAIT_TICK := 0.25 + +var _failures := PackedStringArray() + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := VoxelWorld.new() + root.add_child(world) + world.configure({"seed": 918273}, 10, VoxelWorld.LOD_MODE_FULL) + _expect(world.lod_distance == 10, "full-detail mode no longer covers render distance 10") + world.set_lod_mode(VoxelWorld.LOD_MODE_BALANCED) + _expect(world.lod_distance == VoxelWorld.BALANCED_FULL_DETAIL_DISTANCE, + "balanced mode did not cap full detail at 8 chunks") + world.set_render_distance(6) + _expect(world.lod_distance == 6, "balanced mode reduced a short render distance") + world.set_lod_mode(VoxelWorld.LOD_MODE_FULL) + world.set_render_distance(16) + _expect(world.lod_distance == 16, "full-detail mode unexpectedly used compact LOD") + world.set_render_distance(50) + _expect(world.lod_distance == 50, + "full-detail mode unexpectedly enabled LOD at extreme render distance") + world._stream_center = Vector2i.ZERO + world._rebuild_desired() + _expect(world._desired.size() == 101 * 101, + "extreme desired ring did not contain the complete render square") + _expect(not world._gen_queue.is_empty() and world._gen_queue[0] == Vector2i.ZERO, + "extreme generation queue was not built nearest-first") + _expect(not world._chunk_uses_lod(Vector2i(50, 0)), + "extreme full-detail boundary was classified as LOD") + world.set_render_distance(16) + _expect(world.lod_distance == 16, + "full-detail mode changed after leaving extreme render distance") + world.set_lod_mode(VoxelWorld.LOD_MODE_BALANCED) + _expect(world.lod_distance == 8, "live balanced-mode transition did not update policy") + world.set_render_distance(50) + _expect(world.lod_distance == VoxelWorld.BALANCED_FULL_DETAIL_DISTANCE, + "balanced mode did not retain its explicit inner ring at extreme distance") + world.set_render_distance(RENDER_DISTANCE) + var player := Node3D.new() + root.add_child(player) + world.setup_player(player) + await _wait_for_stream(world) + var lod_chunks := 0 + var full_chunks := 0 + for pos in world._chunks: + var chunk: VoxelWorld.Chunk = world._chunks[pos] + var distance := maxi(absi(pos.x), absi(pos.y)) + if distance > VoxelWorld.BALANCED_FULL_DETAIL_DISTANCE: + lod_chunks += 1 + _expect(chunk.lod, "balanced outer chunk was full detail at %s" % pos) + else: + full_chunks += 1 + _expect(not chunk.lod, "balanced inner chunk used LOD at %s" % pos) + _expect(lod_chunks > 0 and full_chunks > 0, "balanced stream did not produce both detail levels") + print("LOD MODE STREAM: full=%d lod=%d" % [full_chunks, lod_chunks]) + if _failures.is_empty(): + print("LOD MODE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("LOD MODE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _wait_for_stream(world: VoxelWorld) -> void: + var expected := (RENDER_DISTANCE * 2 + 1) * (RENDER_DISTANCE * 2 + 1) + var waited := 0 + while (world._chunks.size() < expected or not world._pending.is_empty() \ + or not world._gen_queue.is_empty() or not world._mesh_queue.is_empty() \ + or not world._generated.is_empty() or not world._commit_queue.is_empty()) \ + and waited < MAX_WAIT_TICKS: + await create_timer(WAIT_TICK).timeout + waited += 1 + _expect(world._chunks.size() == expected, "balanced stream timed out at %d/%d chunks" % [ + world._chunks.size(), expected]) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/lod_mode_verify.gd.uid b/tools/lod_mode_verify.gd.uid new file mode 100644 index 0000000..0d4e259 --- /dev/null +++ b/tools/lod_mode_verify.gd.uid @@ -0,0 +1 @@ +uid://dmbhk7afw3ukl diff --git a/tools/player_target_verify.gd b/tools/player_target_verify.gd index e7553fc..ca28e85 100644 --- a/tools/player_target_verify.gd +++ b/tools/player_target_verify.gd @@ -3,17 +3,118 @@ extends Node func _ready() -> void: + var failed := false var world := VoxelWorld.new() var chunk := VoxelWorld.Chunk.new() chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + chunk.shape = CollisionShape3D.new() + chunk.shape.shape = BoxShape3D.new() + world.add_child(chunk.shape) world._chunks[Vector2i.ZERO] = chunk + var east := VoxelWorld.Chunk.new() + # A committed visible chunk can temporarily lack collision while an approach + # rebuild catches up. It is loaded and must not become an invisible wall. + east.shape = CollisionShape3D.new() + world._chunks[Vector2i(1, 0)] = east + var far_collisionless := VoxelWorld.Chunk.new() + far_collisionless.shape = CollisionShape3D.new() + world._chunks[Vector2i(5, 5)] = far_collisionless + world._stream_center = Vector2i.ZERO + world._ensure_near_collision() + if world._mesh_queue.is_empty() or world._mesh_queue[0] != Vector2i(1, 0): + push_error("player_target_verify: nearby collision remesh was not prioritized") + failed = true + world._mesh_queue.clear() + world._mesh_queued.clear() + world._dirty.clear() + var distant_mesh_job := VoxelWorld.PendingJob.new() + distant_mesh_job.kind = "mesh" + distant_mesh_job.want_collision = false + world._pending[Vector2i(1, 0)] = distant_mesh_job + world._ensure_near_collision() + if not world._dirty.has(Vector2i(1, 0)): + push_error("player_target_verify: in-flight visual mesh was not upgraded for nearby collision") + failed = true + world._pending.erase(Vector2i(1, 0)) + world._dirty.clear() + var far_result := ChunkMesher.MeshResult.new() + far_result.build_collision = false + var near_result := ChunkMesher.MeshResult.new() + near_result.build_collision = true + world._commit_queue = [ + VoxelWorld.CommitItem.new(Vector2i(10, 0), far_result, 0, 0, false), + VoxelWorld.CommitItem.new(Vector2i(1, 0), near_result, 0, 0, false), + ] + if world._next_commit_index() != 1: + push_error("player_target_verify: near collision commit was not prioritized") + failed = true + world._commit_queue.clear() + world._touch_chunk(Vector2i.ZERO, Vector3i(15, 3, 8), + BlockRegistry.BLOCK_STONE, BlockRegistry.BLOCK_AIR) + if world._mesh_queue.is_empty() or world._mesh_queue[0] != Vector2i.ZERO: + push_error("player_target_verify: edited owner was queued behind neighbor lighting work") + failed = true + world._mesh_queue.clear() + world._mesh_queued.clear() + world._dirty.clear() _set_block(chunk.data, Vector3i(2, 3, 1), BlockRegistry.BLOCK_WATER) _set_block(chunk.data, Vector3i(2, 3, 2), BlockRegistry.BLOCK_TALL_GRASS) _set_block(chunk.data, Vector3i(2, 3, 3), BlockRegistry.BLOCK_STONE) var player := Player.new() player.world = world + player.position = Vector3(12.5, 70.0, -8.25) + var detached_state := player.persistent_state() + if detached_state.get("position", []) != [12.5, 70.0, -8.25]: + push_error("player_target_verify: detached persistence did not use local position") + failed = true + var blocked_fraction := world.loaded_motion_fraction( + Vector3(8.0, 70.0, 8.0), Vector3(40.0, 70.0, 8.0)) + var blocked_x := 8.0 + 32.0 * blocked_fraction + if blocked_fraction <= 0.0 or blocked_fraction >= 1.0 or floori(blocked_x / 16.0) != 1: + push_error("player_target_verify: stream boundary did not stop before an unloaded chunk") + failed = true + if world.loaded_motion_fraction(Vector3(2.0, 70.0, 2.0), Vector3(24.0, 70.0, 2.0)) != 1.0: + push_error("player_target_verify: stream boundary blocked motion through loaded chunks") + failed = true + var flight_player := Player.new() + var flight_shape := CollisionShape3D.new() + flight_shape.shape = CapsuleShape3D.new() + flight_player.add_child(flight_shape) + var flight_head := Node3D.new() + flight_head.name = "Head" + flight_player.add_child(flight_head) + var flight_camera := Camera3D.new() + flight_camera.name = "Camera3D" + flight_head.add_child(flight_camera) + flight_player._highlight = MeshInstance3D.new() + flight_player.add_child(flight_player._highlight) + add_child(flight_player) + flight_player.set_physics_process(false) + flight_player.world = world + flight_player.flying = true + flight_player.global_position = Vector3(15.75, 70.0, 8.0) + flight_player.velocity = Vector3(10.0, -10.0, 0.0) + flight_player._physics_process(0.1) + if not is_zero_approx(flight_player.velocity.y) \ + or not is_equal_approx(flight_player.global_position.y, 70.0): + push_error("player_target_verify: flight descended while entering collision-pending terrain") + failed = true + flight_player.global_position = Vector3(16.25, 70.0, 8.0) + var waiting_position := flight_player.global_position + flight_player.velocity = Vector3(1.0, -20.0, 0.0) + flight_player._physics_process(0.1) + if not flight_player.global_position.is_equal_approx(waiting_position) \ + or not flight_player.velocity.is_zero_approx(): + push_error("player_target_verify: flying player moved before current chunk collision was ready") + failed = true + var flight_status := [""] + flight_player.status_requested.connect(func(message: String) -> void: flight_status[0] = message) + flight_player._last_jump_time = Time.get_ticks_msec() / 1000.0 + flight_player._handle_double_tap_jump() + if flight_player.flying or flight_status[0] != "Flying disabled": + push_error("player_target_verify: collision loading prevented flight from being disabled safely") + failed = true var result := player._voxel_raycast(Vector3(2.5, 3.5, 0.5), Vector3.FORWARD * -1.0, 6.0) - var failed := false if result.get("block", Vector3i.ZERO) != Vector3i(2, 3, 2): push_error("player_target_verify: ray did not skip water and select the cross plant") failed = true @@ -25,7 +126,10 @@ func _ready() -> void: push_error("player_target_verify: selected cross plant is not breakable") failed = true registry = null + flight_player.free() player.free() + east.shape.free() + far_collisionless.shape.free() world.free() if not failed: print("PLAYER TARGET VERIFY: PASS") diff --git a/tools/stream_full_verify.gd b/tools/stream_full_verify.gd index c6bab2c..fe2d80f 100644 --- a/tools/stream_full_verify.gd +++ b/tools/stream_full_verify.gd @@ -59,13 +59,19 @@ func _run() -> void: var chunk: VoxelWorld.Chunk = world._chunks[pos] var distance := maxi(absi(pos.x), absi(pos.y)) if distance <= VoxelWorld.COLLISION_DISTANCE: - if chunk.shape.shape == null: + if chunk.shape == null or chunk.shape.shape == null or chunk.body == null: _fail("near chunk missing collision at %s" % pos) else: near_shapes += 1 - elif chunk.shape.shape != null: - _fail("distant chunk still holds a collision shape at %s" % pos) + elif chunk.shape != null or chunk.body != null: + _fail("distant chunk still holds collision nodes at %s" % pos) print("STREAM FULL: near_shapes=%d" % near_shapes) + var safe_position := world.find_safe_spawn(Vector3.ZERO) + if not world.is_player_volume_clear(safe_position): + _fail("safe spawn was rejected by player-volume validation") + var buried_position := safe_position - Vector3(0.0, 1.0, 0.0) + if world.is_player_volume_clear(buried_position): + _fail("buried saved position was accepted by player-volume validation") player.global_position = Vector3(RENDER_DISTANCE * 3 * VoxelDefs.CHUNK_SIZE, 0, 0) var center := world._chunk_for_position(player.global_position) @@ -82,14 +88,14 @@ func _run() -> void: or world._commit_queue.size() > 0: continue var chunk: VoxelWorld.Chunk = world._chunks.get(center) - if chunk != null and chunk.shape.shape != null: + if chunk != null and chunk.shape != null and chunk.shape.shape != null: break var center_chunk: VoxelWorld.Chunk = world._chunks.get(center) - if center_chunk == null or center_chunk.shape.shape == null: + if center_chunk == null or center_chunk.shape == null or center_chunk.shape.shape == null: _fail("approached chunk never gained collision at %s" % center) for pos in world._chunks.keys(): var chunk: VoxelWorld.Chunk = world._chunks[pos] - if chunk.shape.shape != null \ + if chunk.shape != null and chunk.shape.shape != null \ and maxi(absi(pos.x - center.x), absi(pos.y - center.y)) > VoxelWorld.COLLISION_DISTANCE + 1: _fail("stale collision shape was not dropped at %s" % pos) diff --git a/tools/stream_soak_verify.gd b/tools/stream_soak_verify.gd new file mode 100644 index 0000000..782e27a --- /dev/null +++ b/tools/stream_soak_verify.gd @@ -0,0 +1,229 @@ +## Bounded streaming/edit soak test for CI. It crosses many non-overlapping +## low-distance stream centers, persists one sparse edit per center, and proves +## that unloaded disk-backed lifecycle state is released before a reload. +## Run: +## redot --headless --path . --script res://tools/stream_soak_verify.gd +extends SceneTree + +const RENDER_DISTANCE := 1 +const STEP_CHUNKS := 4 +const WAIT_TICK := 0.1 +const MAX_WAIT_TICKS := 30 +const FAST_TRAVEL_STEPS := 16 +const FAST_TRAVEL_WAIT := 0.02 +const CONFIG := { + "seed": 24681357, + "world_type": 1, + "tree_density": 0.0, + "decoration_density": 0.0, +} +## Deliberately project-local: this verifier must not touch user:// during CI. +const STORAGE_ROOT := "res://tools/.stream_soak_verify_data" +const WORLD_ID := "stream-soak" + +var _failures := PackedStringArray() + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _cleanup_storage_root() + var storage := WorldStorage.new(STORAGE_ROOT) + _expect(not storage.create_world(CONFIG, {}, WORLD_ID).is_empty(), "could not create soak storage") + var world := VoxelWorld.new() + root.add_child(world) + world.set_edit_store(storage) + world.configure(CONFIG, RENDER_DISTANCE) + var player := Node3D.new() + root.add_child(player) + var centers := _centers() + player.global_position = _center_position(centers[0]) + world.setup_player(player) + await _verify_fast_traversal(world, player) + var expected_edits: Dictionary = {} + var max_lifecycle := 0 + + for index in range(centers.size()): + var center: Vector2i = centers[index] + player.global_position = _center_position(center) + await _wait_settled(world, center, "center %s" % center) + max_lifecycle = maxi(max_lifecycle, _assert_bounded_state(world, center, max_lifecycle)) + var edit := _place_persistent_edit(world, center) + if not edit.is_empty(): + expected_edits[center] = edit + # Let the edit submit its rebuild, then immediately move on. Any worker + # still live is discarded or stale-checked at the next stream center. + await create_timer(0.01).timeout + + _expect(not expected_edits.is_empty(), "no persistent edits could be placed") + var final_center: Vector2i = centers[centers.size() - 1] + await _wait_settled(world, final_center, "final edit") + max_lifecycle = maxi(max_lifecycle, _assert_bounded_state(world, final_center, max_lifecycle)) + _expect(world.flush_edit_store() == OK, "could not flush staged persistent edits") + + var first_center: Vector2i = centers[0] + _expect(not world._chunks.has(first_center), "first center was not unloaded after traversal") + _expect(not world._edits_by_chunk.has(first_center), "unloaded disk-backed edit bucket was retained") + _expect(not world._hydrated_edit_chunks.has(first_center), "unloaded hydration marker was retained") + _expect(not world._chunk_edit_version.has(first_center), "unloaded edit version was retained") + + # A fresh store proves the subsequent replay comes from the persisted region, + # not from the previous World's cache or the old hydrated edit dictionary. + var reopened := WorldStorage.new(STORAGE_ROOT) + _expect(not reopened.open_world(WORLD_ID).is_empty(), "could not reopen flushed soak storage") + world.set_edit_store(reopened) + player.global_position = _center_position(first_center) + await _wait_settled(world, first_center, "persisted reload") + if expected_edits.has(first_center): + var edit: Dictionary = expected_edits[first_center] + _expect(world.get_block_world(edit["position"]) == edit["id"], + "persisted edit did not reload at %s" % edit["position"]) + _assert_bounded_state(world, first_center, max_lifecycle) + + print("STREAM SOAK: centers=%d edits=%d max_lifecycle=%d chunks=%d" % [ + centers.size(), expected_edits.size(), max_lifecycle, world._chunks.size(), + ]) + player.queue_free() + world.queue_free() + await process_frame + _cleanup_storage_root() + if _failures.is_empty(): + print("STREAM SOAK VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error("stream_soak_verify: %s" % failure) + print("STREAM SOAK VERIFY: FAIL (%d)" % _failures.size()) + quit(1) + + +func _centers() -> Array[Vector2i]: + return [ + Vector2i(0, 0), Vector2i(STEP_CHUNKS, 0), Vector2i(STEP_CHUNKS * 2, 0), + Vector2i(STEP_CHUNKS * 2, STEP_CHUNKS), Vector2i(STEP_CHUNKS, STEP_CHUNKS), + Vector2i(0, STEP_CHUNKS), Vector2i(-STEP_CHUNKS, STEP_CHUNKS), + Vector2i(-STEP_CHUNKS * 2, STEP_CHUNKS), Vector2i(-STEP_CHUNKS * 2, 0), + Vector2i(-STEP_CHUNKS * 2, -STEP_CHUNKS), Vector2i(-STEP_CHUNKS, -STEP_CHUNKS), + Vector2i(0, -STEP_CHUNKS), + ] + + +func _center_position(center: Vector2i) -> Vector3: + return Vector3( + float(center.x * VoxelDefs.CHUNK_SIZE + VoxelDefs.CHUNK_SIZE / 2), 96.0, + float(center.y * VoxelDefs.CHUNK_SIZE + VoxelDefs.CHUNK_SIZE / 2) + ) + + +func _wait_settled(world: VoxelWorld, center: Vector2i, label: String) -> void: + for tick in MAX_WAIT_TICKS: + await create_timer(WAIT_TICK).timeout + if world._stream_center != center or not _stream_queues_settled(world): + continue + return + _fail("%s did not settle (chunks=%d pending=%d gen=%d mesh=%d staged=%d commits=%d)" % [ + label, world._chunks.size(), world._pending.size(), world._gen_queue.size(), + world._mesh_queue.size(), world._generated.size(), world._commit_queue.size(), + ]) + + +func _verify_fast_traversal(world: VoxelWorld, player: Node3D) -> void: + var peak_pending := 0 + var peak_stale_pending := 0 + for step in range(1, FAST_TRAVEL_STEPS + 1): + var center := Vector2i(step, 0) + player.global_position = _center_position(center) + await create_timer(FAST_TRAVEL_WAIT).timeout + peak_pending = maxi(peak_pending, world._pending.size()) + var stale_pending := 0 + for pos in world._pending: + if not world._desired.has(pos): + stale_pending += 1 + peak_stale_pending = maxi(peak_stale_pending, stale_pending) + _expect(world._pending.size() <= world._max_active_jobs + 1, + "fast traversal exceeded the worker bound plus urgent collision slot") + var final_center := Vector2i(FAST_TRAVEL_STEPS, 0) + await _wait_settled(world, final_center, "fast traversal recovery") + _expect(world.is_collision_ready_at(player.global_position), + "fast traversal did not recover collision at the final center") + print("STREAM SOAK FLIGHT: steps=%d peak_pending=%d peak_stale=%d" % [ + FAST_TRAVEL_STEPS, peak_pending, peak_stale_pending, + ]) + + +func _stream_queues_settled(world: VoxelWorld) -> bool: + return world._pending.is_empty() and world._gen_queue.is_empty() \ + and world._mesh_queue.is_empty() and world._generated.is_empty() \ + and world._commit_queue.is_empty() + + +func _place_persistent_edit(world: VoxelWorld, center: Vector2i) -> Dictionary: + var chunk: VoxelWorld.Chunk = world._chunks.get(center) + if chunk == null or chunk.lod: + _fail("no full chunk available for persistent edit at %s" % center) + return {} + var local := VoxelDefs.CHUNK_SIZE / 2 + var column := local + local * VoxelDefs.DATA_STRIDE_Z + var position := Vector3i( + center.x * VoxelDefs.CHUNK_SIZE + local, + mini(chunk.heights[column] + 1, VoxelDefs.WORLD_HEIGHT - 1), + center.y * VoxelDefs.CHUNK_SIZE + local, + ) + if not world.place_block(position, BlockRegistry.BLOCK_TORCH): + _fail("could not place persistent edit at %s" % position) + return {} + return {"position": position, "id": BlockRegistry.BLOCK_TORCH} + + +func _assert_bounded_state(world: VoxelWorld, center: Vector2i, previous_max: int) -> int: + var resident_limit := (world.unload_radius * 2 + 1) * (world.unload_radius * 2 + 1) + var lifecycle_size := world._dirty.size() + world._generated.size() \ + + world._chunk_edit_version.size() + world._edits_by_chunk.size() \ + + world._hydrated_edit_chunks.size() + _expect(world._chunks.size() <= resident_limit, + "resident chunks exceeded bound at %s: %d > %d" % [center, world._chunks.size(), resident_limit]) + _expect(world._chunk_edit_version.size() <= world._chunks.size(), + "edit versions outgrew loaded chunks at %s" % center) + _expect(world._edits_by_chunk.size() <= world._chunks.size(), + "disk edit buckets outgrew loaded chunks at %s" % center) + _expect(world._hydrated_edit_chunks.size() <= world._chunks.size(), + "hydration markers outgrew loaded chunks at %s" % center) + _expect(lifecycle_size <= world._chunks.size() * 3, + "lifecycle state outgrew resident chunks at %s: %d for %d chunks" % [ + center, lifecycle_size, world._chunks.size(), + ]) + return maxi(previous_max, lifecycle_size) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _fail(message) + + +func _fail(message: String) -> void: + _failures.append(message) + + +## This verifier owns a hidden, project-local fixture directory. Clearing it +## keeps repeat runs deterministic without touching user:// or game saves. +func _cleanup_storage_root() -> void: + _remove_tree(ProjectSettings.globalize_path(STORAGE_ROOT)) + + +func _remove_tree(path: String) -> void: + var directory := DirAccess.open(path) + if directory == null: + return + directory.list_dir_begin() + var name := directory.get_next() + while not name.is_empty(): + if name != "." and name != "..": + var child := path.path_join(name) + if directory.current_is_dir(): + _remove_tree(child) + DirAccess.remove_absolute(child) + name = directory.get_next() + directory.list_dir_end() + DirAccess.remove_absolute(path) diff --git a/tools/stream_soak_verify.gd.uid b/tools/stream_soak_verify.gd.uid new file mode 100644 index 0000000..fa73f25 --- /dev/null +++ b/tools/stream_soak_verify.gd.uid @@ -0,0 +1 @@ +uid://c7mavy45wvgs diff --git a/tools/ui_flow_verify.gd b/tools/ui_flow_verify.gd index 3a88245..8139216 100644 --- a/tools/ui_flow_verify.gd +++ b/tools/ui_flow_verify.gd @@ -9,6 +9,7 @@ extends SceneTree var _failures := 0 +var _library_root := "user://ui_flow_verify_%d" % Time.get_ticks_usec() func _initialize() -> void: @@ -16,6 +17,7 @@ func _initialize() -> void: func _run() -> void: + var game_config: Node = root.get_node("GameConfig") var menu: Node = load("res://ui/main_menu.tscn").instantiate() root.add_child(menu) await process_frame @@ -26,9 +28,14 @@ func _run() -> void: var play: Node = menu.get_node("PlayPanel") var worldgen: Node = menu.get_node("WorldGenPanel") + play._library_root = _library_root menu._on_play() await process_frame - _expect(play.visible, "Play did not open the world setup screen") + _expect(play.visible, "Play did not open the worlds hub") + _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/LandingBox/NewWorldButton"), + "worlds hub did not focus New World for an empty library") + play.get_node("Center/Panel/Box/LandingBox/NewWorldButton").pressed.emit() + await process_frame _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/SeedRow/SeedField"), "world setup did not focus the seed field") _expect(play.get_node("Center/Panel/Box/TypeRow/TypeOption").item_count == 3, "world type list is incomplete") @@ -46,6 +53,91 @@ func _run() -> void: for key in ["terrain_scale", "tree_density", "worldgen_version", "macro_scale", "biome_scale", "river_density", "erosion_strength", "regional_erosion", "hydraulic_erosion", "cave_density", "decoration_density"]: _expect(config.has(key), "advanced config is missing %s" % key) + # Saved-world library: compatible worlds load, future worlds remain + # selectable for deletion, confirmation unwinds first, and deleting the + # active final world clears both active state and the list. + play.get_node("Center/Panel/Box/Footer/BackButton").pressed.emit() + await process_frame + _create_world_fixture("alpha", "Alpha Ridge", 101, 100, false) + _create_world_fixture("beta", "Beta Shore", 202, 200, false) + _create_world_fixture("future", "Future Keep", 303, 300, true) + play._refresh_landing() + _expect(play.get_node("Center/Panel/Box/LandingBox/LandingHint").text.begins_with("3 saved worlds"), + "worlds hub did not count saved worlds") + play.get_node("Center/Panel/Box/LandingBox/LoadWorldButton").pressed.emit() + await process_frame + _expect(play._cards.size() == 3, "load view did not list every saved world") + var future_card: Button = play._cards.get("future") + _expect(future_card != null and not future_card.disabled, + "incompatible world cannot be selected for deletion") + if future_card != null: + future_card.pressed.emit() + _expect(play._selected_world_id == "future", "incompatible world was not selectable") + _expect(play.get_node("Center/Panel/Box/LoadBox/LoadFooter/LoadButton").disabled, + "incompatible world incorrectly enabled loading") + _expect(not play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteButton").disabled, + "incompatible world did not enable deletion") + + # Disconnect the menu's scene-changing handler while verifying the panel's + # public signal in isolation. + play.load_requested.disconnect(menu._on_load_world) + var loaded_ids: Array[String] = [] + play.load_requested.connect(func(id: String) -> void: loaded_ids.append(id)) + var beta_card: Button = play._cards.get("beta") + if beta_card != null: + beta_card.pressed.emit() + play.get_node("Center/Panel/Box/LoadBox/LoadFooter/LoadButton").pressed.emit() + _expect(loaded_ids == ["beta"], "selected compatible world did not emit load_requested") + + var beta_metadata: Dictionary = WorldStorage.new(_library_root).open_world("beta") + _expect(game_config.activate_world(beta_metadata), "could not activate deletion fixture") + play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteButton").pressed.emit() + await process_frame + _expect(play._confirming_delete, "delete did not open confirmation") + _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/LoadBox/ConfirmRow/CancelDeleteButton"), + "delete confirmation did not focus the safe choice") + _send_cancel() + await process_frame + _expect(not play._confirming_delete, "cancel did not close delete confirmation first") + _expect(play._selected_world_id == "beta", "canceling deletion lost the world selection") + play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteButton").pressed.emit() + play.get_node("Center/Panel/Box/LoadBox/ConfirmRow/ConfirmDeleteButton").pressed.emit() + await process_frame + _expect(String(game_config.active_world_id).is_empty(), "deleting the active world did not clear active state") + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(_library_root + "/beta")), + "confirmed deletion left the world directory behind") + + var alpha_metadata: Dictionary = WorldStorage.new(_library_root).open_world("alpha") + _expect(game_config.activate_world(alpha_metadata), "could not activate bulk-deletion fixture") + var delete_all_button: Button = play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteAllButton") + _expect(not delete_all_button.disabled, "Delete All was disabled with saved worlds present") + delete_all_button.pressed.emit() + await process_frame + _expect(play._confirming_delete and play._confirming_delete_all, + "Delete All did not open bulk confirmation") + _expect(play.get_node("Center/Panel/Box/LoadBox/ConfirmRow/ConfirmLabel").text.begins_with("Delete all 2 saved worlds"), + "Delete All confirmation did not report the affected world count") + _send_cancel() + await process_frame + _expect(not play._confirming_delete and play._cards.size() == 2, + "canceling Delete All removed worlds or left confirmation open") + _expect(root.gui_get_focus_owner() == delete_all_button, + "canceling Delete All did not restore focus") + delete_all_button.pressed.emit() + play.get_node("Center/Panel/Box/LoadBox/ConfirmRow/ConfirmDeleteButton").pressed.emit() + await process_frame + _expect(play._cards.is_empty(), "deleting every world did not empty the library") + _expect(String(game_config.active_world_id).is_empty(), "Delete All did not clear the active world") + _expect(delete_all_button.disabled, "Delete All remained enabled for an empty library") + _expect(play.get_node("Center/Panel/Box/LoadBox/LoadScroll/EmptyState").visible, + "empty-library state was not shown") + _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/LoadBox/LoadScroll/EmptyState/EmptyCreateButton"), + "empty-library state did not focus Create") + _send_cancel() + await process_frame + _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/LandingBox/NewWorldButton"), + "returning from an empty load view did not focus an enabled landing action") + play.close_panel() await process_frame _expect(root.gui_get_focus_owner() == column.get_node("PlayButton"), "play screen did not restore focus") @@ -109,6 +201,22 @@ func _send_cancel() -> void: Input.parse_input_event(event) +func _create_world_fixture(id: String, display_name: String, seed: int, updated: int, future: bool) -> void: + var storage := WorldStorage.new(_library_root) + var config := WorldGenConfig.new({"seed": seed}).to_dictionary() + var metadata: Dictionary = storage.create_world(config, {}, id) + metadata["name"] = display_name + metadata["created_unix"] = updated - 10 + metadata["updated_unix"] = updated + if future: + (metadata["worldgen"] as Dictionary)["worldgen_version"] = WorldGenConfig.CURRENT_VERSION + 1 + var file := FileAccess.open(_library_root + "/" + id + "/metadata.json", FileAccess.WRITE) + if file == null: + _expect(false, "could not write saved-world UI fixture %s" % id) + return + file.store_string(JSON.stringify(metadata, "\t")) + + func _expect(condition: bool, message: String) -> void: if condition: return diff --git a/tools/world_storage_verify.gd b/tools/world_storage_verify.gd new file mode 100644 index 0000000..212fb09 --- /dev/null +++ b/tools/world_storage_verify.gd @@ -0,0 +1,340 @@ +extends SceneTree + +var _failures := PackedStringArray() +var _root := "user://world_storage_verify_%d" % Time.get_ticks_usec() + + +func _initialize() -> void: + _verify_v1_compatibility_fixture() + _verify_round_trip_and_regions() + _verify_future_metadata_rejection() + _verify_library_listing_and_delete() + _verify_v2_compression_round_trip_and_corruption() + _verify_generation_edit_priority() + if _failures.is_empty(): + print("WORLD STORAGE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLD STORAGE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_v1_compatibility_fixture() -> void: + var storage := WorldStorage.new(_root) + var config := WorldGenConfig.new({"seed": -10101}).to_dictionary() + _expect(not storage.create_world(config, {}, "legacy-v1-world").is_empty(), + "could not create v1 compatibility world") + var legacy_path := _root + "/legacy-v1-world/regions/r.-1.-1.rcregion" + var fixture := FileAccess.open_compressed(legacy_path, FileAccess.WRITE, FileAccess.COMPRESSION_ZSTD) + if fixture == null: + _expect(false, "could not write handcrafted v1 fixture") + return + fixture.store_buffer(WorldStorage.REGION_MAGIC.to_utf8_buffer()) + fixture.store_16(WorldStorage.REGION_VERSION_LEGACY) + fixture.store_32(2) + fixture.store_32(-1) + fixture.store_32(-1) + fixture.store_32(2) + fixture.store_32(-1) + fixture.store_16(10) + fixture.store_32(-1) + fixture.store_8(BlockRegistry.BLOCK_AIR) + fixture.store_32(-16) + fixture.store_16(55) + fixture.store_32(-16) + fixture.store_8(BlockRegistry.BLOCK_TORCH) + fixture.store_32(-2) + fixture.store_32(-1) + fixture.store_32(1) + fixture.store_32(-17) + fixture.store_16(33) + fixture.store_32(-2) + fixture.store_8(BlockRegistry.BLOCK_WATER) + fixture.flush() + fixture = null + var reopened := WorldStorage.new(_root) + _expect(not reopened.open_world("legacy-v1-world").is_empty(), "could not reopen v1 fixture world") + _expect(reopened.load_chunk_edits(Vector2i(-1, -1)) == { + Vector3i(-1, 10, -1): BlockRegistry.BLOCK_AIR, + Vector3i(-16, 55, -16): BlockRegistry.BLOCK_TORCH, + }, "handcrafted v1 negative-coordinate edits did not load") + _expect(reopened.load_chunk_edits(Vector2i(-2, -1)) == { + Vector3i(-17, 33, -2): BlockRegistry.BLOCK_WATER, + }, "handcrafted v1 second chunk did not load") + print("WORLD STORAGE V1: handcrafted_fixture=true negative_coordinates=true") + + +func _verify_round_trip_and_regions() -> void: + var config := WorldGenConfig.new({"seed": -918273, "world_type": 2, "terrain_scale": 1.25}).to_dictionary() + var initial_state := { + "player": {"position": [-17.5, 71.0, -2.5], "flying": true}, + "inventory": [[1, 23], [27, 4]], + "selected_slot": 1, + } + var storage := WorldStorage.new(_root) + var metadata := storage.create_world(config, initial_state, "verify-world") + _expect(not metadata.is_empty(), "could not create verification world") + _expect(metadata.get("worldgen", {}) == config, "worldgen metadata did not round-trip on creation") + + var chunk_a := Vector2i(-1, -1) + var chunk_b := Vector2i(-2, -1) + var chunk_c := Vector2i(0, 0) + var edits_a := { + Vector3i(-1, 10, -1): BlockRegistry.BLOCK_AIR, + Vector3i(-16, 55, -16): BlockRegistry.BLOCK_TORCH, + } + var edits_b := {Vector3i(-17, 33, -2): BlockRegistry.BLOCK_WATER} + var edits_c := {Vector3i(0, 1, 0): BlockRegistry.BLOCK_STONE} + storage.stage_chunk_edits(chunk_a, edits_a) + storage.stage_chunk_edits(chunk_b, edits_b) + storage.stage_chunk_edits(chunk_c, edits_c) + _expect(storage.flush(initial_state) == OK, "first world flush failed") + + var reopened := WorldStorage.new(_root) + var loaded_metadata := reopened.open_world("verify-world") + _expect(_equivalent(loaded_metadata.get("worldgen", {}), config), "frozen worldgen config changed after reopen") + _expect(_equivalent(loaded_metadata.get("state", {}), initial_state), "gameplay metadata changed after reopen") + _expect(reopened.load_chunk_edits(chunk_a) == edits_a, "negative chunk A edits changed after reopen") + _expect(reopened.load_chunk_edits(chunk_b) == edits_b, "same-region chunk B edits were lost") + _expect(reopened.load_chunk_edits(chunk_c) == edits_c, "positive-region chunk edits were lost") + + # Rewriting identical, sorted content must produce identical compressed bytes. + var negative_region_path := _root + "/verify-world/regions/r.-1.-1.rcregion" + var before := FileAccess.get_file_as_bytes(negative_region_path) + reopened.stage_chunk_edits(chunk_b, edits_b) + reopened.stage_chunk_edits(chunk_a, edits_a) + _expect(reopened.flush(initial_state) == OK, "deterministic rewrite flush failed") + var after := FileAccess.get_file_as_bytes(negative_region_path) + _expect(before == after, "region output changed when identical edits were staged in another order") + + # A corrupt primary must recover the prior valid region from .bak. + var corrupt := FileAccess.open(negative_region_path, FileAccess.WRITE) + if corrupt != null: + corrupt.store_string("not a compressed region") + corrupt = null + var recovered := WorldStorage.new(_root) + _expect(not recovered.open_world("verify-world").is_empty(), "metadata failed while testing region backup") + _expect(recovered.load_chunk_edits(chunk_a) == edits_a, "corrupt primary did not recover chunk A from backup") + _expect(recovered.load_chunk_edits(chunk_b) == edits_b, "corrupt primary did not recover chunk B from backup") + recovered.stage_chunk_edits(chunk_a, edits_a) + _expect(recovered.flush(initial_state) == OK, "recovered region repair flush failed") + _expect(FileAccess.file_exists(negative_region_path + ".bak"), + "repair discarded the only valid region backup") + corrupt = FileAccess.open(negative_region_path, FileAccess.WRITE) + if corrupt != null: + corrupt.store_string("corrupt after repair") + corrupt = null + var recovered_twice := WorldStorage.new(_root) + recovered_twice.open_world("verify-world") + _expect(recovered_twice.load_chunk_edits(chunk_a) == edits_a, + "preserved region backup could not recover a second corrupt primary") + + # A future/corrupt region with no backup is rejected rather than partially read. + var backup_path := negative_region_path + ".bak" + if FileAccess.file_exists(backup_path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(backup_path)) + var future := FileAccess.open_compressed(negative_region_path, FileAccess.WRITE, FileAccess.COMPRESSION_ZSTD) + if future != null: + future.store_buffer(WorldStorage.REGION_MAGIC.to_utf8_buffer()) + future.store_16(WorldStorage.REGION_VERSION + 1) + future.store_32(0) + future = null + var rejected := WorldStorage.new(_root) + rejected.open_world("verify-world") + _expect(rejected.load_chunk_edits(chunk_a).is_empty(), "future region version was not rejected") + + # Metadata also recovers from a syntactically valid but structurally corrupt primary. + var metadata_path := _root + "/verify-world/metadata.json" + var invalid_metadata := FileAccess.open(metadata_path, FileAccess.WRITE) + if invalid_metadata != null: + invalid_metadata.store_string(JSON.stringify({"magic": "wrong-but-valid-json"})) + invalid_metadata = null + var metadata_recovery := WorldStorage.new(_root).open_world("verify-world") + _expect(not metadata_recovery.is_empty(), "invalid metadata primary did not recover from backup") + print("WORLD STORAGE REGIONS: negative=true same_region=true deterministic=true backup=true") + + +func _verify_future_metadata_rejection() -> void: + var storage := WorldStorage.new(_root) + var config := WorldGenConfig.new({"seed": 42}).to_dictionary() + var metadata := storage.create_world(config, {}, "future-world") + metadata["format_version"] = WorldStorage.METADATA_VERSION + 1 + var path := _root + "/future-world/metadata.json" + var file := FileAccess.open(path, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(metadata)) + file = null + _expect(WorldStorage.new(_root).open_world("future-world").is_empty(), "future metadata format was accepted") + var invalid_storage := WorldStorage.new(_root) + invalid_storage.create_world(config, {}, "invalid-block-world") + invalid_storage.stage_chunk_edits(Vector2i.ZERO, {Vector3i.ZERO: 255}) + _expect(invalid_storage.flush_dirty_regions() == ERR_INVALID_DATA, "unknown block ID was written to a region") + var invalid_position_storage := WorldStorage.new(_root) + invalid_position_storage.create_world(config, {}, "invalid-position-world") + invalid_position_storage.stage_chunk_edits(Vector2i.ZERO, { + Vector3i(VoxelDefs.CHUNK_SIZE, 1, 0): BlockRegistry.BLOCK_STONE, + }) + _expect(invalid_position_storage.flush_dirty_regions() == ERR_INVALID_DATA, + "edit outside its staged chunk was written to a region") + + +func _verify_library_listing_and_delete() -> void: + var summaries := WorldStorage.list_world_summaries(_root) + var by_id: Dictionary = {} + for summary in summaries: + by_id[String(summary.get("id", ""))] = summary + _expect(by_id.has("verify-world"), "saved-world library omitted a valid world") + _expect(by_id.has("future-world") and not bool((by_id["future-world"] as Dictionary).get("compatible", true)), + "saved-world library did not retain an incompatible world for deletion") + var storage := WorldStorage.new(_root) + _expect(not storage.create_world({"seed": 777}, {}, "delete-me").is_empty(), + "could not create deletion fixture") + _expect(not WorldStorage.delete_world("../delete-me", _root), + "world deletion accepted a traversal id") + _expect(WorldStorage.delete_world("delete-me", _root), "valid world deletion failed") + _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(_root + "/delete-me")), + "deleted world directory remains on disk") + _expect(not FileAccess.file_exists(_root + "/" + WorldStorage.LAST_WORLD_FILE), + "deleting the last-played world left a stale pointer") + + +func _verify_v2_compression_round_trip_and_corruption() -> void: + var storage := WorldStorage.new(_root) + var config := WorldGenConfig.new({"seed": 13579}).to_dictionary() + storage.create_world(config, {}, "cache-world") + for region_x in WorldStorage.MAX_CACHED_REGIONS + 5: + storage.load_chunk_edits(Vector2i(region_x * WorldStorage.REGION_CHUNKS, 0)) + _expect(storage._regions.size() <= WorldStorage.MAX_CACHED_REGIONS, + "clean region cache exceeded its bound") + _expect(storage._loaded_regions.size() <= WorldStorage.MAX_CACHED_REGIONS, + "loaded-region markers were not evicted with cached regions") + + for region_x in WorldStorage.MAX_CACHED_REGIONS + 1: + var chunk_pos := Vector2i(region_x * WorldStorage.REGION_CHUNKS, 0) + storage.stage_chunk_edits(chunk_pos, { + Vector3i(chunk_pos.x * VoxelDefs.CHUNK_SIZE, 1, 0): BlockRegistry.BLOCK_STONE, + }) + _expect(storage._dirty_regions.size() == WorldStorage.MAX_CACHED_REGIONS + 1, + "dirty regions were evicted before flushing") + _expect(storage.flush_dirty_regions() == OK, "multi-region cache flush failed") + _expect(storage._regions.size() <= WorldStorage.MAX_CACHED_REGIONS, + "clean cache was not trimmed after flushing") + storage.create_world(config, {}, "cache-reset-world") + _expect(storage._regions.is_empty() and storage._loaded_regions.is_empty(), + "creating another world retained stale region cache entries") + + var dense_edits: Dictionary = {} + for y in 64: + for z in VoxelDefs.CHUNK_SIZE: + for x in VoxelDefs.CHUNK_SIZE: + dense_edits[Vector3i(x, y, z)] = BlockRegistry.BLOCK_STONE + storage.stage_chunk_edits(Vector2i.ZERO, dense_edits) + _expect(storage.flush_dirty_regions() == OK, "dense compression fixture flush failed") + var region_path := _root + "/cache-reset-world/regions/r.0.0.rcregion" + var compressed_size := FileAccess.get_file_as_bytes(region_path).size() + var raw_record_size := dense_edits.size() * 11 + _expect(compressed_size > 0 and compressed_size < raw_record_size, + "v2 compressed region was not smaller than its raw v1 edit records") + var compressed := FileAccess.open_compressed(region_path, FileAccess.READ, FileAccess.COMPRESSION_ZSTD) + if compressed == null: + _expect(false, "could not read v2 compression fixture") + else: + _expect(compressed.get_buffer(4).get_string_from_utf8() == WorldStorage.REGION_MAGIC + and compressed.get_16() == WorldStorage.REGION_VERSION, + "dense fixture was not written using the v2 region format") + var chunk_count := compressed.get_32() + var chunk_x := compressed.get_32() + var chunk_z := compressed.get_32() + var palette_count := compressed.get_16() + var palette_block := compressed.get_8() + var edit_count := compressed.get_32() + var run_count := compressed.get_32() + var delta := compressed.get_32() + var run_length := compressed.get_16() + var palette_index := compressed.get_16() + _expect(chunk_count == 1 and chunk_x == 0 and chunk_z == 0 and palette_count == 1 \ + and palette_block == BlockRegistry.BLOCK_STONE and edit_count == dense_edits.size() \ + and run_count == 1 and delta == 0 and run_length == dense_edits.size() \ + and palette_index == 0 and compressed.get_position() == compressed.get_length(), + "v2 dense fixture did not use the expected palette plus delta-RLE record") + compressed = null + var reopened := WorldStorage.new(_root) + _expect(not reopened.open_world("cache-reset-world").is_empty(), "could not reopen v2 compression fixture") + _expect(reopened.load_chunk_edits(Vector2i.ZERO) == dense_edits, + "v2 palette and delta-RLE data did not round-trip") + + var corrupt_storage := WorldStorage.new(_root) + _expect(not corrupt_storage.create_world(config, {}, "corrupt-v2-world").is_empty(), + "could not create v2 corruption world") + var corrupt_path := _root + "/corrupt-v2-world/regions/r.0.0.rcregion" + var corrupt := FileAccess.open_compressed(corrupt_path, FileAccess.WRITE, FileAccess.COMPRESSION_ZSTD) + if corrupt == null: + _expect(false, "could not write v2 corruption fixture") + else: + corrupt.store_buffer(WorldStorage.REGION_MAGIC.to_utf8_buffer()) + corrupt.store_16(WorldStorage.REGION_VERSION) + corrupt.store_32(1) + corrupt.store_32(0) + corrupt.store_32(0) + corrupt.store_16(1) + corrupt.store_8(BlockRegistry.BLOCK_STONE) + corrupt.store_32(1) + corrupt.store_32(1) + corrupt.store_32(0) + corrupt.store_16(1) + corrupt.store_16(1) # Palette index 1 is outside the one-entry palette. + corrupt.flush() + corrupt = null + var rejected := WorldStorage.new(_root) + rejected.open_world("corrupt-v2-world") + _expect(rejected.load_chunk_edits(Vector2i.ZERO).is_empty(), + "corrupt v2 palette index was partially decoded") + print("WORLD STORAGE V2: cache_max=%d compressed=%d raw_v1_records=%d round_trip=true corruption_rejected=true" % [ + WorldStorage.MAX_CACHED_REGIONS, compressed_size, raw_record_size]) + + +func _verify_generation_edit_priority() -> void: + var generator := TerrainGenerator.new() + generator.configure({"seed": 77123, "tree_density": 0.0, "decoration_density": 0.0}) + var forced := Vector3i(3, 100, 4) + var removed := Vector3i(5, 1, 6) + var result := generator.generate_data(Vector2i.ZERO, { + forced: BlockRegistry.BLOCK_TORCH, + removed: BlockRegistry.BLOCK_AIR, + }, false) + _expect(_block(result.data, forced) == BlockRegistry.BLOCK_TORCH, "loaded edit did not override generated air/terrain") + _expect(_block(result.data, removed) == BlockRegistry.BLOCK_AIR, "loaded AIR edit did not override generated terrain") + + +func _block(data: PackedByteArray, position: Vector3i) -> int: + return data[position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y] + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) + + +func _equivalent(left: Variant, right: Variant) -> bool: + if (typeof(left) == TYPE_INT or typeof(left) == TYPE_FLOAT) \ + and (typeof(right) == TYPE_INT or typeof(right) == TYPE_FLOAT): + return is_equal_approx(float(left), float(right)) + if typeof(left) != typeof(right): + return false + if typeof(left) == TYPE_DICTIONARY: + if left.size() != right.size(): + return false + for key in left: + if not right.has(key) or not _equivalent(left[key], right[key]): + return false + return true + if typeof(left) == TYPE_ARRAY: + if left.size() != right.size(): + return false + for index in left.size(): + if not _equivalent(left[index], right[index]): + return false + return true + return left == right diff --git a/tools/world_storage_verify.gd.uid b/tools/world_storage_verify.gd.uid new file mode 100644 index 0000000..cc39bba --- /dev/null +++ b/tools/world_storage_verify.gd.uid @@ -0,0 +1 @@ +uid://ckxuii1xumiak diff --git a/tools/worldgen_audit_verify.gd b/tools/worldgen_audit_verify.gd new file mode 100644 index 0000000..b59bdbc --- /dev/null +++ b/tools/worldgen_audit_verify.gd @@ -0,0 +1,224 @@ +## Focused regression coverage for versioned world-generation audit fixes. +extends SceneTree + +const DecorationCatalogScript = preload("res://world/worldgen/decoration_catalog.gd") +const VoxelPopulatorScript = preload("res://world/worldgen/voxel_populator.gd") +const WorldGenConfigScript = preload("res://world/worldgen/world_gen_config.gd") +const BiomeCatalogScript = preload("res://world/worldgen/biome_catalog.gd") +const ChunkTerrainDataScript = preload("res://world/worldgen/chunk_terrain_data.gd") +const BlockRegistryScript = preload("res://world/block_registry.gd") +const VoxelDefsScript = preload("res://world/voxel_defs.gd") + +const OCEAN_ORIGIN_SEED: int = 123456789 +const OCEAN_FIXTURE_ORIGIN := Vector2i(-440, -512) +const MAX_SPAWN_TIME_MSEC: float = 2000.0 + +var _failures := PackedStringArray() + + +func _initialize() -> void: + _verify_large_tree_version_gate() + _verify_ocean_origin_spawn() + _verify_versioned_lava_basins() + if _failures.is_empty(): + print("WORLDGEN AUDIT VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN AUDIT VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_large_tree_version_gate() -> void: + for version in range(1, 11): + var legacy := DecorationCatalogScript.new(version) + _expect(not _contains_large_tree(legacy.tree_entries_for_set(BiomeCatalogScript.DECORATION_FOREST)), + "v%d forest catalog still exposes FEATURE_LARGE_TREE" % version) + _expect(not _contains_large_tree(legacy.tree_entries_for_set(BiomeCatalogScript.DECORATION_TROPICAL)), + "v%d tropical catalog still exposes FEATURE_LARGE_TREE" % version) + + var current := DecorationCatalogScript.new(11) + var forest_entry := _large_tree_entry(current.tree_entries_for_set(BiomeCatalogScript.DECORATION_FOREST)) + var tropical_entry := _large_tree_entry(current.tree_entries_for_set(BiomeCatalogScript.DECORATION_TROPICAL)) + _expect(not forest_entry.is_empty() and float(forest_entry[2]) > 0.0, + "v11 forest FEATURE_LARGE_TREE lacks a nonzero occurrence chance") + _expect(not tropical_entry.is_empty() and float(tropical_entry[2]) > 0.0, + "v11 tropical FEATURE_LARGE_TREE lacks a nonzero occurrence chance") + _expect(_selects_large_tree(current, BiomeCatalogScript.DECORATION_FOREST), + "v11 forest large tree is unreachable through tree selection") + _expect(_selects_large_tree(current, BiomeCatalogScript.DECORATION_TROPICAL), + "v11 tropical large tree is unreachable through tree selection") + + var populator := VoxelPopulatorScript.new(WorldGenConfigScript.new({"worldgen_version": 11}), BiomeCatalogScript.new()) + var data := PackedByteArray() + data.resize(VoxelDefsScript.CHUNK_AREA * VoxelDefsScript.WORLD_HEIGHT) + var highest: int = populator._stamp_feature(data, 0, 0, 8, 48, 8, + DecorationCatalogScript.FEATURE_LARGE_TREE, 918273) + _expect(highest > 48 and data.count(BlockRegistryScript.BLOCK_LOG) > 0 + and data.count(BlockRegistryScript.BLOCK_LEAVES) > 0, + "v11 FEATURE_LARGE_TREE stamp produced no complete tree") + + +func _verify_ocean_origin_spawn() -> void: + var generator := TerrainGenerator.new() + generator.configure({"seed": OCEAN_ORIGIN_SEED, "worldgen_version": 11}) + var origin := generator.sample_point(OCEAN_FIXTURE_ORIGIN.x, OCEAN_FIXTURE_ORIGIN.y) + var origin_biome: int = int(origin["dominant_biome_id"]) + _expect(BiomeCatalogScript.new().is_ocean_biome(origin_biome), + "pinned ocean fixture is not ocean for seed %d at %s" % [OCEAN_ORIGIN_SEED, OCEAN_FIXTURE_ORIGIN]) + var start_us := Time.get_ticks_usec() + var first := generator.find_spawn_position() + var elapsed_msec: float = float(Time.get_ticks_usec() - start_us) / 1000.0 + var second := generator.find_spawn_position() + var spawn_sample := generator.sample_point(floori(first.x), floori(first.z)) + var spawn_biome: int = int(spawn_sample["dominant_biome_id"]) + var spawn_height: float = float(spawn_sample["final_height"]) + var spawn_slope: float = float(spawn_sample["slope"]) + _expect(first == second, "ocean-origin spawn search is not deterministic") + _expect(not BiomeCatalogScript.new().is_ocean_biome(spawn_biome) + and spawn_biome not in [BiomeCatalogScript.BEACH, BiomeCatalogScript.RIVER, BiomeCatalogScript.SWAMP], + "ocean-origin spawn did not reject water-adjacent biomes") + _expect(spawn_height > VoxelDefsScript.SEA_LEVEL + 2 and spawn_slope < 2.2, + "ocean-origin spawn did not return safe dry, level land") + _expect(elapsed_msec <= MAX_SPAWN_TIME_MSEC, + "ocean-origin spawn search exceeded generous %.0f ms ceiling (%.3f ms)" % [MAX_SPAWN_TIME_MSEC, elapsed_msec]) + print("WORLDGEN AUDIT SPAWN: seed=%d ocean_origin=%s biome=%d position=%s elapsed_ms=%.3f" % [ + OCEAN_ORIGIN_SEED, OCEAN_FIXTURE_ORIGIN, spawn_biome, first, elapsed_msec]) + + +func _verify_versioned_lava_basins() -> void: + const center := Vector3i(8, 20, 8) + const radius := 4 + var v10 := VoxelPopulatorScript.new(WorldGenConfigScript.new({"worldgen_version": 10}), BiomeCatalogScript.new()) + var legacy_data := _lava_fixture(0, center, radius) + v10._fill_lava_lake(legacy_data, ChunkTerrainDataScript.new(0, 0), center, radius) + var legacy_count := legacy_data.count(BlockRegistryScript.BLOCK_LAVA) + _expect(legacy_count > 0, "v10 legacy lava fixture produced no lava") + _expect(_lava_only_at_y(legacy_data, center.y), "v10 lava fixture no longer uses its legacy one-plane fill") + + var v11 := VoxelPopulatorScript.new(WorldGenConfigScript.new({"worldgen_version": 11}), BiomeCatalogScript.new()) + var basin_data := _lava_fixture(0, center, radius) + v11._fill_lava_lake(basin_data, ChunkTerrainDataScript.new(0, 0), center, radius) + var basin_count := basin_data.count(BlockRegistryScript.BLOCK_LAVA) + _expect(basin_count > legacy_count and _lava_count_at_y(basin_data, center.y - 2) > 0, + "v11 lava basin lacks supported multi-cell depth") + _expect(_lava_is_supported(basin_data), "v11 lava basin contains floating lava") + + const seam_center := Vector3i(16, 20, 8) + const seam_radius := 5 + var left := _lava_fixture(0, seam_center, seam_radius) + var right := _lava_fixture(1, seam_center, seam_radius) + v11._fill_lava_lake(left, ChunkTerrainDataScript.new(0, 0), seam_center, seam_radius) + v11._fill_lava_lake(right, ChunkTerrainDataScript.new(1, 0), seam_center, seam_radius) + var repeated_left := _lava_fixture(0, seam_center, seam_radius) + var repeated_right := _lava_fixture(1, seam_center, seam_radius) + v11._fill_lava_lake(repeated_left, ChunkTerrainDataScript.new(0, 0), seam_center, seam_radius) + v11._fill_lava_lake(repeated_right, ChunkTerrainDataScript.new(1, 0), seam_center, seam_radius) + _expect(left == repeated_left and right == repeated_right, "v11 lava seam fill is not deterministic") + _expect(_seam_lava_matches_expected(left, right, seam_center, seam_radius), + "v11 lava basin clipped or opened across a chunk seam") + _expect(_lava_is_supported(left) and _lava_is_supported(right), + "v11 seam basin contains floating lava") + print("WORLDGEN AUDIT LAVA: v10_cells=%d v11_cells=%d seam_cells=%d" % [ + legacy_count, basin_count, + left.count(BlockRegistryScript.BLOCK_LAVA) + right.count(BlockRegistryScript.BLOCK_LAVA)]) + + +func _contains_large_tree(entries: Array) -> bool: + return not _large_tree_entry(entries).is_empty() + + +func _large_tree_entry(entries: Array) -> Array: + for entry in entries: + if int(entry[0]) == DecorationCatalogScript.FEATURE_LARGE_TREE: + return entry + return [] + + +func _selects_large_tree(catalog: DecorationCatalog, decoration_set: int) -> bool: + for step in range(1000): + var entry := catalog.choose_tree(decoration_set, float(step) / 1000.0) + if not entry.is_empty() and int(entry[0]) == DecorationCatalogScript.FEATURE_LARGE_TREE: + return true + return false + + +func _lava_fixture(chunk_x: int, center: Vector3i, radius: int) -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefsScript.CHUNK_AREA * VoxelDefsScript.WORLD_HEIGHT) + for y in range(center.y + 1): + for column in VoxelDefsScript.CHUNK_AREA: + data[column + y * VoxelDefsScript.DATA_STRIDE_Y] = BlockRegistryScript.BLOCK_STONE + for world_z in range(center.z - radius, center.z + radius + 1): + for world_x in range(center.x - radius, center.x + radius + 1): + var dx: int = world_x - center.x + var dz: int = world_z - center.z + if dx * dx + dz * dz > radius * radius: + continue + var local_x: int = world_x - chunk_x * VoxelDefsScript.CHUNK_SIZE + if local_x < 0 or local_x >= VoxelDefsScript.CHUNK_SIZE: + continue + var local_z: int = world_z + if local_z < 0 or local_z >= VoxelDefsScript.CHUNK_SIZE: + continue + var depth := _lava_depth(dx, dz, radius) + for y in range(center.y - depth + 1, center.y + 1): + data[local_x + local_z * VoxelDefsScript.DATA_STRIDE_Z + y * VoxelDefsScript.DATA_STRIDE_Y] = BlockRegistryScript.BLOCK_AIR + return data + + +func _lava_depth(dx: int, dz: int, radius: int) -> int: + var radial: float = sqrt(float(dx * dx + dz * dz)) / float(maxi(radius, 1)) + return 1 + roundi((1.0 - clampf(radial, 0.0, 1.0)) * 2.0) + + +func _lava_only_at_y(data: PackedByteArray, expected_y: int) -> bool: + for y in VoxelDefsScript.WORLD_HEIGHT: + for column in VoxelDefsScript.CHUNK_AREA: + if data[column + y * VoxelDefsScript.DATA_STRIDE_Y] == BlockRegistryScript.BLOCK_LAVA and y != expected_y: + return false + return true + + +func _lava_count_at_y(data: PackedByteArray, y: int) -> int: + var count := 0 + for column in VoxelDefsScript.CHUNK_AREA: + if data[column + y * VoxelDefsScript.DATA_STRIDE_Y] == BlockRegistryScript.BLOCK_LAVA: + count += 1 + return count + + +func _lava_is_supported(data: PackedByteArray) -> bool: + for y in range(1, VoxelDefsScript.WORLD_HEIGHT): + for column in VoxelDefsScript.CHUNK_AREA: + if data[column + y * VoxelDefsScript.DATA_STRIDE_Y] != BlockRegistryScript.BLOCK_LAVA: + continue + var below: int = data[column + (y - 1) * VoxelDefsScript.DATA_STRIDE_Y] + if below == BlockRegistryScript.BLOCK_AIR or below == BlockRegistryScript.BLOCK_WATER: + return false + return true + + +func _seam_lava_matches_expected(left: PackedByteArray, right: PackedByteArray, center: Vector3i, radius: int) -> bool: + var expected_count := 0 + for world_z in range(center.z - radius, center.z + radius + 1): + for world_x in range(center.x - radius, center.x + radius + 1): + var dx: int = world_x - center.x + var dz: int = world_z - center.z + if dx * dx + dz * dz > radius * radius: + continue + var depth := _lava_depth(dx, dz, radius) + for y in range(center.y - depth + 1, center.y + 1): + expected_count += 1 + var data := left if world_x < VoxelDefsScript.CHUNK_SIZE else right + var local_x: int = world_x if world_x < VoxelDefsScript.CHUNK_SIZE else world_x - VoxelDefsScript.CHUNK_SIZE + var index: int = local_x + world_z * VoxelDefsScript.DATA_STRIDE_Z + y * VoxelDefsScript.DATA_STRIDE_Y + if data[index] != BlockRegistryScript.BLOCK_LAVA: + return false + return expected_count == left.count(BlockRegistryScript.BLOCK_LAVA) + right.count(BlockRegistryScript.BLOCK_LAVA) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_audit_verify.gd.uid b/tools/worldgen_audit_verify.gd.uid new file mode 100644 index 0000000..c43a0b2 --- /dev/null +++ b/tools/worldgen_audit_verify.gd.uid @@ -0,0 +1 @@ +uid://biunltb17r6hg diff --git a/tools/worldgen_biome_verify.gd b/tools/worldgen_biome_verify.gd index 1919eb2..5ddec53 100644 --- a/tools/worldgen_biome_verify.gd +++ b/tools/worldgen_biome_verify.gd @@ -21,6 +21,9 @@ func _init() -> void: "biome_scale": 3072.0, "tree_density": 1.0, "decoration_density": 1.0, + # This verifier isolates biome vegetation; POI cobblestone is covered by + # worldgen_poi_verify and is not procedural debris. + "region_structures": false, }) var samples := {} var secondary_samples := {} diff --git a/tools/worldgen_cave_verify.gd b/tools/worldgen_cave_verify.gd index 456cac8..76f570e 100644 --- a/tools/worldgen_cave_verify.gd +++ b/tools/worldgen_cave_verify.gd @@ -5,6 +5,7 @@ const TEST_CONFIG := { "world_type": 0, "terrain_scale": 1.0, "tree_density": 0.0, + "worldgen_version": 9, "macro_scale": 384.0, "river_density": 1.0, "erosion_strength": 0.55, @@ -22,6 +23,8 @@ func _initialize() -> void: _registry = BlockRegistry.new() _mesher = ChunkMesher.new(_registry) _verify_cave_catalog() + _verify_v10_cave_compatibility() + _verify_v11_cave_regions() _verify_endless_cave_network() _verify_cave_dressing() _verify_surface_exclusion() @@ -41,26 +44,111 @@ func _verify_cave_catalog() -> void: var catalog := BiomeCatalog.new() _expect(catalog.name_for(BiomeCatalog.LUSH_CAVES) == "lush_caves", "lush cave biome was not appended to BiomeCatalog") _expect(catalog.name_for(BiomeCatalog.DEEP_DARK) == "deep_dark", "deep-dark biome was not appended to BiomeCatalog") + _expect(catalog.name_for(BiomeCatalog.DRIPSTONE_CAVES) == "dripstone_caves", "dripstone cave biome was not appended to BiomeCatalog") var found_lush := false var found_deep := false + var found_dripstone := false var middle_regions := 0 var middle_lush := 0 + var middle_dripstone := 0 for cell_z in range(-8, 9): for cell_x in range(-8, 9): var x := cell_x * BiomeCatalog.CAVE_REGION_SIZE + 16 var z := cell_z * BiomeCatalog.CAVE_REGION_SIZE + 16 - var middle_biome := BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), x, 50, z) + var middle_biome := BiomeCatalog.cave_biome_at( + int(TEST_CONFIG.seed), x, 50, z, int(TEST_CONFIG.worldgen_version)) found_lush = found_lush or middle_biome == BiomeCatalog.LUSH_CAVES - found_deep = found_deep or BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), x, 24, z) == BiomeCatalog.DEEP_DARK + found_dripstone = found_dripstone or middle_biome == BiomeCatalog.DRIPSTONE_CAVES + found_deep = found_deep or BiomeCatalog.cave_biome_at( + int(TEST_CONFIG.seed), x, 24, z, int(TEST_CONFIG.worldgen_version)) == BiomeCatalog.DEEP_DARK middle_regions += 1 if middle_biome == BiomeCatalog.LUSH_CAVES: middle_lush += 1 + elif middle_biome == BiomeCatalog.DRIPSTONE_CAVES: + middle_dripstone += 1 _expect(found_lush, "cave classifier produced no lush regions") _expect(found_deep, "cave classifier produced no deep-dark regions") - _expect(float(middle_lush) / float(middle_regions) >= 0.65, "lush cave regions are too sparse to discover reliably") + _expect(found_dripstone, "cave classifier produced no dripstone regions") + _expect(float(middle_lush) / float(middle_regions) >= 0.62, "lush cave regions are too sparse to discover reliably") + var dripstone_share := float(middle_dripstone) / float(middle_regions) + _expect(dripstone_share >= 0.12 and dripstone_share <= 0.28, + "dripstone cave share left its 12-28% guardrail") + _expect(BiomeCatalog.cave_surface_block(BiomeCatalog.DRIPSTONE_CAVES) == BlockRegistry.BLOCK_CALCITE, + "dripstone caves do not expose calcite floors") + # Version 8 retains the original two-region classifier for persisted worlds. + for cell_x in range(-8, 9): + var x := cell_x * BiomeCatalog.CAVE_REGION_SIZE + 16 + _expect(BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), x, 50, 16, 8) != BiomeCatalog.DRIPSTONE_CAVES, + "version 8 unexpectedly classified a dripstone cave") _expect(not BiomeCatalog.is_cave_biome(BiomeCatalog.PLAINS), "surface biome was classified as a cave biome") +## Fixed legacy outputs guard save compatibility independently of the current +## implementation: v10 worlds must keep their original column identities. +func _verify_v10_cave_compatibility() -> void: + var fixtures: Array[Dictionary] = [ + {"position": Vector3i(16, 24, 16), "biome": BiomeCatalog.DEEP_DARK}, + {"position": Vector3i(16, 50, 16), "biome": BiomeCatalog.LUSH_CAVES}, + {"position": Vector3i(304, 24, -464), "biome": BiomeCatalog.DRIPSTONE_CAVES}, + {"position": Vector3i(304, 50, -464), "biome": BiomeCatalog.LUSH_CAVES}, + {"position": Vector3i(-368, 24, -272), "biome": BiomeCatalog.LUSH_CAVES}, + {"position": Vector3i(-368, 50, -272), "biome": BiomeCatalog.DRIPSTONE_CAVES}, + {"position": Vector3i(-80, 24, -464), "biome": BiomeCatalog.CAVE_BIOME_NONE}, + ] + for fixture in fixtures: + var position: Vector3i = fixture.position + _expect(BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), position.x, position.y, position.z, 10) == int(fixture.biome), + "v10 cave compatibility fixture changed at %s" % position) + # V8 predates dripstone but otherwise keeps its original two-region result. + _expect(BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), 304, 24, -464, 8) == BiomeCatalog.LUSH_CAVES, + "v8 cave compatibility fixture changed") + + +func _verify_v11_cave_regions() -> void: + const version := 11 + var found := { + BiomeCatalog.LUSH_CAVES: false, + BiomeCatalog.DEEP_DARK: false, + BiomeCatalog.DRIPSTONE_CAVES: false, + } + for world_z in range(-384, 385, 24): + for world_x in range(-384, 385, 24): + for y in range(6, 79, 6): + var first := BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), world_x, y, world_z, version) + var repeated := BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), world_x, y, world_z, version) + _expect(first == repeated, "v11 cave classifier is not deterministic") + _expect(first == BiomeCatalog.CAVE_BIOME_NONE or BiomeCatalog.is_cave_biome(first), + "v11 cave classifier returned a surface biome ID") + if found.has(first): + found[first] = true + for biome in found: + _expect(bool(found[biome]), "v11 cave classifier produced no biome %d" % biome) + + # A one-voxel step has a bounded value delta on every axis, including fixed + # global-cell boundaries. This rejects the old column checkerboard behavior. + for world_z in range(-192, 193, 24): + for world_x in range(-192, 193, 24): + for y in range(6, 78, 6): + var center := BiomeCatalog.cave_region_value_at(int(TEST_CONFIG.seed), world_x, y, world_z) + var x_delta := absf(center - BiomeCatalog.cave_region_value_at(int(TEST_CONFIG.seed), world_x + 1, y, world_z)) + var y_delta := absf(center - BiomeCatalog.cave_region_value_at(int(TEST_CONFIG.seed), world_x, y + 1, world_z)) + var z_delta := absf(center - BiomeCatalog.cave_region_value_at(int(TEST_CONFIG.seed), world_x, y, world_z + 1)) + _expect(x_delta <= 0.021 and y_delta <= 0.041 and z_delta <= 0.021, + "v11 cave value field is discontinuous across a voxel or chunk boundary") + + var catalog := BiomeCatalog.new() + var surface_names := PackedStringArray([ + "plains", "forest", "desert", "snow", "swamp", "shelf_sea", "deep_sea", "beach", "river", + "jungle", "savanna", "taiga", "badlands", "meadow", "highlands", "kelp_forest", "seagrass_meadow", + "coral_reef", "frozen_sea", + ]) + for biome in surface_names.size(): + _expect(catalog.name_for(biome) == surface_names[biome], "surface biome ID %d changed in v11" % biome) + _expect(BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), 0, 4, 0, version) == BiomeCatalog.CAVE_BIOME_NONE + and BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), 0, 79, 0, version) == BiomeCatalog.CAVE_BIOME_NONE, + "v11 cave classifier escaped its underground range") + + func _verify_endless_cave_network() -> void: var setup := _worldgen_services() var populator: VoxelPopulator = setup.populator @@ -116,20 +204,31 @@ func _verify_cave_dressing() -> void: var setup := _worldgen_services() var populator: VoxelPopulator = setup.populator var sampler: TerrainSampler = setup.sampler - for cave_biome in [BiomeCatalog.LUSH_CAVES, BiomeCatalog.DEEP_DARK]: + for cave_biome in [BiomeCatalog.LUSH_CAVES, BiomeCatalog.DEEP_DARK, BiomeCatalog.DRIPSTONE_CAVES]: var chunk_pos := _find_cave_chunk(cave_biome) var field := sampler.build_field(chunk_pos) _set_field_height(field, 100.0) - var data := _room_fixture(68, 9, 59) + var floor_y := 18 if cave_biome == BiomeCatalog.DEEP_DARK else 44 + var ceiling_y := 34 if cave_biome == BiomeCatalog.DEEP_DARK else 60 + var data := _room_fixture(68, floor_y, ceiling_y) populator._decorate_caves(data, field, chunk_pos.x * VoxelDefs.CHUNK_SIZE, chunk_pos.y * VoxelDefs.CHUNK_SIZE) if cave_biome == BiomeCatalog.LUSH_CAVES: var lush_surface_blocks := data.count(BlockRegistry.BLOCK_MOSS) + data.count(BlockRegistry.BLOCK_CAVE_MOSS) _expect(lush_surface_blocks >= 24, "lush cave material patches are too sparse to read visually") _expect(data.count(BlockRegistry.BLOCK_CAVE_MOSS) > 0, "lush cave dressing placed no vegetation") - else: + elif cave_biome == BiomeCatalog.DEEP_DARK: var deep_surface_blocks := data.count(BlockRegistry.BLOCK_DEEPSTONE) + data.count(BlockRegistry.BLOCK_SCULK) _expect(deep_surface_blocks >= 24, "deep-dark material patches are too sparse to read visually") _expect(data.count(BlockRegistry.BLOCK_SCULK) > 0, "deep-dark dressing placed no sculk") + else: + _expect(data.count(BlockRegistry.BLOCK_CALCITE) >= 24, + "dripstone cave calcite patches are too sparse to read visually") + _expect(data.count(BlockRegistry.BLOCK_DRIPSTONE) > 0, + "dripstone cave dressing placed no formations") + var duplicate := _room_fixture(68, floor_y, ceiling_y) + populator._decorate_caves(duplicate, field, + chunk_pos.x * VoxelDefs.CHUNK_SIZE, chunk_pos.y * VoxelDefs.CHUNK_SIZE) + _expect(data == duplicate, "cave dressing is not deterministic for biome %d" % cave_biome) var normal_chunk := Vector2i.ZERO var normal_field := sampler.build_field(normal_chunk) _set_field_height(normal_field, 100.0) @@ -246,12 +345,13 @@ func _worldgen_services() -> Dictionary: func _find_cave_chunk(target: int) -> Vector2i: - var y := 50 if target == BiomeCatalog.LUSH_CAVES else 24 + var y := 24 if target == BiomeCatalog.DEEP_DARK else 50 for cell_z in range(-10, 11): for cell_x in range(-10, 11): var world_x := cell_x * BiomeCatalog.CAVE_REGION_SIZE + 16 var world_z := cell_z * BiomeCatalog.CAVE_REGION_SIZE + 16 - if BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), world_x, y, world_z) == target: + if BiomeCatalog.cave_biome_at( + int(TEST_CONFIG.seed), world_x, y, world_z, int(TEST_CONFIG.worldgen_version)) == target: return Vector2i(WorldGenHash.floor_div(world_x, VoxelDefs.CHUNK_SIZE), WorldGenHash.floor_div(world_z, VoxelDefs.CHUNK_SIZE)) _failures.append("could not find cave biome %d fixture region" % target) return Vector2i.ZERO diff --git a/tools/worldgen_config_sweep_verify.gd b/tools/worldgen_config_sweep_verify.gd new file mode 100644 index 0000000..2d3c6e5 --- /dev/null +++ b/tools/worldgen_config_sweep_verify.gd @@ -0,0 +1,279 @@ +extends SceneTree + +## Bounded configuration-boundary coverage for WorldGenConfig and TerrainSampler. +## Each case builds four small terrain fields and two authoritative chunks only. + +const SEED: int = 457219 +const FIELD_POINT_TOLERANCE: float = 0.001 +const MAX_CLAMP_FLAT_TOP_RATIO: float = 0.45 +const EDGE_SAMPLES: Array[int] = [-1, 0, 8, 15, 16] +const FIELD_ORIGIN := Vector2i(-17, 23) +## Widely spaced fields make the ceiling-ratio guard meaningful even at the +## largest 8192-block macro scale, while keeping each case finite and small. +const CLAMP_PROBE_ORIGINS: Array[Vector2i] = [ + FIELD_ORIGIN, Vector2i(127, -91), Vector2i(-359, 211), Vector2i(797, -631), +] +const GENERATED_CHUNK := Vector2i(2, -1) + +var _failures := PackedStringArray() +var _case_count: int = 0 +var _point_count: int = 0 +var _max_clamp_flat_top_ratio: float = 0.0 + + +func _initialize() -> void: + for test_case in _cases(): + _verify_case(test_case) + if _failures.is_empty(): + print("WORLDGEN CONFIG SWEEP: PASS cases=%d point_samples=%d max_clamp_flat_top_ratio=%.4f" % [ + _case_count, _point_count, _max_clamp_flat_top_ratio]) + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN CONFIG SWEEP: FAIL cases=%d failures=%d" % [_case_count, _failures.size()]) + quit(1) + + +func _cases() -> Array[Dictionary]: + return [ + _case("normal_minimums", { + "terrain_scale": WorldGenConfig.MIN_TERRAIN_SCALE, + "tree_density": WorldGenConfig.MIN_TREE_DENSITY, + "macro_scale": WorldGenConfig.MIN_MACRO_SCALE, + "biome_scale": WorldGenConfig.MIN_BIOME_SCALE, + "river_density": WorldGenConfig.MIN_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MIN_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MIN_EROSION_STRENGTH, + "cave_density": WorldGenConfig.MIN_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MIN_DECORATION_DENSITY, + }), + _case("normal_maximums", { + "terrain_scale": WorldGenConfig.MAX_TERRAIN_SCALE, + "tree_density": WorldGenConfig.MAX_TREE_DENSITY, + "macro_scale": WorldGenConfig.MAX_MACRO_SCALE, + "biome_scale": WorldGenConfig.MAX_BIOME_SCALE, + "river_density": WorldGenConfig.MAX_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MAX_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MAX_EROSION_STRENGTH, + "cave_density": WorldGenConfig.MAX_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MAX_DECORATION_DENSITY, + }), + _case("macro_min_biome_max", { + "macro_scale": WorldGenConfig.MIN_MACRO_SCALE, + "biome_scale": WorldGenConfig.MAX_BIOME_SCALE, + }), + _case("macro_max_biome_min", { + "macro_scale": WorldGenConfig.MAX_MACRO_SCALE, + "biome_scale": WorldGenConfig.MIN_BIOME_SCALE, + }), + _case("river_off_erosion_max", { + "river_density": WorldGenConfig.MIN_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MAX_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MAX_EROSION_STRENGTH, + }), + _case("river_max_erosion_min", { + "river_density": WorldGenConfig.MAX_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MIN_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MIN_EROSION_STRENGTH, + }), + _case("caves_and_decorations_off", { + "cave_density": WorldGenConfig.MIN_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MIN_DECORATION_DENSITY, + "tree_density": WorldGenConfig.MIN_TREE_DENSITY, + }), + _case("caves_and_decorations_max", { + "cave_density": WorldGenConfig.MAX_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MAX_DECORATION_DENSITY, + "tree_density": WorldGenConfig.MAX_TREE_DENSITY, + }), + _case("flat_minimums", { + "world_type": WorldGenConfig.WORLD_TYPE_FLAT, + "terrain_scale": WorldGenConfig.MIN_TERRAIN_SCALE, + "macro_scale": WorldGenConfig.MIN_MACRO_SCALE, + "biome_scale": WorldGenConfig.MIN_BIOME_SCALE, + "river_density": WorldGenConfig.MIN_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MIN_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MIN_EROSION_STRENGTH, + "cave_density": WorldGenConfig.MIN_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MIN_DECORATION_DENSITY, + }), + _case("flat_maximums", { + "world_type": WorldGenConfig.WORLD_TYPE_FLAT, + "terrain_scale": WorldGenConfig.MAX_TERRAIN_SCALE, + "macro_scale": WorldGenConfig.MAX_MACRO_SCALE, + "biome_scale": WorldGenConfig.MAX_BIOME_SCALE, + "river_density": WorldGenConfig.MAX_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MAX_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MAX_EROSION_STRENGTH, + "cave_density": WorldGenConfig.MAX_CAVE_DENSITY, + "decoration_density": WorldGenConfig.MAX_DECORATION_DENSITY, + }), + _case("amplified_minimums", { + "world_type": WorldGenConfig.WORLD_TYPE_AMPLIFIED, + "terrain_scale": WorldGenConfig.MIN_TERRAIN_SCALE, + "macro_scale": WorldGenConfig.MIN_MACRO_SCALE, + "biome_scale": WorldGenConfig.MIN_BIOME_SCALE, + "river_density": WorldGenConfig.MIN_RIVER_DENSITY, + }), + _case("amplified_maximums", { + "world_type": WorldGenConfig.WORLD_TYPE_AMPLIFIED, + "terrain_scale": WorldGenConfig.MAX_TERRAIN_SCALE, + "macro_scale": WorldGenConfig.MAX_MACRO_SCALE, + "biome_scale": WorldGenConfig.MAX_BIOME_SCALE, + "river_density": WorldGenConfig.MAX_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.MAX_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.MAX_EROSION_STRENGTH, + }), + _case("experimental_disabled", { + "hydraulic_erosion": false, + "spline_terrain": false, + "elevated_hydrology": false, + "climate_variants": false, + "region_structures": false, + }), + _case("experimental_enabled", { + "hydraulic_erosion": true, + "spline_terrain": true, + "elevated_hydrology": true, + "climate_variants": true, + "region_structures": true, + "river_density": WorldGenConfig.MAX_RIVER_DENSITY, + }), + ] + + +func _case(label: String, overrides: Dictionary) -> Dictionary: + var source: Dictionary = { + "seed": SEED, + "worldgen_version": WorldGenConfig.CURRENT_VERSION, + "world_type": WorldGenConfig.WORLD_TYPE_NORMAL, + "terrain_scale": WorldGenConfig.DEFAULT_TERRAIN_SCALE, + "tree_density": WorldGenConfig.DEFAULT_TREE_DENSITY, + "macro_scale": WorldGenConfig.DEFAULT_MACRO_SCALE, + "biome_scale": WorldGenConfig.DEFAULT_BIOME_SCALE, + "river_density": WorldGenConfig.DEFAULT_RIVER_DENSITY, + "erosion_strength": WorldGenConfig.DEFAULT_EROSION_STRENGTH, + "regional_erosion": WorldGenConfig.DEFAULT_REGIONAL_EROSION, + "hydraulic_erosion": WorldGenConfig.DEFAULT_HYDRAULIC_EROSION, + "cave_density": WorldGenConfig.DEFAULT_CAVE_DENSITY, + "decoration_density": WorldGenConfig.DEFAULT_DECORATION_DENSITY, + "spline_terrain": WorldGenConfig.DEFAULT_SPLINE_TERRAIN, + "elevated_hydrology": WorldGenConfig.DEFAULT_ELEVATED_HYDROLOGY, + "climate_variants": WorldGenConfig.DEFAULT_CLIMATE_VARIANTS, + "region_structures": WorldGenConfig.DEFAULT_REGION_STRUCTURES, + } + for key in overrides: + source[key] = overrides[key] + return {"label": label, "source": source} + + +func _verify_case(test_case: Dictionary) -> void: + var label: String = str(test_case["label"]) + var config := WorldGenConfig.new(test_case["source"]) + var sampler := TerrainSampler.new(config, TerrainProfileCatalog.new(), BiomeCatalog.new()) + var field := sampler.build_field(FIELD_ORIGIN) + var repeat := sampler.build_field(FIELD_ORIGIN) + var east := sampler.build_field(FIELD_ORIGIN + Vector2i.RIGHT) + var south := sampler.build_field(FIELD_ORIGIN + Vector2i.DOWN) + _expect(field.final_height == repeat.final_height, "%s field heights changed between identical runs" % label) + _expect(field.river == repeat.river, "%s river field changed between identical runs" % label) + _expect(field.dominant_biome == repeat.dominant_biome, "%s biome field changed between identical runs" % label) + _verify_field_bounds(label, field) + _verify_x_border(label, sampler, field, east) + _verify_z_border(label, sampler, field, south) + for probe_origin in CLAMP_PROBE_ORIGINS: + if probe_origin != FIELD_ORIGIN: + _verify_field_bounds("%s clamp probe %s" % [label, probe_origin], sampler.build_field(probe_origin)) + + var generator := TerrainGenerator.new() + generator.configure(config.to_dictionary()) + var generated := generator.generate_data(GENERATED_CHUNK, {}, false) + var generated_repeat := generator.generate_data(GENERATED_CHUNK, {}, false) + _expect(generated.data == generated_repeat.data, "%s generated blocks changed between identical runs" % label) + _expect(generated.heights == generated_repeat.heights, "%s generated heightmap changed between identical runs" % label) + _expect(generated.max_y == generated_repeat.max_y, "%s generated max_y changed between identical runs" % label) + _expect(generated.max_y >= 0 and generated.max_y < VoxelDefs.WORLD_HEIGHT - 1, + "%s generated max_y %d is outside valid world bounds" % [label, generated.max_y]) + _case_count += 1 + print("WORLDGEN CONFIG SWEEP %s: clamp_flat_top_ratio=%.4f max_y=%d" % [ + label, _flat_top_ratio(field), generated.max_y]) + + +func _verify_field_bounds(label: String, field: ChunkTerrainData) -> void: + var minimum: float = INF + var maximum: float = -INF + var clamp_count: int = 0 + var sample_count: int = 0 + var clamp_top: float = float(VoxelDefs.WORLD_HEIGHT) - TerrainSampler.HEIGHT_MARGIN + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + var height: float = float(field.final_height[ChunkTerrainData.cell_index(local_x, local_z)]) + minimum = minf(minimum, height) + maximum = maxf(maximum, height) + if height >= clamp_top - FIELD_POINT_TOLERANCE: + clamp_count += 1 + sample_count += 1 + _expect(minimum >= TerrainSampler.MIN_TERRAIN_HEIGHT - FIELD_POINT_TOLERANCE, + "%s terrain minimum %.3f is below the valid bound" % [label, minimum]) + _expect(maximum <= clamp_top + FIELD_POINT_TOLERANCE, + "%s terrain maximum %.3f exceeds the valid bound" % [label, maximum]) + var clamp_ratio: float = float(clamp_count) / float(sample_count) + _max_clamp_flat_top_ratio = maxf(_max_clamp_flat_top_ratio, clamp_ratio) + _expect(clamp_ratio <= MAX_CLAMP_FLAT_TOP_RATIO, + "%s has excessive clamp-flat-top terrain (%.2f%%)" % [label, clamp_ratio * 100.0]) + + +func _flat_top_ratio(field: ChunkTerrainData) -> float: + var clamp_count: int = 0 + var clamp_top: float = float(VoxelDefs.WORLD_HEIGHT) - TerrainSampler.HEIGHT_MARGIN + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + if float(field.final_height[ChunkTerrainData.cell_index(local_x, local_z)]) >= clamp_top - FIELD_POINT_TOLERANCE: + clamp_count += 1 + return float(clamp_count) / float(VoxelDefs.CHUNK_AREA) + + +func _verify_x_border(label: String, sampler: TerrainSampler, field: ChunkTerrainData, east: ChunkTerrainData) -> void: + for local_z in EDGE_SAMPLES: + var left_index: int = ChunkTerrainData.cell_index(VoxelDefs.CHUNK_SIZE, local_z) + var right_index: int = ChunkTerrainData.cell_index(0, local_z) + _expect(is_equal_approx(field.final_height[left_index], east.final_height[right_index]), + "%s x height field seam at z=%d" % [label, local_z]) + _expect(is_equal_approx(field.river[left_index], east.river[right_index]), + "%s x river field seam at z=%d" % [label, local_z]) + _expect(field.dominant_biome[left_index] == east.dominant_biome[right_index], + "%s x biome field seam at z=%d" % [label, local_z]) + _verify_point_parity(label, sampler, field, VoxelDefs.CHUNK_SIZE, local_z, left_index) + + +func _verify_z_border(label: String, sampler: TerrainSampler, field: ChunkTerrainData, south: ChunkTerrainData) -> void: + for local_x in EDGE_SAMPLES: + var north_index: int = ChunkTerrainData.cell_index(local_x, VoxelDefs.CHUNK_SIZE) + var south_index: int = ChunkTerrainData.cell_index(local_x, 0) + _expect(is_equal_approx(field.final_height[north_index], south.final_height[south_index]), + "%s z height field seam at x=%d" % [label, local_x]) + _expect(is_equal_approx(field.river[north_index], south.river[south_index]), + "%s z river field seam at x=%d" % [label, local_x]) + _expect(field.dominant_biome[north_index] == south.dominant_biome[south_index], + "%s z biome field seam at x=%d" % [label, local_x]) + _verify_point_parity(label, sampler, field, local_x, VoxelDefs.CHUNK_SIZE, north_index) + + +func _verify_point_parity(label: String, sampler: TerrainSampler, field: ChunkTerrainData, + local_x: int, local_z: int, field_index: int) -> void: + var world_x: int = FIELD_ORIGIN.x * VoxelDefs.CHUNK_SIZE + local_x + var world_z: int = FIELD_ORIGIN.y * VoxelDefs.CHUNK_SIZE + local_z + var point := sampler.sample_point(world_x, world_z) + _expect(absf(float(field.final_height[field_index]) - float(point["final_height"])) <= FIELD_POINT_TOLERANCE, + "%s field/point height mismatch at (%d, %d)" % [label, world_x, world_z]) + _expect(absf(float(field.river[field_index]) - float(point["river"])) <= FIELD_POINT_TOLERANCE, + "%s field/point river mismatch at (%d, %d)" % [label, world_x, world_z]) + _expect(int(field.dominant_biome[field_index]) == int(point["dominant_biome_id"]), + "%s field/point biome mismatch at (%d, %d)" % [label, world_x, world_z]) + _point_count += 1 + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_config_sweep_verify.gd.uid b/tools/worldgen_config_sweep_verify.gd.uid new file mode 100644 index 0000000..cad0ca4 --- /dev/null +++ b/tools/worldgen_config_sweep_verify.gd.uid @@ -0,0 +1 @@ +uid://1falc3wd62l8 diff --git a/tools/worldgen_elevated_hydrology_verify.gd b/tools/worldgen_elevated_hydrology_verify.gd new file mode 100644 index 0000000..9de4add --- /dev/null +++ b/tools/worldgen_elevated_hydrology_verify.gd @@ -0,0 +1,251 @@ +extends SceneTree + +var _failures := PackedStringArray() + + +func _initialize() -> void: + _verify_config_gates() + _verify_disabled_equivalence() + _verify_flat_gate() + _verify_v10_compatibility() + var fixture := _find_v11_fixture() + if fixture.is_empty(): + _failures.append("could not find a routed v11 elevated-water fixture with a stepped drop") + else: + _verify_v11_fixture(fixture) + if _failures.is_empty(): + print("WORLDGEN ELEVATED HYDROLOGY VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN ELEVATED HYDROLOGY VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_config_gates() -> void: + _expect(not WorldGenConfig.new({"worldgen_version": 9, "elevated_hydrology": true}).elevated_hydrology, + "legacy world enabled elevated hydrology") + _expect(not WorldGenConfig.new({"worldgen_version": 11}).elevated_hydrology, + "elevated hydrology is not default-off") + _expect(WorldGenConfig.new({"worldgen_version": 10, "elevated_hydrology": true}).elevated_hydrology, + "v10 did not retain the legacy experiment") + var enabled := WorldGenConfig.new({"worldgen_version": 11, "elevated_hydrology": true}) + _expect(enabled.elevated_hydrology and bool(enabled.to_dictionary().get("elevated_hydrology", false)), + "v11 elevated hydrology did not serialize") + + +func _verify_disabled_equivalence() -> void: + var disabled := _sampler(11, false).build_field(Vector2i(3, -2)) + _expect(disabled.inland_water_y.count(-1) == ChunkTerrainData.CELL_COUNT, + "disabled hydrology emitted inland water") + var enabled := _sampler(11, true).build_field(Vector2i(3, -2)) + for index in ChunkTerrainData.CELL_COUNT: + _expect(enabled.final_height[index] <= disabled.final_height[index] + 0.0001, + "routed hydrology raised terrain while enabled") + + +func _verify_flat_gate() -> void: + var config := _config(11, true) + config.world_type = WorldGenConfig.WORLD_TYPE_FLAT + var sampler := TerrainSampler.new(config, TerrainProfileCatalog.new(), BiomeCatalog.new()) + var field := sampler.build_field(Vector2i(-3, 4)) + _expect(field.inland_water_y.count(-1) == ChunkTerrainData.CELL_COUNT, + "flat world emitted routed elevated water") + + +func _verify_v10_compatibility() -> void: + var sampler := _sampler(10, true) + var found := false + for cell_z in range(-12, 13): + for cell_x in range(-12, 13): + var hash_value := WorldGenHash.hash_2d(918273 + 2213, cell_x, cell_z) + if hash_value % 100 >= 30: + continue + var x := cell_x * TerrainSampler.INLAND_WATER_CELL_SIZE + 32 + (hash_value / 101) % 128 + var z := cell_z * TerrainSampler.INLAND_WATER_CELL_SIZE + 32 + (hash_value / 307) % 128 + var sample := sampler.sample_point(x, z) + if int(sample["inland_water_y"]) <= VoxelDefs.SEA_LEVEL: + continue + found = true + _expect(int(sample["inland_water_y"]) == TerrainSampler.INLAND_WATER_Y, + "v11 changed v10's fixed elevated-water level") + _expect(float(sample["final_height"]) <= float(TerrainSampler.INLAND_WATER_Y - 1), + "v10 elevated water lost its supported bed") + break + if found: + break + _expect(found, "could not find a legacy v10 elevated-water fixture") + + +## A source lake is accepted only after TerrainSampler's immutable owner-cell +## validation. Require an actual water-level drop, so the fixture also covers +## the waterfall-facing path rather than merely locating a lake. +func _find_v11_fixture() -> Dictionary: + var sampler := _sampler(11, true) + for cell_z in range(-28, 29): + for cell_x in range(-28, 29): + var route: Dictionary = sampler._v11_route_for_source(cell_x, cell_z) + if route.is_empty(): + continue + var levels: PackedInt32Array = route["levels"] + var source_water_y: int = int(route["source_water_y"]) + var previous: int = source_water_y + var drop_index := -1 + for index in levels.size(): + if levels[index] < previous: + drop_index = index + break + previous = levels[index] + if drop_index < 0: + continue + var center: Vector2i = route["center"] + var sample := sampler.sample_point(center.x, center.y) + if int(sample["inland_water_y"]) > VoxelDefs.SEA_LEVEL: + return {"sampler": sampler, "route": route, "drop_index": drop_index} + return {} + + +func _verify_v11_fixture(fixture: Dictionary) -> void: + var sampler: TerrainSampler = fixture["sampler"] + var route: Dictionary = fixture["route"] + var points: Array = route["points"] + var levels: PackedInt32Array = route["levels"] + var center: Vector2i = route["center"] + var source_water_y: int = int(route["source_water_y"]) + var source := sampler.sample_point(center.x, center.y) + + # Source lakes remain wide, supported water bodies rather than route-only + # ribbons. The 3x3 interior is a conservative lake-presence check. + _expect(int(source["inland_water_y"]) > VoxelDefs.SEA_LEVEL, "v11 source lake is dry") + var lake_cells := 0 + for offset_z in range(-1, 2): + for offset_x in range(-1, 2): + var lake_sample := sampler.sample_point(center.x + offset_x * 4, center.y + offset_z * 4) + if int(lake_sample["inland_water_y"]) == source_water_y: + lake_cells += 1 + _expect(lake_cells >= 7, "v11 source did not produce a lake") + + # Every route endpoint is chosen downhill from the clearly pre-hydrology + # coarse terrain source, while water levels can only stay level or descend. + var previous_water_y: int = source_water_y + for reach in levels.size(): + var start: Vector2i = points[reach] + var finish: Vector2i = points[reach + 1] + var start_height: float = sampler._coarse_pre_hydrology_height_at(start.x, start.y) + var finish_height: float = sampler._coarse_pre_hydrology_height_at(finish.x, finish.y) + _expect(finish_height < start_height - 0.24, + "route reach %d was not selected downhill" % reach) + _expect(levels[reach] <= previous_water_y, + "route reach %d raised its water level" % reach) + previous_water_y = levels[reach] + var drop_index: int = int(fixture["drop_index"]) + var drop_before: int = source_water_y if drop_index == 0 else levels[drop_index - 1] + _expect(levels[drop_index] < drop_before, "fixture does not contain a stepped drop") + + var chunk_pos := Vector2i(WorldGenHash.floor_div(center.x, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(center.y, VoxelDefs.CHUNK_SIZE)) + var field := sampler.build_field(chunk_pos) + var disabled := _sampler(11, false).build_field(chunk_pos) + for local_z in range(-1, VoxelDefs.CHUNK_SIZE + 1): + for local_x in range(-1, VoxelDefs.CHUNK_SIZE + 1): + var index := ChunkTerrainData.cell_index(local_x, local_z) + var water_y: int = field.inland_water_y[index] + _expect(field.final_height[index] <= disabled.final_height[index] + 0.0001, + "hydrology raised a bank at (%d,%d)" % [local_x, local_z]) + if water_y > VoxelDefs.SEA_LEVEL: + _expect(roundi(field.final_height[index]) < water_y, + "elevated water column has no supported bed") + var local_x := center.x - chunk_pos.x * VoxelDefs.CHUNK_SIZE + var local_z := center.y - chunk_pos.y * VoxelDefs.CHUNK_SIZE + var center_index := ChunkTerrainData.cell_index(local_x, local_z) + _expect(field.inland_water_y[center_index] == int(source["inland_water_y"]), + "point/field inland-water parity failed") + _expect(is_equal_approx(field.final_height[center_index], float(source["final_height"])), + "point/field elevated-height parity failed") + + _verify_seams(sampler, chunk_pos) + _verify_waterfall_face(sampler, points[drop_index]) + _verify_full_compact_parity(chunk_pos, center, int(source["inland_water_y"])) + + +func _verify_seams(sampler: TerrainSampler, chunk_pos: Vector2i) -> void: + for origin in [chunk_pos, Vector2i(-17, 11)]: + var field := sampler.build_field(origin) + var east := sampler.build_field(origin + Vector2i(1, 0)) + var south := sampler.build_field(origin + Vector2i(0, 1)) + for local in range(-1, VoxelDefs.CHUNK_SIZE + 1): + var field_east := ChunkTerrainData.cell_index(VoxelDefs.CHUNK_SIZE, local) + var neighbor_west := ChunkTerrainData.cell_index(0, local) + _expect(field.inland_water_y[field_east] == east.inland_water_y[neighbor_west], + "east/west elevated-water seam at %s/%d" % [origin, local]) + _expect(is_equal_approx(field.final_height[field_east], east.final_height[neighbor_west]), + "east/west elevated-bed seam at %s/%d" % [origin, local]) + var field_south := ChunkTerrainData.cell_index(local, VoxelDefs.CHUNK_SIZE) + var neighbor_north := ChunkTerrainData.cell_index(local, 0) + _expect(field.inland_water_y[field_south] == south.inland_water_y[neighbor_north], + "north/south elevated-water seam at %s/%d" % [origin, local]) + _expect(is_equal_approx(field.final_height[field_south], south.final_height[neighbor_north]), + "north/south elevated-bed seam at %s/%d" % [origin, local]) + + +func _verify_waterfall_face(sampler: TerrainSampler, drop: Vector2i) -> void: + var center_chunk := Vector2i(WorldGenHash.floor_div(drop.x, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(drop.y, VoxelDefs.CHUNK_SIZE)) + var found_face := false + for chunk_z in range(center_chunk.y - 1, center_chunk.y + 2): + for chunk_x in range(center_chunk.x - 1, center_chunk.x + 2): + var field := sampler.build_field(Vector2i(chunk_x, chunk_z)) + for local_z in range(-1, VoxelDefs.CHUNK_SIZE + 1): + for local_x in range(-1, VoxelDefs.CHUNK_SIZE + 1): + var here: int = field.inland_water_y[ChunkTerrainData.cell_index(local_x, local_z)] + if here <= VoxelDefs.SEA_LEVEL: + continue + for offset in [Vector2i.RIGHT, Vector2i.DOWN]: + var next_x: int = local_x + offset.x + var next_z: int = local_z + offset.y + if not ChunkTerrainData.is_valid_local(next_x, next_z): + continue + var next: int = field.inland_water_y[ChunkTerrainData.cell_index(next_x, next_z)] + if next > VoxelDefs.SEA_LEVEL and next != here: + found_face = true + _expect(found_face, "stepped drop did not expose a waterfall water face") + + +func _verify_full_compact_parity(chunk_pos: Vector2i, center: Vector2i, water_y: int) -> void: + var config := _config(11, true) + var generator := TerrainGenerator.new() + generator.configure(config.to_dictionary()) + var full := generator.generate_data(chunk_pos, {}, false) + var lod := generator.generate_data(chunk_pos, {}, true) + _expect(full.heights == lod.heights, "full/compact elevated terrain height parity failed") + var local_x := center.x - chunk_pos.x * VoxelDefs.CHUNK_SIZE + var local_z := center.y - chunk_pos.y * VoxelDefs.CHUNK_SIZE + var column := local_x + local_z * VoxelDefs.DATA_STRIDE_Z + _expect(lod.lod_water_y[column] == water_y, "LOD omitted elevated lake water") + _expect(full.data[column + water_y * VoxelDefs.DATA_STRIDE_Y] == BlockRegistry.BLOCK_WATER, + "full generation omitted elevated lake water") + _expect(full.data[column + roundi(full.heights[column]) * VoxelDefs.DATA_STRIDE_Y] != BlockRegistry.BLOCK_AIR, + "full elevated-water bed is not solid") + + +func _sampler(version: int, enabled: bool) -> TerrainSampler: + var config := _config(version, enabled) + return TerrainSampler.new(config, TerrainProfileCatalog.new(), BiomeCatalog.new()) + + +func _config(version: int, enabled: bool) -> WorldGenConfig: + return WorldGenConfig.new({ + "seed": 918273, + "worldgen_version": version, + "elevated_hydrology": enabled, + "cave_density": 0.0, + "tree_density": 0.0, + "decoration_density": 0.0, + "hydraulic_erosion": false, + }) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_elevated_hydrology_verify.gd.uid b/tools/worldgen_elevated_hydrology_verify.gd.uid new file mode 100644 index 0000000..1e90e39 --- /dev/null +++ b/tools/worldgen_elevated_hydrology_verify.gd.uid @@ -0,0 +1 @@ +uid://byfbtsktxy4tt diff --git a/tools/worldgen_mesh_benchmark.gd b/tools/worldgen_mesh_benchmark.gd index ffa9104..b11e5b0 100644 --- a/tools/worldgen_mesh_benchmark.gd +++ b/tools/worldgen_mesh_benchmark.gd @@ -1,5 +1,7 @@ extends SceneTree +const USEC_PER_MSEC: float = 1000.0 + func _initialize() -> void: var generator := TerrainGenerator.new() @@ -7,19 +9,55 @@ func _initialize() -> void: var blocks := BlockRegistry.new() var mesher := ChunkMesher.new(blocks) var positions: Array[Vector2i] = [Vector2i.ZERO, Vector2i(1, 0), Vector2i(-4, 7), Vector2i(20, -12)] - var full_total := 0.0 - var lod_total := 0.0 + var full_total_ms: float = 0.0 + var lod_total_ms: float = 0.0 + var light_assembly_total_ms: float = 0.0 + var sky_light_total_ms: float = 0.0 + var block_light_total_ms: float = 0.0 + var padding_total_ms: float = 0.0 + var face_emit_total_ms: float = 0.0 for position in positions: var full := generator.generate_data(position, {}, false) - var start := Time.get_ticks_usec() + var start_us: int = Time.get_ticks_usec() var mesh := mesher.build(full.data, full.max_y, full.heights, full.foliage_tints, full.water_tints, ChunkMesher.NeighborSet.new()) - var full_ms := float(Time.get_ticks_usec() - start) / 1000.0 + var full_ms: float = float(Time.get_ticks_usec() - start_us) / USEC_PER_MSEC var lod := generator.generate_data(position, {}, true) - start = Time.get_ticks_usec() + start_us = Time.get_ticks_usec() var lod_mesh := mesher.build_lod(lod.lod_solid_y, lod.lod_solid_id, lod.lod_sub_id, lod.lod_water_y, lod.lod_water_level, lod.max_y, lod.foliage_tints, lod.water_tints, ChunkMesher.LodNeighbors.new()) - var lod_ms := float(Time.get_ticks_usec() - start) / 1000.0 - full_total += full_ms - lod_total += lod_ms + var lod_ms: float = float(Time.get_ticks_usec() - start_us) / USEC_PER_MSEC + full_total_ms += full_ms + lod_total_ms += lod_ms + light_assembly_total_ms += float(mesh.timings.light_assembly_us) / USEC_PER_MSEC + sky_light_total_ms += float(mesh.timings.sky_light_us) / USEC_PER_MSEC + block_light_total_ms += float(mesh.timings.block_light_us) / USEC_PER_MSEC + padding_total_ms += float(mesh.timings.padding_us) / USEC_PER_MSEC + face_emit_total_ms += float(mesh.timings.face_emit_us) / USEC_PER_MSEC print(position, " max=", full.max_y, " full_mesh_ms=", full_ms, " tris=", mesh.indices.size() / 3, " lod_mesh_ms=", lod_ms, " lod_tris=", lod_mesh.indices.size() / 3) - print("MESH AVERAGE full=", full_total / positions.size(), " lod=", lod_total / positions.size()) + var sample_count: float = float(positions.size()) + var full_average_ms: float = full_total_ms / sample_count + var lod_average_ms: float = lod_total_ms / sample_count + var light_assembly_average_ms: float = light_assembly_total_ms / sample_count + var sky_light_average_ms: float = sky_light_total_ms / sample_count + var block_light_average_ms: float = block_light_total_ms / sample_count + var padding_average_ms: float = padding_total_ms / sample_count + var face_emit_average_ms: float = face_emit_total_ms / sample_count + print("MESH AVERAGE full=", full_average_ms, " lod=", lod_average_ms) + print("MESH PHASE AVERAGES ms assembly=%.3f sky=%.3f block=%.3f padding=%.3f emit=%.3f" % [ + light_assembly_average_ms, + sky_light_average_ms, + block_light_average_ms, + padding_average_ms, + face_emit_average_ms, + ]) + print("BENCHMARK_MARKDOWN_BEGIN") + print("### Worldgen mesh benchmark") + print("") + print("| Samples | Full mesh | Compact LOD mesh | Light assembly | Sky light | Block light | Padding | Face emission |") + print("|---:|---:|---:|---:|---:|---:|---:|---:|") + print("| %d | %.2f ms | %.2f ms | %.2f ms | %.2f ms | %.2f ms | %.2f ms | %.2f ms |" % [ + positions.size(), full_average_ms, lod_average_ms, light_assembly_average_ms, + sky_light_average_ms, block_light_average_ms, padding_average_ms, + face_emit_average_ms, + ]) + print("BENCHMARK_MARKDOWN_END") quit() diff --git a/tools/worldgen_ore_verify.gd b/tools/worldgen_ore_verify.gd new file mode 100644 index 0000000..a616ca9 --- /dev/null +++ b/tools/worldgen_ore_verify.gd @@ -0,0 +1,117 @@ +extends SceneTree + +const ORIGINS: Array[Vector2i] = [ + Vector2i(0, 0), + Vector2i(16, 0), + Vector2i(-16, -16), + Vector2i(320, -240), +] +const EXPECTED_DIGESTS := { + Vector2i(0, 0): "06c3fd62e76de1de59f2297d0f26c3b16c51c82df23ca3af34fed58b3085f564", + Vector2i(16, 0): "bbc4089cc87bc9991912079b14d651c148565043d801d5a0ff19546cb45c40ba", + Vector2i(-16, -16): "6090fdc16b1f2b0384ca3894e3c6944f129cf4e4418390ed539f69d9628ad94a", + Vector2i(320, -240): "4ac2a91eb2003603f629bbc05bc289f16d52f7ed39cbb1045b2a6dfc6595cc23", +} + +var _failures := PackedStringArray() + + +func _initialize() -> void: + var populator := VoxelPopulator.new(WorldGenConfig.new({ + "seed": 123456789, + "world_type": WorldGenConfig.WORLD_TYPE_NORMAL, + "worldgen_version": 8, + }), BiomeCatalog.new()) + _verify_selector(populator) + _verify_catalog(populator) + _verify_stage_fingerprints(populator) + _verify_replacement_semantics(populator) + if _failures.is_empty(): + print("WORLDGEN ORE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN ORE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_selector(populator: VoxelPopulator) -> void: + for y in [0, 20, 31, 32, 40, 57, 58, 60, 80, 89, 90, 120]: + for roll in 100: + var expected := _expected_ore(y, roll) + _expect(populator._ore_for_anchor(roll, y) == expected, + "selector changed at y=%d roll=%d" % [y, roll]) + _expect(BlockRegistry.BLOCK_COAL_ORE == 13, "coal ore ID changed") + _expect(BlockRegistry.BLOCK_IRON_ORE == 14, "iron ore ID changed") + _expect(BlockRegistry.BLOCK_GOLD_ORE == 15, "gold ore ID changed") + + +func _verify_catalog(populator: VoxelPopulator) -> void: + var rules: Array = populator._ore_catalog.rules + var expected := [ + [BlockRegistry.BLOCK_GOLD_ORE, 32, 17], + [BlockRegistry.BLOCK_IRON_ORE, 58, 34], + [BlockRegistry.BLOCK_COAL_ORE, 90, 57], + ] + _expect(rules.size() == expected.size(), "legacy ore catalog rule count changed") + for index in mini(rules.size(), expected.size()): + var rule: OreCatalog.OreRule = rules[index] + _expect([rule.block_id, rule.max_anchor_y_exclusive, rule.roll_exclusive] == expected[index], + "legacy ore catalog rule %d changed" % index) + + +func _verify_stage_fingerprints(populator: VoxelPopulator) -> void: + for origin in ORIGINS: + var data := _stone_volume() + populator._place_ore_veins(data, origin.x, origin.y) + var digest := _sha256(data) + print("ORE FIXTURE ", origin, " sha256=", digest, " counts=", _ore_counts(data)) + _expect(digest == String(EXPECTED_DIGESTS[origin]), + "ore fixture changed at %s: %s" % [origin, digest]) + + +func _verify_replacement_semantics(populator: VoxelPopulator) -> void: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + data.fill(BlockRegistry.BLOCK_COBBLESTONE) + var before := data.duplicate() + populator._place_ore_veins(data, 0, 0) + _expect(data == before, "ore generation replaced a non-stone block") + + +func _stone_volume() -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + data.fill(BlockRegistry.BLOCK_STONE) + return data + + +func _expected_ore(y: int, roll: int) -> int: + if y < 32 and roll < 17: + return BlockRegistry.BLOCK_GOLD_ORE + if y < 58 and roll < 34: + return BlockRegistry.BLOCK_IRON_ORE + if y < 90 and roll < 57: + return BlockRegistry.BLOCK_COAL_ORE + return BlockRegistry.BLOCK_AIR + + +func _sha256(data: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(data) + return context.finish().hex_encode() + + +func _ore_counts(data: PackedByteArray) -> Dictionary: + var counts := {13: 0, 14: 0, 15: 0} + for block_id in data: + if counts.has(block_id): + counts[block_id] += 1 + return counts + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_ore_verify.gd.uid b/tools/worldgen_ore_verify.gd.uid new file mode 100644 index 0000000..6d71f8b --- /dev/null +++ b/tools/worldgen_ore_verify.gd.uid @@ -0,0 +1 @@ +uid://bkg8r2mv7ieno diff --git a/tools/worldgen_poi_verify.gd b/tools/worldgen_poi_verify.gd new file mode 100644 index 0000000..7f432b6 --- /dev/null +++ b/tools/worldgen_poi_verify.gd @@ -0,0 +1,223 @@ +extends SceneTree + +## Exercises the v11 region-structure pass without relying on scene state. + +const CONFIG := { + "seed": 481516234, + "world_type": WorldGenConfig.WORLD_TYPE_NORMAL, + "worldgen_version": 11, + "cave_density": 0.0, + "decoration_density": 0.0, + "region_structures": true, +} + +var _failures := PackedStringArray() + + +func _initialize() -> void: + var generator := TerrainGenerator.new() + generator.configure(CONFIG) + var fixture := _find_cross_chunk_fixture(generator) + if fixture.is_empty(): + _expect(false, "could not locate an accepted cross-chunk region structure") + else: + _verify_catalog_determinism(fixture) + _verify_clipping_and_continuity(generator, fixture) + _verify_lod_tops(generator, fixture) + _verify_edits_last(generator, fixture) + _verify_gates(fixture) + if _failures.is_empty(): + print("WORLDGEN POI VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN POI VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _find_cross_chunk_fixture(generator: TerrainGenerator) -> Dictionary: + for owner_z in range(-5, 6): + for owner_x in range(-5, 6): + var candidate := StructureCatalog.candidate_for(int(CONFIG["seed"]), owner_x, owner_z) + if candidate == null or not _crosses_chunk_boundary(candidate.anchor): + continue + var anchor_chunk := Vector2i(WorldGenHash.floor_div(candidate.anchor.x, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(candidate.anchor.y, VoxelDefs.CHUNK_SIZE)) + var field := generator._sampler.build_field(anchor_chunk) + var scratch := VoxelPopulator.DecorationGroundScratch.new() + var ground := generator._sampler.sample_decoration_ground(candidate.anchor.x, candidate.anchor.y) + if not generator._populator._region_structure_site_is_valid(field, scratch, candidate, ground.x): + continue + return { + "candidate": candidate, + "ground_y": ground.x, + "chunks": _structure_chunks(candidate.anchor), + } + return {} + + +func _crosses_chunk_boundary(anchor: Vector2i) -> bool: + return WorldGenHash.floor_div(anchor.x - StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) \ + != WorldGenHash.floor_div(anchor.x + StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) \ + or WorldGenHash.floor_div(anchor.y - StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) \ + != WorldGenHash.floor_div(anchor.y + StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) + + +func _structure_chunks(anchor: Vector2i) -> Array[Vector2i]: + var chunks: Array[Vector2i] = [] + for chunk_z in range(WorldGenHash.floor_div(anchor.y - StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(anchor.y + StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) + 1): + for chunk_x in range(WorldGenHash.floor_div(anchor.x - StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(anchor.x + StructureCatalog.HORIZONTAL_HALO, VoxelDefs.CHUNK_SIZE) + 1): + chunks.append(Vector2i(chunk_x, chunk_z)) + return chunks + + +func _verify_catalog_determinism(fixture: Dictionary) -> void: + var candidate = fixture["candidate"] + var owner_x: int = WorldGenHash.floor_div(candidate.anchor.x, StructureCatalog.OWNER_CELL_SIZE) + var owner_z: int = WorldGenHash.floor_div(candidate.anchor.y, StructureCatalog.OWNER_CELL_SIZE) + var duplicate := StructureCatalog.candidate_for(int(CONFIG["seed"]), owner_x, owner_z) + _expect(duplicate != null and duplicate.kind == candidate.kind and duplicate.anchor == candidate.anchor \ + and duplicate.orientation == candidate.orientation and duplicate.hash_value == candidate.hash_value, + "owner-cell candidate is not deterministic") + + +func _verify_clipping_and_continuity(generator: TerrainGenerator, fixture: Dictionary) -> void: + var expected := _expected_blocks(fixture) + var chunks: Array[Vector2i] = fixture["chunks"] + var actual := _collect_blocks(generator, chunks, expected, false) + var reversed: Array[Vector2i] = chunks.duplicate() + reversed.reverse() + var repeated := _collect_blocks(generator, reversed, expected, false) + _expect(actual == expected, "cross-chunk structure clipping did not reproduce the catalog stamp") + _expect(repeated == expected and actual == repeated, + "cross-chunk structure continuity depends on generation order") + var sample: Vector2i = chunks[0] + var first := generator.generate_data(sample, {}, false) + var duplicate := generator.generate_data(sample, {}, false) + _expect(first.data == duplicate.data, "full-detail POI chunk is not deterministic") + + +func _verify_lod_tops(generator: TerrainGenerator, fixture: Dictionary) -> void: + var expected_tops := _expected_tops(fixture) + for chunk: Vector2i in fixture["chunks"]: + var full := generator.generate_data(chunk, {}, false) + var lod := generator.generate_data(chunk, {}, true) + var origin := chunk * VoxelDefs.CHUNK_SIZE + for structure_column in expected_tops: + if structure_column.x < origin.x or structure_column.x >= origin.x + VoxelDefs.CHUNK_SIZE \ + or structure_column.y < origin.y or structure_column.y >= origin.y + VoxelDefs.CHUNK_SIZE: + continue + var local_x: int = structure_column.x - origin.x + var local_z: int = structure_column.y - origin.y + var column: int = local_x + local_z * VoxelDefs.DATA_STRIDE_Z + var top: Vector2i = expected_tops[structure_column] + var full_top := _full_solid_top(full.data, full.max_y, column) + _expect(full_top == top, "full POI top differs from catalog at %s" % structure_column) + _expect(lod.lod_solid_y[column] == top.x and lod.lod_solid_id[column] == top.y, + "LOD/full POI top mismatch at %s" % structure_column) + + +func _verify_edits_last(generator: TerrainGenerator, fixture: Dictionary) -> void: + var expected_tops := _expected_tops(fixture) + for structure_column in expected_tops: + var top: Vector2i = expected_tops[structure_column] + var position := Vector3i(structure_column.x, top.x, structure_column.y) + var chunk := Vector2i(WorldGenHash.floor_div(position.x, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(position.z, VoxelDefs.CHUNK_SIZE)) + var edited := generator.generate_data(chunk, {position: BlockRegistry.BLOCK_AIR}, false) + var local_x: int = WorldGenHash.floor_mod(position.x, VoxelDefs.CHUNK_SIZE) + var local_z: int = WorldGenHash.floor_mod(position.z, VoxelDefs.CHUNK_SIZE) + var index: int = local_x + local_z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y + _expect(edited.data[index] == BlockRegistry.BLOCK_AIR, "saved edit did not override POI block at %s" % position) + return + _expect(false, "POI fixture had no solid stamp available for edit precedence") + + +func _verify_gates(fixture: Dictionary) -> void: + var chunks: Array[Vector2i] = fixture["chunks"] + var enabled := TerrainGenerator.new() + enabled.configure(CONFIG) + var disabled_config := CONFIG.duplicate() + disabled_config["region_structures"] = false + var disabled := TerrainGenerator.new() + disabled.configure(disabled_config) + var changed := false + for chunk in chunks: + if enabled.generate_data(chunk, {}, false).data != disabled.generate_data(chunk, {}, false).data: + changed = true + _expect(changed, "enabled region structures produced no gated output") + var legacy_config := CONFIG.duplicate() + legacy_config["worldgen_version"] = 10 + legacy_config["region_structures"] = true + var legacy_enabled := TerrainGenerator.new() + legacy_enabled.configure(legacy_config) + legacy_config["region_structures"] = false + var legacy_disabled := TerrainGenerator.new() + legacy_disabled.configure(legacy_config) + for chunk in chunks: + _expect(legacy_enabled.generate_data(chunk, {}, false).data == legacy_disabled.generate_data(chunk, {}, false).data, + "legacy v10 output changed when region_structures was requested at %s" % chunk) + + +func _expected_blocks(fixture: Dictionary) -> Dictionary: + var expected: Dictionary = {} + var candidate = fixture["candidate"] + var ground_y: int = int(fixture["ground_y"]) + for offset_z in range(-StructureCatalog.HORIZONTAL_HALO, StructureCatalog.HORIZONTAL_HALO + 1): + for offset_x in range(-StructureCatalog.HORIZONTAL_HALO, StructureCatalog.HORIZONTAL_HALO + 1): + for local_y in range(1, StructureCatalog.max_height(candidate.kind) + 1): + var block_id: int = StructureCatalog.block_at(candidate.kind, candidate.orientation, + offset_x, local_y, offset_z) + if block_id != BlockRegistry.BLOCK_AIR: + expected[Vector3i(candidate.anchor.x + offset_x, ground_y + local_y, + candidate.anchor.y + offset_z)] = block_id + return expected + + +func _expected_tops(fixture: Dictionary) -> Dictionary: + var tops: Dictionary = {} + var blocks := _expected_blocks(fixture) + for position in blocks: + var block_id: int = int(blocks[position]) + if block_id == BlockRegistry.BLOCK_TORCH: + continue + var column := Vector2i(position.x, position.z) + if not tops.has(column) or position.y > tops[column].x: + tops[column] = Vector2i(position.y, block_id) + return tops + + +func _collect_blocks(generator: TerrainGenerator, chunks: Array[Vector2i], expected: Dictionary, + lod: bool) -> Dictionary: + var actual: Dictionary = {} + for chunk in chunks: + var result := generator.generate_data(chunk, {}, lod) + if lod: + continue + var origin := chunk * VoxelDefs.CHUNK_SIZE + for position in expected: + if position.x < origin.x or position.x >= origin.x + VoxelDefs.CHUNK_SIZE \ + or position.z < origin.y or position.z >= origin.y + VoxelDefs.CHUNK_SIZE: + continue + var local_x: int = position.x - origin.x + var local_z: int = position.z - origin.y + var index: int = local_x + local_z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y + actual[position] = result.data[index] + return actual + + +func _full_solid_top(data: PackedByteArray, max_y: int, column: int) -> Vector2i: + for y in range(max_y, -1, -1): + var block_id: int = data[column + y * VoxelDefs.DATA_STRIDE_Y] + if block_id == BlockRegistry.BLOCK_AIR or block_id == BlockRegistry.BLOCK_TORCH: + continue + return Vector2i(y, block_id) + return Vector2i(-1, BlockRegistry.BLOCK_AIR) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_poi_verify.gd.uid b/tools/worldgen_poi_verify.gd.uid new file mode 100644 index 0000000..56fd808 --- /dev/null +++ b/tools/worldgen_poi_verify.gd.uid @@ -0,0 +1 @@ +uid://bggoxonlaixno diff --git a/tools/worldgen_spline_verify.gd b/tools/worldgen_spline_verify.gd new file mode 100644 index 0000000..5da716c --- /dev/null +++ b/tools/worldgen_spline_verify.gd @@ -0,0 +1,192 @@ +extends SceneTree + +var _failures := PackedStringArray() + +const V10_COMPATIBILITY_FINGERPRINT: int = 3534179881661516132 + + +func _initialize() -> void: + _verify_config_gate() + _verify_v10_splines() + _verify_v10_compatibility() + _verify_v11_splines() + _verify_v11_field_point_parity() + _verify_variant_diversity() + if _failures.is_empty(): + print("WORLDGEN SPLINE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN SPLINE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_config_gate() -> void: + _expect(not WorldGenConfig.new({"worldgen_version": 9, "spline_terrain": true}).spline_terrain, + "legacy world enabled spline terrain") + var enabled := WorldGenConfig.new({"worldgen_version": 10, "spline_terrain": true}) + _expect(enabled.spline_terrain, "version 10 did not retain explicit spline terrain") + _expect(bool(enabled.to_dictionary().get("spline_terrain", false)), + "spline terrain did not serialize") + _expect(not WorldGenConfig.new({"worldgen_version": 10}).spline_terrain, + "spline terrain is not default-off") + _expect(not WorldGenConfig.new({"worldgen_version": 10, "climate_variants": true}).climate_variants, + "legacy world enabled climate variants") + _expect(WorldGenConfig.new({"worldgen_version": 11}).climate_variants, + "version 11 did not enable climate variants by default") + _expect(not WorldGenConfig.new({"worldgen_version": 11, "climate_variants": false}).climate_variants, + "version 11 did not retain explicit climate-variant opt-out") + + +func _verify_v10_splines() -> void: + var sampler := _sampler(10, true) + for outputs in [TerrainSampler.CONTINENT_SPLINE_Y, TerrainSampler.PROFILE_SPLINE_Y]: + var previous := -1.0 + for step in 101: + var result: float = sampler._piecewise_cubic_remap(float(step) / 100.0, outputs) + _expect(result >= previous and result >= 0.0 and result <= 1.0, + "spline remap is not monotone and bounded") + previous = result + for knot in 5: + _expect(is_equal_approx(sampler._piecewise_cubic_remap(float(knot) * 0.25, outputs), + float(outputs[knot])), "spline remap missed knot %d" % knot) + + +## Fixed quantized samples protect the released v10 experimental terrain from +## accidental changes while v11 evolves independently. The input set includes +## coast, inland, and island regions rather than a single local patch. +func _verify_v10_compatibility() -> void: + var sampler := _sampler(10, true) + var fingerprint := 17 + for position in [Vector2i(-1024, -768), Vector2i(-256, 320), Vector2i(0, 0), + Vector2i(384, -640), Vector2i(960, 768), Vector2i(1536, -1152)]: + var sample := sampler.sample_point(position.x, position.y) + fingerprint = fingerprint * 31 + roundi(float(sample["continentalness"]) * 100000.0) + fingerprint = fingerprint * 31 + roundi(float(sample["final_height"]) * 100000.0) + fingerprint = fingerprint * 31 + int(sample["profile_id"]) + fingerprint = fingerprint * 31 + int(sample["dominant_biome_id"]) + _expect(fingerprint == V10_COMPATIBILITY_FINGERPRINT, + "v10 spline compatibility fingerprint changed (%d)" % fingerprint) + + +func _verify_v11_splines() -> void: + var sampler := _sampler(11, true) + for row in TerrainSampler.PROFILE_GRID: + _verify_monotone_bounded(sampler, row, "profile-grid row") + for column in range(TerrainSampler.PROFILE_GRID[0].size()): + var values: Array[float] = [] + for row in TerrainSampler.PROFILE_GRID: + values.append(float(row[column])) + _verify_monotone_bounded(sampler, values, "profile-grid column") + for continental_step in 21: + var previous := -1.0 + for landform_step in 101: + var result := sampler._profile_grid_sample( + float(continental_step) / 20.0, float(landform_step) / 100.0) + _expect(result >= previous - 0.000001 and result >= -0.000001 and result <= 1.000001, + "v11 profile grid overshot or reversed along landform") + previous = result + for landform_step in 21: + var previous := -1.0 + for continental_step in 101: + var result := sampler._profile_grid_sample( + float(continental_step) / 100.0, float(landform_step) / 20.0) + _expect(result >= previous - 0.000001 and result >= -0.000001 and result <= 1.000001, + "v11 profile grid overshot or reversed along continentalness") + previous = result + for row_index in TerrainSampler.PROFILE_GRID.size(): + for column_index in TerrainSampler.PROFILE_GRID[row_index].size(): + var result := sampler._profile_grid_sample(float(row_index) * 0.25, float(column_index) * 0.25) + _expect(is_equal_approx(result, float(TerrainSampler.PROFILE_GRID[row_index][column_index])), + "v11 profile grid missed knot (%d, %d)" % [row_index, column_index]) + + +func _verify_monotone_bounded(sampler: TerrainSampler, outputs: Array, label: String) -> void: + var previous := -1.0 + for step in 101: + var result: float = sampler._monotone_cubic_sample(float(step) / 100.0, outputs) + _expect(result >= previous - 0.000001 and result >= -0.000001 and result <= 1.000001, + "%s is not monotone and bounded" % label) + previous = result + + +func _verify_v11_field_point_parity() -> void: + var baseline := _sampler(11, false) + var enabled := _sampler(11, true) + var changed := 0 + for z in range(-512, 513, 64): + for x in range(-512, 513, 64): + var before := baseline.sample_point(x, z) + var after := enabled.sample_point(x, z) + if absf(float(before.final_height) - float(after.final_height)) > 0.01: + changed += 1 + _expect(changed >= 20, "v11 spline terrain did not materially alter sampled terrain") + var left := enabled.build_field(Vector2i.ZERO) + var right := enabled.build_field(Vector2i(1, 0)) + for local_z in range(-1, VoxelDefs.CHUNK_SIZE + 1): + var left_index := ChunkTerrainData.cell_index(VoxelDefs.CHUNK_SIZE, local_z) + var right_index := ChunkTerrainData.cell_index(0, local_z) + _expect(is_equal_approx(left.final_height[left_index], right.final_height[right_index]), + "v11 spline terrain height seam at z=%d" % local_z) + _expect(is_equal_approx(left.continentalness[left_index], right.continentalness[right_index]), + "v11 spline continentalness seam at z=%d" % local_z) + _expect(left.biome_id[left_index] == right.biome_id[right_index], + "v11 biome seam at z=%d" % local_z) + for chunk_pos in [Vector2i.ZERO, Vector2i(1, 0), Vector2i(-3, 2)]: + var field := enabled.build_field(chunk_pos) + for local_z in range(-1, VoxelDefs.CHUNK_SIZE + 1): + for local_x in range(-1, VoxelDefs.CHUNK_SIZE + 1): + var index := ChunkTerrainData.cell_index(local_x, local_z) + var world_x: int = chunk_pos.x * VoxelDefs.CHUNK_SIZE + local_x + var world_z: int = chunk_pos.y * VoxelDefs.CHUNK_SIZE + local_z + var point := enabled.sample_point(world_x, world_z) + var ground := enabled.sample_decoration_ground(world_x, world_z) + _expect(is_equal_approx(field.final_height[index], float(point["final_height"])), + "v11 field/point height parity at (%d, %d)" % [world_x, world_z]) + _expect(field.biome_id[index] == int(point["biome_id"]), + "v11 field/point biome parity at (%d, %d)" % [world_x, world_z]) + _expect(field.dominant_biome[index] == int(point["dominant_biome_id"]), + "v11 field/point dominant parity at (%d, %d)" % [world_x, world_z]) + _expect(ground.y == int(point["dominant_biome_id"]), + "v11 decoration-ground biome parity at (%d, %d)" % [world_x, world_z]) + var debug := enabled.sample_debug_point("biome", world_x, world_z) + _expect(int(debug["dominant_biome_id"]) == int(point["dominant_biome_id"]), + "v11 debug-biome parity at (%d, %d)" % [world_x, world_z]) + + +func _verify_variant_diversity() -> void: + var sampler := _sampler(11, true) + var seen := {} + for z in range(-8192, 8193, 128): + if seen.size() == 3: + break + for x in range(-8192, 8193, 128): + if seen.size() == 3: + break + var sample := sampler.sample_point(x, z) + var biome := int(sample["biome_id"]) + if biome in [BiomeCatalog.SNOWY_TAIGA, BiomeCatalog.WOODED_BADLANDS, BiomeCatalog.STONY_SHORE]: + seen[biome] = true + var debug := sampler.sample_debug_point("biome", x, z) + _expect(int(debug["biome_id"]) == biome, + "v11 debug biome omitted variant %d" % biome) + _expect(seen.has(BiomeCatalog.SNOWY_TAIGA), "v11 did not produce snowy taiga") + _expect(seen.has(BiomeCatalog.WOODED_BADLANDS), "v11 did not produce wooded badlands") + _expect(seen.has(BiomeCatalog.STONY_SHORE), "v11 did not produce stony shore") + + +func _sampler(version: int, enabled: bool) -> TerrainSampler: + var config := WorldGenConfig.new({ + "seed": 918273, + "worldgen_version": version, + "spline_terrain": enabled, + "climate_variants": true, + "hydraulic_erosion": false, + }) + return TerrainSampler.new(config, TerrainProfileCatalog.new(), BiomeCatalog.new()) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_spline_verify.gd.uid b/tools/worldgen_spline_verify.gd.uid new file mode 100644 index 0000000..72ad386 --- /dev/null +++ b/tools/worldgen_spline_verify.gd.uid @@ -0,0 +1 @@ +uid://beg0bg7qjhc7j diff --git a/tools/worldgen_stream_benchmark.gd b/tools/worldgen_stream_benchmark.gd index 0bb26b2..35b61a2 100644 --- a/tools/worldgen_stream_benchmark.gd +++ b/tools/worldgen_stream_benchmark.gd @@ -29,7 +29,7 @@ func _initialize() -> void: print("STREAM BENCH seed=", SEED, " workers=", OS.get_processor_count()) _breakdown(generator, mesher) for render_distance in [10, 16, 32]: - var lod_distance := mini(render_distance, VoxelWorld.MAX_FULL_DETAIL_DISTANCE) + var lod_distance := render_distance var positions := _ring(render_distance) var full := 0 for pos in positions: diff --git a/tools/worldgen_structure_verify.gd b/tools/worldgen_structure_verify.gd new file mode 100644 index 0000000..7579f40 --- /dev/null +++ b/tools/worldgen_structure_verify.gd @@ -0,0 +1,105 @@ +extends SceneTree + +var _failures := PackedStringArray() + + +func _initialize() -> void: + _verify_versioned_catalog() + _verify_boulder_stamp(Vector3i(16, 60, 16), 7109) + _verify_boulder_stamp(Vector3i(-16, 60, -16), 7109) + if _failures.is_empty(): + print("WORLDGEN STRUCTURE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN STRUCTURE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_versioned_catalog() -> void: + var v8 := DecorationCatalog.new(8).entries_for_set(BiomeCatalog.DECORATION_ALPINE) + var v9 := DecorationCatalog.new(9).entries_for_set(BiomeCatalog.DECORATION_ALPINE) + var v11_snow := DecorationCatalog.new(11).entries_for_set(BiomeCatalog.DECORATION_SNOWFIELD) + var v12_snow := DecorationCatalog.new(12).entries_for_set(BiomeCatalog.DECORATION_SNOWFIELD) + var v11_alpine := DecorationCatalog.new(11).entries_for_set(BiomeCatalog.DECORATION_ALPINE) + var v12_alpine := DecorationCatalog.new(12).entries_for_set(BiomeCatalog.DECORATION_ALPINE) + var v11_badlands := DecorationCatalog.new(11).entries_for_set(BiomeCatalog.DECORATION_BADLANDS) + var v12_badlands := DecorationCatalog.new(12).entries_for_set(BiomeCatalog.DECORATION_BADLANDS) + var legacy_catalog := DecorationCatalog.new(12) + var current_catalog := DecorationCatalog.new(13) + _expect(not _contains_feature(v8, DecorationCatalog.FEATURE_BOULDER), + "version 8 unexpectedly gained highland boulders") + _expect(_contains_feature(v9, DecorationCatalog.FEATURE_BOULDER), + "version 9 highland boulder is unreachable from the catalog") + _expect(_contains_feature(v11_snow, DecorationCatalog.FEATURE_ROCK_OUTCROP) \ + and _contains_feature(v11_alpine, DecorationCatalog.FEATURE_ROCK_OUTCROP) \ + and _contains_feature(v11_badlands, DecorationCatalog.FEATURE_ROCK_OUTCROP), + "version 11 rock-outcrop compatibility changed") + _expect(not _contains_feature(v12_snow, DecorationCatalog.FEATURE_ROCK_OUTCROP) \ + and not _contains_feature(v12_alpine, DecorationCatalog.FEATURE_ROCK_OUTCROP) \ + and not _contains_feature(v12_badlands, DecorationCatalog.FEATURE_ROCK_OUTCROP), + "version 12 still selects artificial cobblestone outcrops") + _expect(_contains_feature(v12_alpine, DecorationCatalog.FEATURE_BOULDER), + "version 12 unexpectedly removed rare alpine boulders") + for set_id in [BiomeCatalog.DECORATION_FOREST, BiomeCatalog.DECORATION_TROPICAL, BiomeCatalog.DECORATION_TAIGA]: + _expect(_contains_feature(legacy_catalog.entries_for_set(set_id), DecorationCatalog.FEATURE_FALLEN_LOG), + "version 12 fallen-log compatibility changed for set %d" % set_id) + _expect(not _contains_feature(current_catalog.entries_for_set(set_id), DecorationCatalog.FEATURE_FALLEN_LOG), + "version 13 still selects fallen logs for set %d" % set_id) + for set_id in [BiomeCatalog.DECORATION_SWAMP, BiomeCatalog.DECORATION_RIVERBANK, BiomeCatalog.DECORATION_BEACH]: + _expect(_contains_feature(legacy_catalog.entries_for_set(set_id), DecorationCatalog.FEATURE_DRIFTWOOD), + "version 12 driftwood compatibility changed for set %d" % set_id) + _expect(not _contains_feature(current_catalog.entries_for_set(set_id), DecorationCatalog.FEATURE_DRIFTWOOD), + "version 13 still selects driftwood for set %d" % set_id) + + +func _verify_boulder_stamp(anchor: Vector3i, hash_value: int) -> void: + var config := WorldGenConfig.new({"seed": 918273, "worldgen_version": 9}) + var populator := VoxelPopulator.new(config, BiomeCatalog.new()) + var actual: Dictionary = {} + var duplicate: Dictionary = {} + for chunk_z in range(WorldGenHash.floor_div(anchor.z - 2, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(anchor.z + 2, VoxelDefs.CHUNK_SIZE) + 1): + for chunk_x in range(WorldGenHash.floor_div(anchor.x - 2, VoxelDefs.CHUNK_SIZE), + WorldGenHash.floor_div(anchor.x + 2, VoxelDefs.CHUNK_SIZE) + 1): + var origin := Vector2i(chunk_x * VoxelDefs.CHUNK_SIZE, chunk_z * VoxelDefs.CHUNK_SIZE) + _collect_stamp(populator, actual, origin, anchor, hash_value) + _collect_stamp(populator, duplicate, origin, anchor, hash_value) + var expected: Dictionary = {} + var radius := 1 + hash_value % 2 + for dz in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + if dx * dx + dz * dz <= radius * radius: + expected[Vector3i(anchor.x + dx, anchor.y + 1, anchor.z + dz)] = true + _expect(actual == expected, "cross-chunk boulder stamp changed at %s" % anchor) + _expect(actual == duplicate, "boulder stamp is not deterministic at %s" % anchor) + for position in actual: + _expect(maxi(absi(position.x - anchor.x), absi(position.z - anchor.z)) <= 2, + "boulder exceeded its two-block structure halo") + _expect(position.y == anchor.y + 1, "boulder exceeded its one-block vertical footprint") + + +func _collect_stamp(populator: VoxelPopulator, output: Dictionary, origin: Vector2i, + anchor: Vector3i, hash_value: int) -> void: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + populator._stamp_boulder(data, origin.x, origin.y, anchor.x, anchor.y, anchor.z, hash_value) + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + var index := local_x + local_z * VoxelDefs.DATA_STRIDE_Z \ + + (anchor.y + 1) * VoxelDefs.DATA_STRIDE_Y + if data[index] == BlockRegistry.BLOCK_COBBLESTONE: + output[Vector3i(origin.x + local_x, anchor.y + 1, origin.y + local_z)] = true + + +func _contains_feature(entries: Array, feature: int) -> bool: + for entry in entries: + if int(entry[0]) == feature: + return true + return false + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_structure_verify.gd.uid b/tools/worldgen_structure_verify.gd.uid new file mode 100644 index 0000000..1f9f1e9 --- /dev/null +++ b/tools/worldgen_structure_verify.gd.uid @@ -0,0 +1 @@ +uid://bpghuuxio00tc diff --git a/tools/worldgen_tree_verify.gd b/tools/worldgen_tree_verify.gd index cb7a7c2..6ae2046 100644 --- a/tools/worldgen_tree_verify.gd +++ b/tools/worldgen_tree_verify.gd @@ -95,7 +95,8 @@ func _verify_tree_ground_validation() -> void: field.set_height(index, ground_y, ground_y, ground_y, 0.0) field.set_biome(index, BiomeCatalogScript.FOREST, BiomeCatalogScript.FOREST, 0, BiomeCatalogScript.FOREST) field.seal() - _check(populator._tree_site_is_safe(field, {}, 8, ground_y, 8, 3, 10), "solid generated terrain must permit a tree root") + _check(populator._tree_site_is_safe(field, VoxelPopulatorScript.DecorationGroundScratch.new(), + 8, ground_y, 8, 3, 10), "solid generated terrain must permit a tree root") # Entrance tunnels are the one cave operation permitted to touch a terrain # surface. The common immutable predicate, rather than owner-chunk voxel # state, must reject them for every canopy chunk. @@ -111,7 +112,9 @@ func _verify_tree_ground_validation() -> void: if entrance_found: break _check(entrance_found, "surface-cave fixture must find an immutable entrance exclusion") - _check(not cave_populator._tree_site_is_safe(field, {}, entrance_position.x, ground_y, entrance_position.y, 3, 10), "surface-cave roots must be excluded consistently before stamping") + _check(not cave_populator._tree_site_is_safe(field, VoxelPopulatorScript.DecorationGroundScratch.new(), + entrance_position.x, ground_y, entrance_position.y, 3, 10), + "surface-cave roots must be excluded consistently before stamping") func _verify_grove_distribution() -> void: diff --git a/tools/worldgen_water_verify.gd b/tools/worldgen_water_verify.gd new file mode 100644 index 0000000..04d36ea --- /dev/null +++ b/tools/worldgen_water_verify.gd @@ -0,0 +1,236 @@ +extends SceneTree + +const TEST_CONFIG := { + "seed": 123456789, + "world_type": 0, + "terrain_scale": 1.0, + "tree_density": 0.0, + "macro_scale": 384.0, + "river_density": 1.0, + "erosion_strength": 0.55, + "regional_erosion": 0.5, + "cave_density": 1.0, + "decoration_density": 0.0, +} + +const SEEDS: Array[int] = [123456789, 246813579, -987654321] + +var _failures := PackedStringArray() + + +func _initialize() -> void: + _verify_determinism() + _verify_legacy_compatibility() + _verify_adjacent_chunk_borders() + _verify_surface_and_river_protection() + if _failures.is_empty(): + print("WORLDGEN WATER VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN WATER VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_determinism() -> void: + var samples := 0 + for seed in SEEDS: + var populator := _populator_for(seed) + for world_z in range(-160, 161, 7): + for world_x in range(-160, 161, 5): + var first: int = populator._aquifer_water_level_at(world_x, world_z) + var second: int = populator._aquifer_water_level_at(world_x, world_z) + _expect(first == second, "aquifer table was not deterministic at %d, %d for seed %d" % [world_x, world_z, seed]) + samples += 1 + var chunk := Vector2i(-3, 4) + var field := _flat_field(chunk, 100, 0.0) + var first_data := _air_fixture() + var second_data := _air_fixture() + populator._fill_underground_liquids(first_data, field, chunk.x * VoxelDefs.CHUNK_SIZE, chunk.y * VoxelDefs.CHUNK_SIZE) + populator._fill_underground_liquids(second_data, field, chunk.x * VoxelDefs.CHUNK_SIZE, chunk.y * VoxelDefs.CHUNK_SIZE) + _expect(first_data == second_data, "aquifer population changed between identical runs for seed %d" % seed) + print("WORLDGEN WATER DETERMINISM: seeds=%d samples=%d" % [SEEDS.size(), samples]) + + +func _verify_legacy_compatibility() -> void: + var legacy_config: Dictionary = TEST_CONFIG.duplicate() + legacy_config["worldgen_version"] = 8 + var populator := VoxelPopulator.new(WorldGenConfig.new(legacy_config), BiomeCatalog.new()) + var fixture_chunk := Vector2i.ZERO + for chunk_z in range(-12, 13): + for chunk_x in range(-12, 13): + var aquifer_hash := WorldGenHash.hash_2d(int(legacy_config.seed) + 907, chunk_x, chunk_z) + if aquifer_hash % 11 == 0: + fixture_chunk = Vector2i(chunk_x, chunk_z) + break + if fixture_chunk != Vector2i.ZERO: + break + var data := _fill_air_fixture(populator, fixture_chunk, 100, 0.0) + var digest := _sha256(data) + print("WORLDGEN WATER LEGACY: chunk=%s sha256=%s" % [fixture_chunk, digest]) + _expect(fixture_chunk == Vector2i(-10, -12), "legacy aquifer fixture location changed: %s" % fixture_chunk) + _expect(digest == "5d0e4ac9ccea24e9786143e1ba7db9ae02fa1b3d404adc1244f9f54634a61e11", + "legacy aquifer fixture changed: %s" % digest) + + +func _verify_adjacent_chunk_borders() -> void: + var shared_water_faces := 0 + var static_boundary_walls := 0 + var table_mismatches := 0 + for seed in SEEDS: + var populator := _populator_for(seed) + for chunk_z in range(-5, 6): + for chunk_x in range(-5, 6): + var chunk := Vector2i(chunk_x, chunk_z) + for direction: Vector2i in [Vector2i.RIGHT, Vector2i.DOWN]: + var neighbor: Vector2i = chunk + direction + var first := _fill_air_fixture(populator, chunk, 100, 0.0) + var second := _fill_air_fixture(populator, neighbor, 100, 0.0) + var border := _border_metrics(first, second, direction) + shared_water_faces += int(border.shared) + static_boundary_walls += int(border.static_wall) + table_mismatches += _count_table_mismatches(populator, first, chunk) + table_mismatches += _count_table_mismatches(populator, second, neighbor) + _expect(shared_water_faces > 0, "no generated aquifer region crossed an adjacent chunk border") + _expect(static_boundary_walls == 0, "aquifer ownership produced %d static chunk-boundary water walls" % static_boundary_walls) + _expect(table_mismatches == 0, "generated aquifer blocks disagreed with the global table in %d fixture cells" % table_mismatches) + print("WORLDGEN WATER SEAMS: seeds=%d shared_faces=%d static_walls=%d table_mismatches=%d" % [SEEDS.size(), shared_water_faces, static_boundary_walls, table_mismatches]) + + +func _verify_surface_and_river_protection() -> void: + var protected_water_cells := 0 + var violations := 0 + for seed in SEEDS: + var populator := _populator_for(seed) + var chunk := _find_aquifer_chunk(populator) + var land_field := _flat_field(chunk, 100, 0.0) + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + var index := ChunkTerrainData.cell_index(local_x, local_z) + land_field.final_height[index] = 10.0 + float((local_x + local_z) % 7) + var land_data := _air_fixture() + populator._fill_underground_liquids(land_data, land_field, chunk.x * VoxelDefs.CHUNK_SIZE, chunk.y * VoxelDefs.CHUNK_SIZE) + violations += _count_protection_violations(land_data, land_field) + protected_water_cells += land_data.count(BlockRegistry.BLOCK_WATER) + + var river_field := _flat_field(chunk, 100, 0.75) + var river_data := _air_fixture() + populator._fill_underground_liquids(river_data, river_field, chunk.x * VoxelDefs.CHUNK_SIZE, chunk.y * VoxelDefs.CHUNK_SIZE) + violations += _count_protection_violations(river_data, river_field) + _expect(protected_water_cells > 0, "surface-clearance fixture did not contain an aquifer") + _expect(violations == 0, "generated aquifer water crossed the surface or river protection ceiling in %d cells" % violations) + print("WORLDGEN WATER PROTECTION: seeds=%d water_cells=%d violations=%d" % [SEEDS.size(), protected_water_cells, violations]) + + +func _populator_for(seed: int) -> VoxelPopulator: + var config: Dictionary = TEST_CONFIG.duplicate() + config["seed"] = seed + return VoxelPopulator.new(WorldGenConfig.new(config), BiomeCatalog.new()) + + +func _flat_field(chunk: Vector2i, height: int, river: float) -> ChunkTerrainData: + var field := ChunkTerrainData.new(chunk.x, chunk.y) + for index in field.final_height.size(): + field.final_height[index] = float(height) + field.river[index] = river + return field + + +func _air_fixture() -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for column in VoxelDefs.CHUNK_AREA: + data[column] = BlockRegistry.BLOCK_BEDROCK + return data + + +func _fill_air_fixture(populator: VoxelPopulator, chunk: Vector2i, height: int, river: float) -> PackedByteArray: + var data := _air_fixture() + populator._fill_underground_liquids(data, _flat_field(chunk, height, river), chunk.x * VoxelDefs.CHUNK_SIZE, chunk.y * VoxelDefs.CHUNK_SIZE) + return data + + +func _border_metrics(first: PackedByteArray, second: PackedByteArray, direction: Vector2i) -> Dictionary: + var shared := 0 + var static_wall := false + for y in range(4, 28): + var first_water_count := 0 + var second_water_count := 0 + for offset in VoxelDefs.CHUNK_SIZE: + var first_block: int + var second_block: int + if direction == Vector2i.RIGHT: + first_block = _block(first, VoxelDefs.CHUNK_SIZE - 1, y, offset) + second_block = _block(second, 0, y, offset) + else: + first_block = _block(first, offset, y, VoxelDefs.CHUNK_SIZE - 1) + second_block = _block(second, offset, y, 0) + if first_block == BlockRegistry.BLOCK_WATER and second_block == BlockRegistry.BLOCK_WATER: + shared += 1 + if first_block == BlockRegistry.BLOCK_WATER: + first_water_count += 1 + if second_block == BlockRegistry.BLOCK_WATER: + second_water_count += 1 + # The retired chunk-local model produced an entire 16-block face of + # water beside an entirely dry neighbor at every table depth. A circular + # global region may have a natural edge, but cannot make this ownership + # pattern along a complete chunk face. + if (first_water_count == VoxelDefs.CHUNK_SIZE and second_water_count == 0) \ + or (second_water_count == VoxelDefs.CHUNK_SIZE and first_water_count == 0): + static_wall = true + return {"shared": shared, "static_wall": static_wall} + + +func _count_table_mismatches(populator: VoxelPopulator, data: PackedByteArray, chunk: Vector2i) -> int: + var mismatches := 0 + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + var table: int = populator._aquifer_water_level_at(chunk.x * VoxelDefs.CHUNK_SIZE + local_x, chunk.y * VoxelDefs.CHUNK_SIZE + local_z) + for y in range(4, 28): + var expected_water := table >= y + var actual_water := _block(data, local_x, y, local_z) == BlockRegistry.BLOCK_WATER + if expected_water != actual_water: + mismatches += 1 + return mismatches + + +func _find_aquifer_chunk(populator: VoxelPopulator) -> Vector2i: + for chunk_z in range(-12, 13): + for chunk_x in range(-12, 13): + var world_x: int = chunk_x * VoxelDefs.CHUNK_SIZE + VoxelDefs.CHUNK_SIZE / 2 + var world_z: int = chunk_z * VoxelDefs.CHUNK_SIZE + VoxelDefs.CHUNK_SIZE / 2 + if populator._aquifer_water_level_at(world_x, world_z) >= 4: + return Vector2i(chunk_x, chunk_z) + _failures.append("could not find an aquifer fixture region") + return Vector2i.ZERO + + +func _count_protection_violations(data: PackedByteArray, field: ChunkTerrainData) -> int: + var violations := 0 + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + var index := ChunkTerrainData.cell_index(local_x, local_z) + var ceiling: int = int(roundi(field.final_height[index])) - VoxelPopulator.SURFACE_CLEARANCE + if field.river[index] >= 0.62: + ceiling = mini(ceiling, VoxelDefs.SEA_LEVEL - 3) + for y in range(maxi(4, ceiling + 1), VoxelDefs.WORLD_HEIGHT): + if _block(data, local_x, y, local_z) == BlockRegistry.BLOCK_WATER: + violations += 1 + return violations + + +func _block(data: PackedByteArray, x: int, y: int, z: int) -> int: + return data[x + z * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y] + + +func _sha256(data: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(data) + return context.finish().hex_encode() + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_water_verify.gd.uid b/tools/worldgen_water_verify.gd.uid new file mode 100644 index 0000000..006a6cc --- /dev/null +++ b/tools/worldgen_water_verify.gd.uid @@ -0,0 +1 @@ +uid://cgkvqck3au7vk diff --git a/ui/README.md b/ui/README.md index 8642d0a..03cd481 100644 --- a/ui/README.md +++ b/ui/README.md @@ -20,9 +20,20 @@ The interface uses a shared "Deepslate & Ember" design system built from native ## Menu hierarchy -- **Play** opens `play_panel` (world type, seed, clipboard import, Create Game). Its - **Advanced** button opens `world_gen_panel`, which owns only the detailed world - tunables; `build_config()` returns them and the play screen merges seed/type in. +- **Play** opens `play_panel`, a landing hub: **Continue** (ember, when a last + saved world exists), **New World**, and **Load World** (disabled while the + library is empty). **New World** reveals the creation form (world type, seed, + clipboard import, Create Game); its **Advanced** button opens + `world_gen_panel`, which owns only the detailed world tunables; + `build_config()` returns them and the play screen merges seed/type in. + **Load World** lists every saved world (name, type, seed, created, last + played) via `WorldStorage.list_world_summaries()`, which does not rewrite the + "last world" pointer. Cards select on first + activation, load on the second (or double-click, or Load World); Delete + swaps the footer for an inline confirmation whose safe choice takes focus. + Incompatible worlds (newer metadata/worldgen format) list but cannot load. + `ui_cancel` unwinds one layer at a time: delete confirmation -> load list or + create form -> landing -> main menu, restoring focus to each layer's opener. - **Settings** opens `settings_menu`, a category hub. Categories are `settings_category_panel` instances (`category` property): Display (render distance + extreme toggle, FOV, fullscreen, UI scale, text size, V-Sync, FPS diff --git a/ui/main_menu.gd b/ui/main_menu.gd index 1da3c5f..5fec122 100644 --- a/ui/main_menu.gd +++ b/ui/main_menu.gd @@ -54,6 +54,7 @@ func _ready() -> void: _play_panel.closed.connect(_on_panel_closed) _play_panel.advanced_requested.connect(_on_play_advanced) _play_panel.create_requested.connect(_on_create_world) + _play_panel.load_requested.connect(_on_load_world) _settings_menu.closed.connect(_on_panel_closed) _world_gen_panel.closed.connect(_on_world_gen_closed) $Center/Column/PlayButton.grab_focus() @@ -254,9 +255,24 @@ func _on_create_world(seed: int, world_type: int) -> void: config["seed"] = seed config["world_type"] = world_type GameConfig.apply_world(config) + # Main creates the durable world only after the gameplay scene loads, so a + # failed scene change cannot leave an empty world in the library. + GameConfig.clear_active_world() _start_game() +func _on_load_world(world_id: String) -> void: + var storage := WorldStorage.new() + var metadata := storage.open_world(world_id) + if metadata.is_empty(): + # Removed on disk, unreadable, or from a newer build: the play panel + # refreshes its lists so the stale entry disappears. + _play_panel.notify_load_failed() + return + if GameConfig.activate_world(metadata): + _start_game() + + func _start_game() -> void: get_tree().change_scene_to_file("res://game/main.tscn") diff --git a/ui/motion.gd b/ui/motion.gd index cbcf5d8..64450af 100644 --- a/ui/motion.gd +++ b/ui/motion.gd @@ -38,19 +38,22 @@ static func dim_in(dim: ColorRect, duration: float = 0.16) -> void: ## Staggered entrance for a column of controls (menu buttons). static func stagger_in(controls: Array, step: float = 0.055, duration: float = 0.26) -> void: for control in controls: - if control is Control: + if is_instance_valid(control) and control is Control: (control as Control).modulate.a = 0.0 var tree: SceneTree = null for control in controls: - if control is Control and (control as Control).get_tree() != null: + if is_instance_valid(control) and control is Control and (control as Control).get_tree() != null: tree = (control as Control).get_tree() break if tree == null: return await tree.process_frame for index in controls.size(): - var control: Control = controls[index] - if control == null or not is_instance_valid(control) or not control.visible: + var candidate: Variant = controls[index] + if candidate == null or not is_instance_valid(candidate) or not (candidate is Control): + continue + var control := candidate as Control + if not control.visible: continue control.pivot_offset = control.size * 0.5 control.scale = Vector2(0.985, 0.985) diff --git a/ui/play_panel.gd b/ui/play_panel.gd index 432f850..b4a3d1d 100644 --- a/ui/play_panel.gd +++ b/ui/play_panel.gd @@ -1,18 +1,38 @@ class_name PlayPanel extends Control -## World setup screen: pick a type and seed (or import one), then create. The -## detailed terrain/hydrology knobs live behind Advanced in WorldGenPanel. +## Play hub. The landing offers Continue / New World / Load World; New World +## reveals the creation form (world type, seed, clipboard import, and the +## Advanced world-gen screen), Load World lists every saved world with +## metadata and supports select-to-load plus delete-with-confirmation. +## `ui_cancel` unwinds one layer at a time: delete confirmation -> load list +## or create form -> landing -> closed. The world-gen screen handles its own +## cancel first because it sits above this panel in the tree. signal closed signal advanced_requested signal create_requested(seed: int, world_type: int) +signal load_requested(world_id: String) + +enum View { LANDING, CREATE, LOAD } const WORLD_TYPES := ["Normal", "Flat", "Amplified"] +const MONTHS := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] +const CREATE_PANEL_WIDTH := 560.0 +const LOAD_PANEL_WIDTH := 640.0 +const VIEWPORT_MARGIN := 48.0 +const STAGGER_LIMIT := 6 +const SECONDS_PER_MINUTE := 60.0 +const SECONDS_PER_HOUR := 3600.0 +const SECONDS_PER_DAY := 86400.0 +const SECONDS_PER_WEEK := 604800.0 @onready var _panel: PanelContainer = $Center/Panel @onready var _heading: Label = $Center/Panel/Box/Heading +@onready var _footer: HBoxContainer = $Center/Panel/Box/Footer +@onready var _type_row: HBoxContainer = $Center/Panel/Box/TypeRow @onready var _type_option: OptionButton = $Center/Panel/Box/TypeRow/TypeOption +@onready var _seed_row: HBoxContainer = $Center/Panel/Box/SeedRow @onready var _seed_field: LineEdit = $Center/Panel/Box/SeedRow/SeedField @onready var _import_button: Button = $Center/Panel/Box/SeedRow/ImportButton @onready var _random_button: Button = $Center/Panel/Box/SeedRow/RandomSeedButton @@ -20,11 +40,46 @@ const WORLD_TYPES := ["Normal", "Flat", "Amplified"] @onready var _back_button: Button = $Center/Panel/Box/Footer/BackButton @onready var _create_button: Button = $Center/Panel/Box/Footer/CreateButton @onready var _dim: ColorRect = $Dim +@onready var _landing_box: VBoxContainer = $Center/Panel/Box/LandingBox +@onready var _continue_button: Button = $Center/Panel/Box/LandingBox/ContinueButton +@onready var _new_world_button: Button = $Center/Panel/Box/LandingBox/NewWorldButton +@onready var _load_world_button: Button = $Center/Panel/Box/LandingBox/LoadWorldButton +@onready var _landing_hint: Label = $Center/Panel/Box/LandingBox/LandingHint +@onready var _landing_back_button: Button = $Center/Panel/Box/LandingBox/LandingFooter/LandingBackButton +@onready var _load_box: VBoxContainer = $Center/Panel/Box/LoadBox +@onready var _load_scroll: ScrollContainer = $Center/Panel/Box/LoadBox/LoadScroll +@onready var _world_list: VBoxContainer = $Center/Panel/Box/LoadBox/LoadScroll/WorldList +@onready var _empty_state: VBoxContainer = $Center/Panel/Box/LoadBox/LoadScroll/EmptyState +@onready var _empty_title: Label = $Center/Panel/Box/LoadBox/LoadScroll/EmptyState/EmptyTitle +@onready var _empty_body: Label = $Center/Panel/Box/LoadBox/LoadScroll/EmptyState/EmptyBody +@onready var _empty_create_button: Button = $Center/Panel/Box/LoadBox/LoadScroll/EmptyState/EmptyCreateButton +@onready var _confirm_row: HBoxContainer = $Center/Panel/Box/LoadBox/ConfirmRow +@onready var _confirm_label: Label = $Center/Panel/Box/LoadBox/ConfirmRow/ConfirmLabel +@onready var _cancel_delete_button: Button = $Center/Panel/Box/LoadBox/ConfirmRow/CancelDeleteButton +@onready var _confirm_delete_button: Button = $Center/Panel/Box/LoadBox/ConfirmRow/ConfirmDeleteButton +@onready var _load_footer: HBoxContainer = $Center/Panel/Box/LoadBox/LoadFooter +@onready var _delete_all_button: Button = $Center/Panel/Box/LoadBox/LoadFooter/DeleteAllButton +@onready var _delete_button: Button = $Center/Panel/Box/LoadBox/LoadFooter/DeleteButton +@onready var _load_back_button: Button = $Center/Panel/Box/LoadBox/LoadFooter/LoadBackButton +@onready var _load_button: Button = $Center/Panel/Box/LoadBox/LoadFooter/LoadButton + +## Library root is injectable so verifiers can point the list at a scratch +## directory; the game always uses WorldStorage's default. +var _library_root: String = WorldStorage.DEFAULT_ROOT +var _view: int = View.LANDING +var _confirming_delete := false +var _confirming_delete_all := false +var _selected_world_id := "" +var _continue_world_id := "" +var _cards: Dictionary = {} +var _world_names: Dictionary = {} +var _landing_return: Control = null func _ready() -> void: UITheme.apply(self) _style_static() + _load_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED for type_name in WORLD_TYPES: _type_option.add_item(type_name) _random_button.pressed.connect(func() -> void: @@ -32,43 +87,477 @@ func _ready() -> void: ) _import_button.pressed.connect(_on_import) _advanced_button.pressed.connect(func() -> void: advanced_requested.emit()) - _back_button.pressed.connect(close_panel) + _back_button.pressed.connect(func() -> void: _back_to_landing(_new_world_button)) _create_button.pressed.connect(_on_create) _seed_field.text_submitted.connect(func(_text: String) -> void: _on_create()) + _continue_button.pressed.connect(func() -> void: + if not _continue_world_id.is_empty(): + load_requested.emit(_continue_world_id) + ) + _new_world_button.pressed.connect(func() -> void: _show_view(View.CREATE)) + _load_world_button.pressed.connect(func() -> void: _show_view(View.LOAD)) + _landing_back_button.pressed.connect(close_panel) + _empty_create_button.pressed.connect(func() -> void: _show_view(View.CREATE)) + _load_footer.move_child(_delete_all_button, _delete_button.get_index()) + _delete_all_button.pressed.connect(_begin_delete_all_confirmation) + _delete_button.pressed.connect(_begin_delete_confirmation) + _load_back_button.pressed.connect(func() -> void: _back_to_landing(_load_world_button)) + _load_button.pressed.connect(_load_selected) + _cancel_delete_button.pressed.connect(_cancel_delete) + _confirm_delete_button.pressed.connect(_confirm_delete) + # A live interface-scale change moves the logical viewport under the open + # modal, so re-fit the panel and list instead of clipping them. + GameConfig.interface_scale_changed.connect(_apply_view_metrics) func _unhandled_input(event: InputEvent) -> void: - if visible and event.is_action_pressed("ui_cancel"): - close_panel() + if not visible: + return + if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() + if _confirming_delete: + _cancel_delete() + elif _view == View.LANDING: + close_panel() + else: + _back_to_landing(_new_world_button if _view == View.CREATE else _load_world_button) + return + if _view == View.LOAD and not _confirming_delete and not _selected_world_id.is_empty() \ + and event is InputEventKey and event.pressed and not event.echo: + if (event as InputEventKey).keycode == KEY_DELETE: + get_viewport().set_input_as_handled() + _begin_delete_confirmation() func open_panel() -> void: - _panel.custom_minimum_size.x = minf(560.0, get_viewport().get_visible_rect().size.x - 48.0) - _seed_field.text = str(GameConfig.get_world_seed()) - _type_option.selected = clampi(GameConfig.get_world_type(), 0, WORLD_TYPES.size() - 1) + _landing_return = null + _confirming_delete = false + _confirming_delete_all = false visible = true + _show_view(View.LANDING) Motion.dim_in(_dim) Motion.pop_in(_panel) - _seed_field.grab_focus() func close_panel() -> void: if not visible: return + _confirming_delete = false + _confirming_delete_all = false visible = false closed.emit() +## The advanced world-gen screen is only reachable from the creation form, so +## returning to it always restores the create view (and its Advanced button). func focus_advanced() -> void: - if visible: - _advanced_button.grab_focus() + if not visible: + return + if _view != View.CREATE: + _show_view(View.CREATE) + _advanced_button.grab_focus() + + +## Called by MainMenu when storage rejects the requested world (removed on +## disk, unreadable, or incompatible): refresh whatever view is showing. +func notify_load_failed() -> void: + if not visible: + return + if _view == View.LOAD: + _refresh_load_view() + elif _view == View.LANDING: + _refresh_landing() + + +func _show_view(view: int) -> void: + _view = view + _landing_box.visible = view == View.LANDING + _type_row.visible = view == View.CREATE + _seed_row.visible = view == View.CREATE + _footer.visible = view == View.CREATE + _load_box.visible = view == View.LOAD + match view: + View.LANDING: + _heading.text = "Worlds" + _refresh_landing() + var focus_target := _landing_return + _landing_return = null + if not _can_focus(focus_target): + focus_target = _continue_button if _continue_button.visible else _new_world_button + focus_target.grab_focus() + Motion.stagger_in(_visible_landing_buttons()) + View.CREATE: + _heading.text = "New World" + _seed_field.text = str(GameConfig.get_world_seed()) + _type_option.selected = clampi(GameConfig.get_world_type(), 0, WORLD_TYPES.size() - 1) + _seed_field.grab_focus() + View.LOAD: + _heading.text = "Load World" + _refresh_load_view() + _apply_view_metrics() + + +func _back_to_landing(opener: Control) -> void: + if _confirming_delete: + _cancel_delete() + _landing_return = opener + _show_view(View.LANDING) + + +# ------------------------------------------------------------- landing view -- + +func _refresh_landing() -> void: + var saved := WorldStorage.latest_world_metadata(_library_root) + _continue_world_id = String(saved.get("id", "")) + _continue_button.visible = not _continue_world_id.is_empty() + if _continue_button.visible: + _continue_button.text = "Continue — %s" % String(saved.get("name", "Last World")) + var total := WorldStorage.list_world_summaries(_library_root).size() + _load_world_button.disabled = total == 0 + _load_world_button.tooltip_text = "Browse saved worlds" if total > 0 else "No saved worlds yet" + if total == 0: + _landing_hint.text = "No saved worlds yet — create one to get started." + else: + _landing_hint.text = "%d saved %s on this device." % [total, "world" if total == 1 else "worlds"] + # Ember marks the single fastest path: resuming when possible, creating otherwise. + if _continue_button.visible: + UITheme.style_button_primary(_continue_button) + UITheme.style_button_ghost(_new_world_button) + else: + UITheme.style_button_ghost(_continue_button) + UITheme.style_button_primary(_new_world_button) + + +func _visible_landing_buttons() -> Array: + var buttons: Array = [_continue_button, _new_world_button, _load_world_button] + var visible_buttons: Array = [] + for button in buttons: + if button != null and is_instance_valid(button) and (button as Button).visible: + visible_buttons.append(button) + return visible_buttons + + +# ---------------------------------------------------------- load world view -- + +func _refresh_load_view() -> void: + for card in _cards.values(): + card.queue_free() + _cards.clear() + _world_names.clear() + _selected_world_id = "" + _confirming_delete = false + _confirming_delete_all = false + _confirm_row.visible = false + _load_footer.visible = true + _confirm_delete_button.text = "Delete" + var worlds := WorldStorage.list_world_summaries(_library_root) + var entrance: Array = [] + for world in worlds: + var card := _build_card(world) + _world_list.add_child(card) + var id := String(world["id"]) + _cards[id] = card + _world_names[id] = _display_name(world) + if entrance.size() < STAGGER_LIMIT: + entrance.append(card) + var has_worlds := not worlds.is_empty() + _world_list.visible = has_worlds + _empty_state.visible = not has_worlds + _delete_button.disabled = true + _delete_button.tooltip_text = "Select a world first" + _delete_all_button.disabled = not has_worlds + _delete_all_button.tooltip_text = "Delete every saved world (with confirmation)" if has_worlds \ + else "No saved worlds to delete" + _load_button.disabled = true + _load_button.tooltip_text = "Select a world to load" + _apply_view_metrics() + if has_worlds: + (_cards.values()[0] as Button).grab_focus() + Motion.stagger_in(entrance) + else: + _empty_create_button.grab_focus() + + +func _build_card(world: Dictionary) -> Button: + var id := String(world["id"]) + var compatible := bool(world.get("compatible", true)) + var card := Button.new() + card.name = "WorldCard_" + id + card.text = "" + card.toggle_mode = false + card.focus_mode = Control.FOCUS_ALL + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.custom_minimum_size = Vector2(0.0, 62.0) + card.set_meta("world_id", id) + card.set_meta("compatible", compatible) + if compatible: + card.tooltip_text = "Enter or double-click to load; Delete removes this world" + else: + card.tooltip_text = "Saved by a newer version of RedotCraft; it cannot be loaded yet." + + var bar := UITheme.accent_bar(UITheme.EMBER) + bar.name = "AccentBar" + bar.visible = false + card.add_child(bar) + + var margin := MarginContainer.new() + margin.name = "Content" + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + margin.mouse_filter = Control.MOUSE_FILTER_IGNORE + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_bottom", 8) + card.add_child(margin) + + var row := HBoxContainer.new() + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_theme_constant_override("separation", 14) + margin.add_child(row) + + var details := VBoxContainer.new() + details.mouse_filter = Control.MOUSE_FILTER_IGNORE + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + details.alignment = BoxContainer.ALIGNMENT_CENTER + row.add_child(details) + + var title := Label.new() + title.text = _display_name(world) + title.add_theme_font_override("font", UITheme.font_semi()) + title.add_theme_color_override("font_color", UITheme.INK) + UITheme.apply_font_size(title, UITheme.SIZE_BODY) + title.mouse_filter = Control.MOUSE_FILTER_IGNORE + details.add_child(title) + + var meta := Label.new() + meta.text = "%s · Seed %d · Created %s" % [ + WORLD_TYPES[clampi(int(world.get("world_type", 0)), 0, WORLD_TYPES.size() - 1)], + int(world.get("seed", 0)), + _format_date(int(world.get("created_unix", 0))), + ] + meta.add_theme_color_override("font_color", UITheme.MUTED) + UITheme.apply_font_size(meta, 13) + meta.mouse_filter = Control.MOUSE_FILTER_IGNORE + details.add_child(meta) + + var stats := VBoxContainer.new() + stats.mouse_filter = Control.MOUSE_FILTER_IGNORE + stats.custom_minimum_size = Vector2(112.0, 0.0) + stats.size_flags_vertical = Control.SIZE_SHRINK_CENTER + stats.alignment = BoxContainer.ALIGNMENT_CENTER + row.add_child(stats) + + var caption := Label.new() + caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + caption.add_theme_font_override("font", UITheme.font_eyebrow()) + caption.add_theme_color_override("font_color", UITheme.FAINT) + UITheme.apply_font_size(caption, 11) + caption.mouse_filter = Control.MOUSE_FILTER_IGNORE + stats.add_child(caption) + + var stat_value := Label.new() + stat_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + stat_value.add_theme_font_override("font", UITheme.font_semi()) + UITheme.apply_font_size(stat_value, UITheme.SIZE_VALUE) + stat_value.mouse_filter = Control.MOUSE_FILTER_IGNORE + stats.add_child(stat_value) + if compatible: + caption.text = "LAST PLAYED" + stat_value.add_theme_color_override("font_color", UITheme.CYAN) + stat_value.text = _relative_time(int(world.get("updated_unix", 0))) + else: + caption.text = "VERSION" + stat_value.add_theme_color_override("font_color", UITheme.WARN) + stat_value.text = "INCOMPATIBLE" + + _style_card(card, false) + card.pressed.connect(_on_card_activated.bind(id)) + card.gui_input.connect(_on_card_gui_input.bind(id)) + return card + + +func _style_card(card: Button, selected: bool) -> void: + var fill := Color(UITheme.EMBER.r, UITheme.EMBER.g, UITheme.EMBER.b, 0.13) if selected \ + else Color(UITheme.SURFACE_HI.r, UITheme.SURFACE_HI.g, UITheme.SURFACE_HI.b, 0.4) + var border := Color(UITheme.EMBER.r, UITheme.EMBER.g, UITheme.EMBER.b, 0.5) if selected \ + else UITheme.LINE_HI + var normal := UITheme.panel_style(fill, border, 1, 8) + normal.content_margin_left = 16.0 + normal.content_margin_right = 14.0 + normal.content_margin_top = 6.0 + normal.content_margin_bottom = 6.0 + var hover_fill := Color(UITheme.EMBER.r, UITheme.EMBER.g, UITheme.EMBER.b, 0.2) if selected \ + else Color(UITheme.SURFACE_HI.r, UITheme.SURFACE_HI.g, UITheme.SURFACE_HI.b, 0.85) + var hover_border := Color(UITheme.EMBER.r, UITheme.EMBER.g, UITheme.EMBER.b, 0.65) if selected \ + else Color(UITheme.EMBER.r, UITheme.EMBER.g, UITheme.EMBER.b, 0.5) + var hover := UITheme.panel_style(hover_fill, hover_border, 1, 8) + hover.content_margin_left = 16.0 + hover.content_margin_right = 14.0 + hover.content_margin_top = 6.0 + hover.content_margin_bottom = 6.0 + card.add_theme_stylebox_override("normal", normal) + card.add_theme_stylebox_override("hover", hover) + var bar := card.get_node_or_null("AccentBar") + if bar != null: + bar.visible = selected + + +func _on_card_activated(id: String) -> void: + if _confirming_delete: + return + if id == _selected_world_id: + _load_selected() + else: + _select_world(id) + + +func _on_card_gui_input(event: InputEvent, id: String) -> void: + if _confirming_delete: + return + if event is InputEventMouseButton and (event as InputEventMouseButton).double_click: + _select_world(id) + _load_selected() + + +func _select_world(id: String) -> void: + _selected_world_id = id + for card_id in _cards: + _style_card(_cards[card_id] as Button, card_id == id) + var card := _cards.get(id) as Button + var compatible := card != null and bool(card.get_meta("compatible", true)) + _delete_button.disabled = false + _delete_button.tooltip_text = "Delete the selected world (with confirmation)" + _load_button.disabled = not compatible + _load_button.tooltip_text = "Load the selected world" if compatible \ + else "The selected world was saved by a newer version" + + +func _load_selected() -> void: + if _confirming_delete or _selected_world_id.is_empty(): + return + var card := _cards.get(_selected_world_id) as Button + if card == null or not bool(card.get_meta("compatible", true)): + return + load_requested.emit(_selected_world_id) + + +func _begin_delete_confirmation() -> void: + if _confirming_delete or _selected_world_id.is_empty(): + return + _confirming_delete = true + _confirming_delete_all = false + var world_name := String(_world_names.get(_selected_world_id, "this world")) + _confirm_label.text = "Delete \"%s\"? Its blocks and progress are removed permanently." % world_name + _load_footer.visible = false + _confirm_row.visible = true + # The safe choice takes focus first; the destructive one is one Tab away. + _cancel_delete_button.grab_focus() + + +func _begin_delete_all_confirmation() -> void: + if _confirming_delete: + return + var world_count := WorldStorage.list_world_summaries(_library_root).size() + if world_count == 0: + return + _confirming_delete = true + _confirming_delete_all = true + _confirm_label.text = "Delete all %d saved %s? Every world's blocks and progress will be removed permanently." % [ + world_count, + "world" if world_count == 1 else "worlds", + ] + _confirm_delete_button.text = "Delete All" + _load_footer.visible = false + _confirm_row.visible = true + _cancel_delete_button.grab_focus() + + +func _cancel_delete() -> void: + if not _confirming_delete: + return + var focus_target: Button = _delete_all_button if _confirming_delete_all else _delete_button + _confirming_delete = false + _confirming_delete_all = false + _confirm_row.visible = false + _load_footer.visible = true + _confirm_delete_button.text = "Delete" + focus_target.grab_focus() + + +func _confirm_delete() -> void: + if not _confirming_delete: + return + if _confirming_delete_all: + _confirm_delete_all() + return + var id := _selected_world_id + _confirming_delete = false + _confirming_delete_all = false + _confirm_row.visible = false + _load_footer.visible = true + _confirm_delete_button.text = "Delete" + if WorldStorage.delete_world(id, _library_root) and GameConfig.active_world_id == id: + GameConfig.clear_active_world() + _refresh_load_view() + + +func _confirm_delete_all() -> void: + var active_world_id := GameConfig.active_world_id + var deleted_active_world := false + for world in WorldStorage.list_world_summaries(_library_root): + var id := String(world.get("id", "")) + if WorldStorage.delete_world(id, _library_root) and id == active_world_id: + deleted_active_world = true + if deleted_active_world: + GameConfig.clear_active_world() + _confirming_delete = false + _confirming_delete_all = false + _confirm_row.visible = false + _load_footer.visible = true + _confirm_delete_button.text = "Delete" + _refresh_load_view() + + +# ------------------------------------------------------------- create view -- + +func _on_import() -> void: + var text := DisplayServer.clipboard_get().strip_edges() + if not text.is_empty(): + _seed_field.text = text + _seed_field.caret_column = text.length() + + +func _on_create() -> void: + var seed_text := _seed_field.text.strip_edges() + var seed_value := 0 + if seed_text.is_valid_int(): + seed_value = int(seed_text) + elif not seed_text.is_empty(): + seed_value = seed_text.hash() & 0x7FFFFFFF + else: + seed_value = randi() % 1000000000 + create_requested.emit(seed_value, _type_option.selected) + + +# -------------------------------------------------------------- chrome/sizing + +func _apply_view_metrics() -> void: + var viewport_size := get_viewport().get_visible_rect().size + var width := LOAD_PANEL_WIDTH if _view == View.LOAD else CREATE_PANEL_WIDTH + _panel.custom_minimum_size.x = minf(width, maxf(viewport_size.x - VIEWPORT_MARGIN, width * 0.6)) + if _view != View.LOAD: + return + # The list scrolls once it no longer fits between the header and footer. + var content_height := maxf(_world_list.get_combined_minimum_size().y, _empty_state.get_combined_minimum_size().y) + _load_scroll.custom_minimum_size.y = content_height + var chrome := _panel.get_combined_minimum_size().y - _load_scroll.custom_minimum_size.y + var max_height := maxf(viewport_size.y - chrome - VIEWPORT_MARGIN, 120.0) + _load_scroll.custom_minimum_size.y = minf(content_height, max_height) func _style_static() -> void: _dim.color = Color(UITheme.VOID.r, UITheme.VOID.g, UITheme.VOID.b, 0.68) _panel.add_theme_stylebox_override("panel", UITheme.modal_style()) - _heading.text = "Create World" _heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT UITheme.style_heading(_heading) var box := _heading.get_parent() as VBoxContainer @@ -83,6 +572,31 @@ func _style_static() -> void: UITheme.style_button_ghost(_advanced_button) UITheme.style_button_ghost(_back_button) UITheme.style_button_primary(_create_button) + UITheme.style_button_ghost(_new_world_button) + UITheme.style_button_ghost(_load_world_button) + UITheme.style_button_ghost(_landing_back_button) + UITheme.style_button_ghost(_delete_all_button) + UITheme.style_button_ghost(_delete_button) + UITheme.style_button_ghost(_load_back_button) + UITheme.style_button_primary(_load_button) + UITheme.style_button_ghost(_cancel_delete_button) + UITheme.style_button_primary(_confirm_delete_button) + UITheme.style_button_ghost(_empty_create_button) + _landing_hint.add_theme_color_override("font_color", UITheme.MUTED) + UITheme.apply_font_size(_landing_hint, 13) + _confirm_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _confirm_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _confirm_label.add_theme_color_override("font_color", UITheme.WARN) + UITheme.apply_font_size(_confirm_label, 14) + _empty_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _empty_title.add_theme_font_override("font", UITheme.font_semi()) + _empty_title.add_theme_color_override("font_color", UITheme.INK_DIM) + UITheme.apply_font_size(_empty_title, 18) + _empty_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _empty_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _empty_body.add_theme_color_override("font_color", UITheme.MUTED) + UITheme.apply_font_size(_empty_body, 13) + _empty_state.custom_minimum_size = Vector2(0.0, 240.0) func _style_row_label(label: Label, text: String) -> void: @@ -91,20 +605,39 @@ func _style_row_label(label: Label, text: String) -> void: label.add_theme_font_override("font", UITheme.font_semi()) -func _on_import() -> void: - var text := DisplayServer.clipboard_get().strip_edges() - if not text.is_empty(): - _seed_field.text = text - _seed_field.caret_column = text.length() +# ------------------------------------------------------------ presentation -- +func _display_name(world: Dictionary) -> String: + var name_text := String(world.get("name", "")) + if not name_text.is_empty(): + return name_text + return "World %d" % int(world.get("seed", 0)) -func _on_create() -> void: - var seed_text := _seed_field.text.strip_edges() - var seed_value := 0 - if seed_text.is_valid_int(): - seed_value = int(seed_text) - elif not seed_text.is_empty(): - seed_value = seed_text.hash() & 0x7FFFFFFF - else: - seed_value = randi() % 1000000000 - create_requested.emit(seed_value, _type_option.selected) + +func _can_focus(control: Control) -> bool: + if control == null or not is_instance_valid(control) or not control.visible \ + or control.focus_mode == Control.FOCUS_NONE: + return false + return not (control is BaseButton and (control as BaseButton).disabled) + + +func _relative_time(unix_time: int) -> String: + var delta := maxi(int(Time.get_unix_time_from_system()) - unix_time, 0) + if delta < SECONDS_PER_MINUTE: + return "just now" + if delta < SECONDS_PER_HOUR: + return "%d min ago" % floori(float(delta) / SECONDS_PER_MINUTE) + if delta < SECONDS_PER_DAY: + return "%d hr ago" % floori(float(delta) / SECONDS_PER_HOUR) + if delta < SECONDS_PER_WEEK: + return "%d d ago" % floori(float(delta) / SECONDS_PER_DAY) + return _format_date(unix_time) + + +func _format_date(unix_time: int) -> String: + var date := Time.get_datetime_dict_from_unix_time(unix_time) + var now := Time.get_datetime_dict_from_system() + var text := "%s %d" % [MONTHS[clampi(int(date["month"]) - 1, 0, MONTHS.size() - 1)], int(date["day"])] + if int(date["year"]) != int(now["year"]): + text += ", %d" % int(date["year"]) + return text diff --git a/ui/play_panel.tscn b/ui/play_panel.tscn index d42aa23..a56955d 100644 --- a/ui/play_panel.tscn +++ b/ui/play_panel.tscn @@ -3,16 +3,18 @@ [ext_resource type="Script" path="res://ui/play_panel.gd" id="1_play"] [node name="PlayPanel" type="Control"] +visible = false layout_mode = 3 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -visible = false script = ExtResource("1_play") [node name="Dim" type="ColorRect" parent="."] layout_mode = 1 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 @@ -21,14 +23,15 @@ color = Color(0, 0.02, 0.03, 0.62) [node name="Center" type="CenterContainer" parent="."] layout_mode = 1 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 [node name="Panel" type="PanelContainer" parent="Center"] -layout_mode = 2 custom_minimum_size = Vector2(560, 0) +layout_mode = 2 [node name="Box" type="VBoxContainer" parent="Center/Panel"] layout_mode = 2 @@ -36,18 +39,46 @@ theme_override_constants/separation = 14 [node name="Heading" type="Label" parent="Center/Panel/Box"] layout_mode = 2 -theme_override_colors/font_color = Color(0.545, 0.827, 0.78, 1) theme_override_font_sizes/font_size = 30 -text = "CREATE WORLD" +theme_override_colors/font_color = Color(0.545, 0.827, 0.78, 1) +text = "WORLDS" horizontal_alignment = 1 +[node name="LandingBox" type="VBoxContainer" parent="Center/Panel/Box"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="ContinueButton" type="Button" parent="Center/Panel/Box/LandingBox"] +layout_mode = 2 +text = "Continue" + +[node name="NewWorldButton" type="Button" parent="Center/Panel/Box/LandingBox"] +layout_mode = 2 +text = "New World" + +[node name="LoadWorldButton" type="Button" parent="Center/Panel/Box/LandingBox"] +layout_mode = 2 +text = "Load World" + +[node name="LandingHint" type="Label" parent="Center/Panel/Box/LandingBox"] +layout_mode = 2 +text = "No saved worlds yet" + +[node name="LandingFooter" type="HBoxContainer" parent="Center/Panel/Box/LandingBox"] +layout_mode = 2 +alignment = 2 + +[node name="LandingBackButton" type="Button" parent="Center/Panel/Box/LandingBox/LandingFooter"] +layout_mode = 2 +text = "Back" + [node name="TypeRow" type="HBoxContainer" parent="Center/Panel/Box"] layout_mode = 2 theme_override_constants/separation = 14 [node name="TypeLabel" type="Label" parent="Center/Panel/Box/TypeRow"] -layout_mode = 2 custom_minimum_size = Vector2(160, 0) +layout_mode = 2 [node name="TypeOption" type="OptionButton" parent="Center/Panel/Box/TypeRow"] layout_mode = 2 @@ -58,8 +89,8 @@ layout_mode = 2 theme_override_constants/separation = 14 [node name="SeedLabel" type="Label" parent="Center/Panel/Box/SeedRow"] -layout_mode = 2 custom_minimum_size = Vector2(160, 0) +layout_mode = 2 [node name="SeedField" type="LineEdit" parent="Center/Panel/Box/SeedRow"] layout_mode = 2 @@ -73,6 +104,80 @@ text = "Import" layout_mode = 2 text = "Random" +[node name="LoadBox" type="VBoxContainer" parent="Center/Panel/Box"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="LoadScroll" type="ScrollContainer" parent="Center/Panel/Box/LoadBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="WorldList" type="VBoxContainer" parent="Center/Panel/Box/LoadBox/LoadScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="EmptyState" type="VBoxContainer" parent="Center/Panel/Box/LoadBox/LoadScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 +alignment = 1 + +[node name="EmptyTitle" type="Label" parent="Center/Panel/Box/LoadBox/LoadScroll/EmptyState"] +layout_mode = 2 +text = "No saved worlds yet" + +[node name="EmptyBody" type="Label" parent="Center/Panel/Box/LoadBox/LoadScroll/EmptyState"] +layout_mode = 2 +text = "Worlds you create are stored on this device and appear here." + +[node name="EmptySpacer" type="Control" parent="Center/Panel/Box/LoadBox/LoadScroll/EmptyState"] +custom_minimum_size = Vector2(0, 6) +layout_mode = 2 + +[node name="EmptyCreateButton" type="Button" parent="Center/Panel/Box/LoadBox/LoadScroll/EmptyState"] +layout_mode = 2 +text = "Create New World" + +[node name="ConfirmRow" type="HBoxContainer" parent="Center/Panel/Box/LoadBox"] +visible = false +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="ConfirmLabel" type="Label" parent="Center/Panel/Box/LoadBox/ConfirmRow"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Delete this world?" + +[node name="CancelDeleteButton" type="Button" parent="Center/Panel/Box/LoadBox/ConfirmRow"] +layout_mode = 2 +text = "Cancel" + +[node name="ConfirmDeleteButton" type="Button" parent="Center/Panel/Box/LoadBox/ConfirmRow"] +layout_mode = 2 +text = "Delete" + +[node name="LoadFooter" type="HBoxContainer" parent="Center/Panel/Box/LoadBox"] +layout_mode = 2 +theme_override_constants/separation = 10 +alignment = 2 + +[node name="DeleteButton" type="Button" parent="Center/Panel/Box/LoadBox/LoadFooter"] +layout_mode = 2 +text = "Delete..." + +[node name="LoadBackButton" type="Button" parent="Center/Panel/Box/LoadBox/LoadFooter"] +layout_mode = 2 +text = "Back" + +[node name="LoadButton" type="Button" parent="Center/Panel/Box/LoadBox/LoadFooter"] +layout_mode = 2 +text = "Load World" + +[node name="DeleteAllButton" type="Button" parent="Center/Panel/Box/LoadBox/LoadFooter"] +layout_mode = 2 +text = "Delete All…" + [node name="Footer" type="HBoxContainer" parent="Center/Panel/Box"] layout_mode = 2 theme_override_constants/separation = 10 diff --git a/ui/settings_category_panel.gd b/ui/settings_category_panel.gd index 438dbea..b2ab6ab 100644 --- a/ui/settings_category_panel.gd +++ b/ui/settings_category_panel.gd @@ -13,7 +13,7 @@ signal setting_changed(key: String, value: Variant) const RENDER_DISTANCE_MAX := 32.0 const RENDER_DISTANCE_EXTREME_MAX := 100.0 -const EXTREME_WARNING := "Warning: extreme render distance can take minutes to load and use several GB of memory." +const EXTREME_WARNING := "Warning: extreme render distance can take minutes to load and use several GB of memory. Full Detail generates every selected chunk; choose Balanced terrain detail to reduce memory and loading time." @onready var _panel: PanelContainer = $Center/Panel @onready var _heading: Label = $Center/Panel/Box/Heading @@ -112,6 +112,7 @@ func _category_definition() -> Dictionary: "advanced": false, "rows": [ {"type": "render_distance", "label": "Render Distance", "key": "render_distance", "min": 4.0, "max": RENDER_DISTANCE_MAX, "step": 1.0, "format": "%d chunks"}, + {"type": "option", "label": "Terrain Detail", "key": "lod_mode", "options": GameConfig.LOD_MODE_NAMES, "tooltip": "Full Detail generates real chunks through the selected distance. Balanced uses compact terrain beyond 8 chunks."}, {"type": "slider", "label": "Field of View", "key": "fov", "min": 60.0, "max": 100.0, "step": 1.0, "format": "%d"}, {"type": "check", "label": "Fullscreen", "key": "fullscreen", "window": true}, {"type": "option", "label": "UI Scale", "key": "ui_scale", "options": GameConfig.UI_SCALE_NAMES, "values": GameConfig.UI_SCALE_VALUES, "tooltip": "Scales the HUD and menus without changing the 3D render resolution."}, diff --git a/ui/world_gen_panel.gd b/ui/world_gen_panel.gd index 7755ab1..53bfe6a 100644 --- a/ui/world_gen_panel.gd +++ b/ui/world_gen_panel.gd @@ -3,6 +3,14 @@ extends Control signal closed +## The creation UI intentionally exposes a conservative subset of the wider +## import/config validation envelope in WorldGenConfig. +const UI_TERRAIN_RANGE := Vector3(0.5, 2.0, 0.05) +const UI_MACRO_RANGE := Vector3(192.0, 1024.0, 32.0) +const UI_BIOME_RANGE := Vector3(384.0, 4096.0, 64.0) +const UI_DENSITY_RANGE := Vector3(0.0, 2.0, 0.05) +const UI_STRENGTH_RANGE := Vector3(0.0, 1.0, 0.05) + @onready var _panel: PanelContainer = $Center/Panel @onready var _heading: Label = $Center/Panel/Box/Heading @onready var _hint: Label = $Center/Panel/Box/Hint @@ -20,6 +28,10 @@ var _regional_erosion_slider: HSlider var _hydraulic_toggle: CheckButton var _caves_slider: HSlider var _decoration_slider: HSlider +var _spline_terrain_toggle: CheckButton +var _elevated_hydrology_toggle: CheckButton +var _climate_variants_toggle: CheckButton +var _region_structures_toggle: CheckButton func _ready() -> void: @@ -30,39 +42,51 @@ func _ready() -> void: var terrain := _section("TERRAIN") _terrain_slider = UITheme.slider_row( - terrain, "Terrain Scale", 0.5, 2.0, 0.05, + terrain, "Terrain Scale", UI_TERRAIN_RANGE.x, UI_TERRAIN_RANGE.y, UI_TERRAIN_RANGE.z, GameConfig.get_terrain_scale(), "%d%%", 100.0) _macro_slider = UITheme.slider_row( - terrain, "Landmass Scale", 192.0, 1024.0, 32.0, - float(GameConfig.world.get("macro_scale", 384.0)), "%d blocks", 1.0) + terrain, "Landmass Scale", UI_MACRO_RANGE.x, UI_MACRO_RANGE.y, UI_MACRO_RANGE.z, + float(GameConfig.world.get("macro_scale", WorldGenConfig.DEFAULT_MACRO_SCALE)), "%d blocks", 1.0) _biome_slider = UITheme.slider_row( - terrain, "Biome Scale", 384.0, 4096.0, 64.0, - float(GameConfig.world.get("biome_scale", 3072.0)), "%d blocks", 1.0) + terrain, "Biome Scale", UI_BIOME_RANGE.x, UI_BIOME_RANGE.y, UI_BIOME_RANGE.z, + float(GameConfig.world.get("biome_scale", WorldGenConfig.DEFAULT_BIOME_SCALE)), "%d blocks", 1.0) + _spline_terrain_toggle = _inline_toggle( + terrain, "Spline Terrain", "Experimental", + bool(GameConfig.world.get("spline_terrain", WorldGenConfig.DEFAULT_SPLINE_TERRAIN))) + _climate_variants_toggle = _inline_toggle( + terrain, "Climate Variants", "Third climate channel", + bool(GameConfig.world.get("climate_variants", WorldGenConfig.DEFAULT_CLIMATE_VARIANTS))) var water := _section("WATER & EROSION") _rivers_slider = UITheme.slider_row( - water, "River Density", 0.0, 2.0, 0.05, - float(GameConfig.world.get("river_density", 1.0)), "%d%%", 100.0) + water, "River Density", UI_DENSITY_RANGE.x, UI_DENSITY_RANGE.y, UI_DENSITY_RANGE.z, + float(GameConfig.world.get("river_density", WorldGenConfig.DEFAULT_RIVER_DENSITY)), "%d%%", 100.0) _erosion_slider = UITheme.slider_row( - water, "Slope Erosion", 0.0, 1.0, 0.05, - float(GameConfig.world.get("erosion_strength", 0.55)), "%d%%", 100.0) + water, "Slope Erosion", UI_STRENGTH_RANGE.x, UI_STRENGTH_RANGE.y, UI_STRENGTH_RANGE.z, + float(GameConfig.world.get("erosion_strength", WorldGenConfig.DEFAULT_EROSION_STRENGTH)), "%d%%", 100.0) _regional_erosion_slider = UITheme.slider_row( - water, "Regional Erosion", 0.0, 1.0, 0.05, - float(GameConfig.world.get("regional_erosion", 0.5)), "%d%%", 100.0) + water, "Regional Erosion", UI_STRENGTH_RANGE.x, UI_STRENGTH_RANGE.y, UI_STRENGTH_RANGE.z, + float(GameConfig.world.get("regional_erosion", WorldGenConfig.DEFAULT_REGIONAL_EROSION)), "%d%%", 100.0) _hydraulic_toggle = _inline_toggle( water, "Hydraulic Erosion", "High quality (slower)", - bool(GameConfig.world.get("hydraulic_erosion", false))) + bool(GameConfig.world.get("hydraulic_erosion", WorldGenConfig.DEFAULT_HYDRAULIC_EROSION))) + _elevated_hydrology_toggle = _inline_toggle( + water, "Elevated Hydrology", "Experimental", + bool(GameConfig.world.get("elevated_hydrology", WorldGenConfig.DEFAULT_ELEVATED_HYDROLOGY))) var features := _section("FEATURES") _trees_slider = UITheme.slider_row( - features, "Tree Density", 0.0, 2.0, 0.05, + features, "Tree Density", UI_DENSITY_RANGE.x, UI_DENSITY_RANGE.y, UI_DENSITY_RANGE.z, GameConfig.get_tree_density(), "%d%%", 100.0) _caves_slider = UITheme.slider_row( - features, "Cave Density", 0.0, 2.0, 0.05, - float(GameConfig.world.get("cave_density", 1.0)), "%d%%", 100.0) + features, "Cave Density", UI_DENSITY_RANGE.x, UI_DENSITY_RANGE.y, UI_DENSITY_RANGE.z, + float(GameConfig.world.get("cave_density", WorldGenConfig.DEFAULT_CAVE_DENSITY)), "%d%%", 100.0) _decoration_slider = UITheme.slider_row( - features, "Ground Cover", 0.0, 2.0, 0.05, - float(GameConfig.world.get("decoration_density", 1.0)), "%d%%", 100.0) + features, "Ground Cover", UI_DENSITY_RANGE.x, UI_DENSITY_RANGE.y, UI_DENSITY_RANGE.z, + float(GameConfig.world.get("decoration_density", WorldGenConfig.DEFAULT_DECORATION_DENSITY)), "%d%%", 100.0) + _region_structures_toggle = _inline_toggle( + features, "Region Structures", "Camps and ruins", + bool(GameConfig.world.get("region_structures", WorldGenConfig.DEFAULT_REGION_STRUCTURES))) func _wrap_rows_in_scroll() -> void: @@ -165,4 +189,8 @@ func build_config() -> Dictionary: "hydraulic_erosion": _hydraulic_toggle.button_pressed, "cave_density": snappedf(_caves_slider.value, 0.05), "decoration_density": snappedf(_decoration_slider.value, 0.05), + "spline_terrain": _spline_terrain_toggle.button_pressed, + "elevated_hydrology": _elevated_hydrology_toggle.button_pressed, + "climate_variants": _climate_variants_toggle.button_pressed, + "region_structures": _region_structures_toggle.button_pressed, } diff --git a/world/chunk_mesher.gd b/world/chunk_mesher.gd index e36caf4..1d943fc 100644 --- a/world/chunk_mesher.gd +++ b/world/chunk_mesher.gd @@ -23,11 +23,21 @@ var _water_level: PackedByteArray = PackedByteArray() var _layer_top: PackedInt32Array = PackedInt32Array() var _layer_bottom: PackedInt32Array = PackedInt32Array() var _layer_side: PackedInt32Array = PackedInt32Array() +var _emissive: PackedByteArray = PackedByteArray() var _emission_r: PackedByteArray = PackedByteArray() var _emission_g: PackedByteArray = PackedByteArray() var _emission_b: PackedByteArray = PackedByteArray() +class MeshTimings: + var light_assembly_us: int = 0 + var sky_light_us: int = 0 + var block_light_us: int = 0 + var padding_us: int = 0 + var face_emit_us: int = 0 + var total_us: int = 0 + + class MeshResult: var data := PackedByteArray() var heights := PackedInt32Array() @@ -50,6 +60,9 @@ class MeshResult: var water_light := PackedFloat32Array() var water_indices := PackedInt32Array() var light_volume: LightVolume + ## Worker-side phase timings in microseconds. Kept on the transient result so + ## benchmarks can diagnose regressions without mutable global counters. + var timings := MeshTimings.new() ## Distant full chunks skip collision triangles; approaching them rebuilds ## the chunk with collision enabled. var build_collision := true @@ -105,7 +118,13 @@ class NeighborSample: ## Compact neighbors answer per-voxel occupancy without materializing a tile. func block_at(local_x: int, y: int, local_z: int) -> int: if not lod: - return data[local_x + local_z * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y] + var index := local_x + local_z * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y + # Neighbor snapshots are immutable worker inputs. Treat a malformed or + # stale short snapshot as an opaque-boundary miss instead of flooding the + # runtime with out-of-bounds errors; gatherers normally provide full data. + if index < 0 or index >= data.size(): + return BlockRegistry.BLOCK_AIR + return data[index] var column := local_x + local_z * VoxelDefs.DATA_STRIDE_Z var top: int = lod_solid_y[column] if y > top: @@ -150,6 +169,9 @@ func _init(blocks: BlockRegistry) -> void: ## Thread-safe: only reads immutable block tables once constructed. func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, foliage_tints: PackedColorArray, water_tints: PackedColorArray, neighbors: NeighborSet, want_collision: bool = true) -> MeshResult: + # Scratch buffers remain call-local: this immutable mesher is shared by + # concurrent WorkerThreadPool jobs, so a mutable member pool would race. + var total_start: int = Time.get_ticks_usec() var result := MeshResult.new() result.data = data result.heights = heights @@ -161,11 +183,18 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo # local meshing bound only; storing it made max_y grow on every remesh. result.max_y = data_max_y var max_y := clampi(data_max_y + 1, 1, VoxelDefs.WORLD_HEIGHT - 1) + var phase_start: int = Time.get_ticks_usec() var light_volume := _assemble_light_volume(data, data_max_y, heights, neighbors) + result.timings.light_assembly_us = Time.get_ticks_usec() - phase_start + phase_start = Time.get_ticks_usec() _compute_sky_light(light_volume) + result.timings.sky_light_us = Time.get_ticks_usec() - phase_start + phase_start = Time.get_ticks_usec() _compute_block_light(light_volume) + result.timings.block_light_us = Time.get_ticks_usec() - phase_start result.light_volume = light_volume var pad_height := max_y + 3 + phase_start = Time.get_ticks_usec() var padded := PackedByteArray() padded.resize(VoxelDefs.PAD_W * VoxelDefs.PAD_W * pad_height) for pad_y in range(0, pad_height): @@ -195,6 +224,8 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo var neighbor_z := local_z - dz * VoxelDefs.CHUNK_SIZE id = sample.block_at(neighbor_x, y, neighbor_z) padded[pad_x + pad_z * VoxelDefs.PAD_STRIDE_Z + pad_y * VoxelDefs.PAD_STRIDE_Y] = id + result.timings.padding_us = Time.get_ticks_usec() - phase_start + phase_start = Time.get_ticks_usec() for y in range(0, max_y + 1): for local_z in VoxelDefs.CHUNK_SIZE: for local_x in VoxelDefs.CHUNK_SIZE: @@ -229,6 +260,8 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo if not is_opaque and neighbor_id == id and _leaves[id] == 0: continue _append_face(face, pad_index, local_x, y, local_z, id, padded, tint, result) + result.timings.face_emit_us = Time.get_ticks_usec() - phase_start + result.timings.total_us = Time.get_ticks_usec() - total_start result.light_volume = null return result @@ -247,6 +280,7 @@ const LOD_DEPTH_DARKEN: float = 0.055 const LOD_MIN_SIDE_SHADE: float = 0.5 func build_lod(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: PackedByteArray, water_y: PackedInt32Array, water_level: PackedByteArray, data_max_y: int, foliage_tints: PackedColorArray, water_tints: PackedColorArray, neighbors: LodNeighbors) -> MeshResult: + var total_start: int = Time.get_ticks_usec() var result := MeshResult.new() result.foliage_tints = foliage_tints result.water_tints = water_tints @@ -257,6 +291,7 @@ func build_lod(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: Pac result.lod_sub_id = sub_id result.lod_water_y = water_y result.lod_water_level = water_level + var face_emit_start: int = Time.get_ticks_usec() for z in VoxelDefs.CHUNK_SIZE: for x in VoxelDefs.CHUNK_SIZE: var column := x + z * VoxelDefs.DATA_STRIDE_Z @@ -317,6 +352,8 @@ func build_lod(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: Pac var run_from := maxi(water_from, neighbor_water + 1) _append_lod_water_run(face, x, run_from, top_water, z, _water_top(level), level < 8, _column_tint(column, water_tints), result) + result.timings.face_emit_us = Time.get_ticks_usec() - face_emit_start + result.timings.total_us = Time.get_ticks_usec() - total_start return result @@ -510,6 +547,7 @@ func _build_light_tables() -> void: _layer_top.resize(256) _layer_bottom.resize(256) _layer_side.resize(256) + _emissive.resize(256) _emission_r.resize(256) _emission_g.resize(256) _emission_b.resize(256) @@ -529,6 +567,7 @@ func _build_light_tables() -> void: _layer_top[id] = _blocks.layer_for(id, 0) _layer_bottom[id] = _blocks.layer_for(id, 1) _layer_side[id] = _blocks.layer_for(id, 2) + _emissive[id] = 1 if BlockRegistry.EMISSIVE_COLORS.has(id) else 0 var color := _blocks.emission_color(id) if color == Color.BLACK: continue @@ -786,85 +825,87 @@ func _compute_sky_light(volume: LightVolume) -> void: func _compute_block_light(volume: LightVolume) -> void: var blocks := volume.blocks - var found_emitter := false - for block_id in BlockRegistry.EMISSIVE_COLORS: - if blocks.find(block_id) != -1: - found_emitter = true - break - if not found_emitter: - return var size := blocks.size() var area := volume.w * volume.d - volume.block_r.resize(size) - volume.block_g.resize(size) - volume.block_b.resize(size) - var red := volume.block_r - var green := volume.block_g - var blue := volume.block_b - var queue := PackedInt32Array() + var found_emitter := false + var red: PackedByteArray + var green: PackedByteArray + var blue: PackedByteArray + var queue: PackedInt32Array var head := 0 var volume_z := 0 var y := 0 var vx := 0 - for block_id in BlockRegistry.EMISSIVE_COLORS: - var emitter := blocks.find(block_id) + for emitter in size: + var block_id := blocks[emitter] + if _emissive[block_id] == 0: + continue + if not found_emitter: + found_emitter = true + volume.block_r.resize(size) + volume.block_g.resize(size) + volume.block_b.resize(size) + red = volume.block_r + green = volume.block_g + blue = volume.block_b + queue = PackedInt32Array() var seed_r := _emission_r[block_id] var seed_g := _emission_g[block_id] var seed_b := _emission_b[block_id] if seed_r <= 0 and seed_g <= 0 and seed_b <= 0: continue var emitter_opaque := _opacity[block_id] >= MAX_LEVEL - while emitter != -1: - var remainder := int(emitter / volume.w) - vx = emitter % volume.w - volume_z = remainder % volume.d - y = int(remainder / volume.d) - if not emitter_opaque: - if red[emitter] < seed_r or green[emitter] < seed_g or blue[emitter] < seed_b: - red[emitter] = maxi(red[emitter], seed_r) - green[emitter] = maxi(green[emitter], seed_g) - blue[emitter] = maxi(blue[emitter], seed_b) - queue.push_back(emitter) - elif seed_r > 1 or seed_g > 1 or seed_b > 1: - var neighbor_r := maxi(seed_r - 1, 0) - var neighbor_g := maxi(seed_g - 1, 0) - var neighbor_b := maxi(seed_b - 1, 0) - for direction in 6: - var ni := emitter - match direction: - 0: - if vx == 0: - continue - ni -= 1 - 1: - if vx == volume.w - 1: - continue - ni += 1 - 2: - if volume_z == 0: - continue - ni -= volume.w - 3: - if volume_z == volume.d - 1: - continue - ni += volume.w - 4: - if y == 0: - continue - ni -= area - _: - if y == volume.h - 1: - continue - ni += area - if _opacity[blocks[ni]] >= MAX_LEVEL: - continue - if red[ni] >= neighbor_r and green[ni] >= neighbor_g and blue[ni] >= neighbor_b: - continue - red[ni] = maxi(red[ni], neighbor_r) - green[ni] = maxi(green[ni], neighbor_g) - blue[ni] = maxi(blue[ni], neighbor_b) - queue.push_back(ni) - emitter = blocks.find(block_id, emitter + 1) + var remainder := int(emitter / volume.w) + vx = emitter % volume.w + volume_z = remainder % volume.d + y = int(remainder / volume.d) + if not emitter_opaque: + if red[emitter] < seed_r or green[emitter] < seed_g or blue[emitter] < seed_b: + red[emitter] = maxi(red[emitter], seed_r) + green[emitter] = maxi(green[emitter], seed_g) + blue[emitter] = maxi(blue[emitter], seed_b) + queue.push_back(emitter) + elif seed_r > 1 or seed_g > 1 or seed_b > 1: + var neighbor_r := maxi(seed_r - 1, 0) + var neighbor_g := maxi(seed_g - 1, 0) + var neighbor_b := maxi(seed_b - 1, 0) + for direction in 6: + var ni := emitter + match direction: + 0: + if vx == 0: + continue + ni -= 1 + 1: + if vx == volume.w - 1: + continue + ni += 1 + 2: + if volume_z == 0: + continue + ni -= volume.w + 3: + if volume_z == volume.d - 1: + continue + ni += volume.w + 4: + if y == 0: + continue + ni -= area + _: + if y == volume.h - 1: + continue + ni += area + if _opacity[blocks[ni]] >= MAX_LEVEL: + continue + if red[ni] >= neighbor_r and green[ni] >= neighbor_g and blue[ni] >= neighbor_b: + continue + red[ni] = maxi(red[ni], neighbor_r) + green[ni] = maxi(green[ni], neighbor_g) + blue[ni] = maxi(blue[ni], neighbor_b) + queue.push_back(ni) + if not found_emitter: + return while head < queue.size(): var index := queue[head] head += 1 diff --git a/world/day_night_cycle.gd b/world/day_night_cycle.gd index b903018..4354eff 100644 --- a/world/day_night_cycle.gd +++ b/world/day_night_cycle.gd @@ -77,6 +77,7 @@ const UNDERWATER_FALLBACK_COLOR := Color(0.06, 0.24, 0.36) const CAVE_FOG_ADD := 0.012 const CAVE_VOLUMETRIC_SCALE := 1.22 const LUSH_CAVE_LIGHT_FLOOR := 0.48 +const DRIPSTONE_CAVE_LIGHT_FLOOR := 0.34 const DEEP_DARK_LIGHT_FLOOR := 0.16 # Lightning is a short additive flash on the sun/ambient and fog; `WeatherSystem` # schedules the strikes and `Main` calls trigger_lightning(). @@ -151,6 +152,16 @@ func set_time(hours: float) -> void: _apply() +func persistent_state() -> Dictionary: + return {"hours": time_hours, "moon_phase": _moon_phase} + + +func restore_persistent_state(state: Dictionary) -> void: + time_hours = fposmod(float(state.get("hours", start_hour)), 24.0) + _moon_phase = fposmod(float(state.get("moon_phase", 0.5)), 1.0) + _apply() + + func set_weather_dim(value: float) -> void: weather_dim = clampf(value, 0.0, 1.0) @@ -230,7 +241,11 @@ func _apply() -> void: _fill_light.light_energy *= lerpf(1.0, UNDERWATER_LIGHT_FLOOR, submerged) if cave_amount > 0.001 and underwater_amount < 0.5: var cave_mix := cave_amount * lerpf(0.65, 1.0, cave_depth) - var light_floor := DEEP_DARK_LIGHT_FLOOR if cave_biome == BiomeCatalog.DEEP_DARK else LUSH_CAVE_LIGHT_FLOOR + var light_floor := LUSH_CAVE_LIGHT_FLOOR + if cave_biome == BiomeCatalog.DEEP_DARK: + light_floor = DEEP_DARK_LIGHT_FLOOR + elif cave_biome == BiomeCatalog.DRIPSTONE_CAVES: + light_floor = DRIPSTONE_CAVE_LIGHT_FLOOR environment.fog_light_color = environment.fog_light_color.lerp(cave_color, cave_mix) environment.fog_density += CAVE_FOG_ADD * cave_mix environment.volumetric_fog_density *= lerpf(1.0, CAVE_VOLUMETRIC_SCALE, cave_mix) diff --git a/world/terrain_generator.gd b/world/terrain_generator.gd index d5f27fc..81a5e3a 100644 --- a/world/terrain_generator.gd +++ b/world/terrain_generator.gd @@ -157,7 +157,7 @@ func water_tint_for(biome_id: int) -> Color: ## actually inside a cave before applying this 3D region label. func cave_biome_id_at(world_x: int, y: int, world_z: int) -> int: _ensure_configured() - return BiomeCatalog.cave_biome_at(_config.seed, world_x, y, world_z) + return BiomeCatalog.cave_biome_at(_config.seed, world_x, y, world_z, _config.worldgen_version) func biome_color(biome_id: int) -> Color: @@ -267,11 +267,18 @@ func find_spawn_position() -> Vector3: candidates.append(Vector2i(radius, edge)) for coarse: Vector2i in candidates: var point := coarse * 8 - var sample: Dictionary = _sampler.sample_point(point.x, point.y) - var biome: int = int(sample["dominant_biome_id"]) - var height: float = float(sample["final_height"]) + # Reject unsuitable columns with the allocation-free ground query. + # The full point query resolves raw height and four neighbouring final + # heights for slope, so only pay for it on plausible dry land. + var ground := _sampler.sample_decoration_ground(point.x, point.y) + var biome: int = ground.y + var height: float = float(ground.x) if _biomes.is_ocean_biome(biome) or biome in [BiomeCatalog.BEACH, BiomeCatalog.RIVER, BiomeCatalog.SWAMP]: continue + if height <= VoxelDefs.SEA_LEVEL + 2: + continue + var sample: Dictionary = _sampler.sample_point(point.x, point.y) + height = float(sample["final_height"]) var slope: float = float(sample["slope"]) var score := height - slope * 8.0 - float(radius) * 0.12 if height > VoxelDefs.SEA_LEVEL + 2 and slope < 2.2 and score > best_score: diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 419d800..c8d5c22 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -5,16 +5,29 @@ const SPAWN_RADIUS := 1 const SPAWN_SEARCH_RADIUS := 12 ## Half the logical cores, capped at 8. Measured on a 16-thread desktop: 8 ## concurrent chunk jobs stream ~40% faster than 4, while 16 adds only ~10% -## more and risks starving the main thread on smaller machines. +## more and risks starving the main thread on smaller machines. One additional +## high-priority near-player job may be submitted when all normal slots are +## occupied, preventing stale distant work from starving collision recovery. const MIN_ACTIVE_JOBS := 4 const MAX_ACTIVE_JOBS := 8 -## Full-detail chunks run to the render distance. Only distances past this cap -## (the Extreme toggle) fall back to compact LOD chunks. -const MAX_FULL_DETAIL_DISTANCE := 32 ## Collision shapes are only built for chunks near the player; approaching a ## distant full chunk rebuilds it to add collision instead of holding a shape ## for every loaded chunk. const COLLISION_DISTANCE := 6 +## Full-detail voxel arrays are immutable between edits. Once they are beyond +## interaction range, retain their authoritative metadata but store voxel IDs +## as a deterministic palette followed by little-endian RLE runs. +const CHUNK_DATA_RLE_HEADER_BYTES := 6 +const LOD_MODE_FULL := 0 +const LOD_MODE_BALANCED := 1 +const BALANCED_FULL_DETAIL_DISTANCE := COLLISION_DISTANCE + 2 +const STREAM_BOUNDARY_MARGIN := 0.05 +## Palette/RLE cold storage is retained as a verified codec, but production +## compaction is disabled. Live flight profiling showed repeated neighbor +## decodes caused stream stutter, and a stale compressed snapshot could reach a +## remesh as a short array. Balanced LOD provides the memory saving without +## putting compression in the active meshing path. +const ENABLE_COLD_CHUNK_COMPRESSION := false const COMMIT_BUDGET_MS := 2 ## Diagnostic map rasters resolve one mode sample per pixel. The final-height ## family walks the erosion graph five times per sample, so map views request @@ -43,6 +56,9 @@ const FIRE_SPREAD_PER_TICK := 1 const FIRE_IGNITION_DELAY_TICKS := 4 const FIRE_SPREAD_PERIOD_TICKS := 2 const FIRE_SPREAD_INTERVAL_TICKS := 2 +## Light starts at MAX_LIGHT_LEVEL and attenuates at least one level per cell, +## so its furthest possible affected cell is this many horizontal steps away. +const LIGHT_MAX_PROPAGATION_DISTANCE := BlockRegistry.MAX_LIGHT_LEVEL - 1 const FIRE_OFFSETS: Array[Vector3i] = [ Vector3i(1, 0, 0), Vector3i(-1, 0, 0), Vector3i(0, 1, 0), Vector3i(0, -1, 0), @@ -57,6 +73,7 @@ const WATER_NEIGHBOR_OFFSETS := [ var render_distance := 10 var lod_distance := 5 +var lod_mode := LOD_MODE_FULL var unload_radius := 12 var _blocks: BlockRegistry @@ -75,6 +92,8 @@ var _dirty: Dictionary = {} var _desired: Dictionary = {} var _edited_blocks: Dictionary = {} var _edits_by_chunk: Dictionary = {} +var _hydrated_edit_chunks: Dictionary = {} +var _edit_store: WorldStorage var _chunk_edit_version: Dictionary = {} var _stream_center := Vector2i(999999, 999999) var _player: Node3D @@ -109,6 +128,7 @@ var _debug_cache: Dictionary = {} class Chunk: var data := PackedByteArray() + var compressed_data := PackedByteArray() var heights := PackedInt32Array() var foliage_tints := PackedColorArray() var water_tints := PackedColorArray() @@ -126,12 +146,78 @@ class Chunk: var shape: CollisionShape3D +## The palette uses first-seen ordering, making the representation stable for a +## given voxel array. Runs are [palette_index, length_low, length_high]. The +## raw length and palette count are little-endian uint32/uint16 header fields. +static func compress_chunk_data(raw: PackedByteArray) -> PackedByteArray: + var palette := PackedByteArray() + var palette_indices: Dictionary = {} + for value in raw: + var block_id := int(value) + if not palette_indices.has(block_id): + palette_indices[block_id] = palette.size() + palette.append(block_id) + var out := PackedByteArray() + out.resize(CHUNK_DATA_RLE_HEADER_BYTES) + var raw_size := raw.size() + out[0] = raw_size & 0xff + out[1] = (raw_size >> 8) & 0xff + out[2] = (raw_size >> 16) & 0xff + out[3] = (raw_size >> 24) & 0xff + out[4] = palette.size() & 0xff + out[5] = (palette.size() >> 8) & 0xff + out.append_array(palette) + var start := 0 + while start < raw_size: + var block_id := raw[start] + var run_length := 1 + while start + run_length < raw_size and raw[start + run_length] == block_id \ + and run_length < 65535: + run_length += 1 + out.append(int(palette_indices[int(block_id)])) + out.append(run_length & 0xff) + out.append((run_length >> 8) & 0xff) + start += run_length + return out + + +static func decompress_chunk_data(compressed: PackedByteArray) -> PackedByteArray: + if compressed.size() < CHUNK_DATA_RLE_HEADER_BYTES: + return PackedByteArray() + var raw_size := int(compressed[0]) | (int(compressed[1]) << 8) \ + | (int(compressed[2]) << 16) | (int(compressed[3]) << 24) + var palette_size := int(compressed[4]) | (int(compressed[5]) << 8) + var offset := CHUNK_DATA_RLE_HEADER_BYTES + if raw_size == 0: + return PackedByteArray() + if raw_size < 0 or palette_size <= 0 or compressed.size() < offset + palette_size: + return PackedByteArray() + var palette := compressed.slice(offset, offset + palette_size) + offset += palette_size + var raw := PackedByteArray() + raw.resize(raw_size) + var written := 0 + while offset + 2 < compressed.size(): + var palette_index := int(compressed[offset]) + var run_length := int(compressed[offset + 1]) | (int(compressed[offset + 2]) << 8) + offset += 3 + if palette_index >= palette.size() or run_length <= 0 or written + run_length > raw_size: + return PackedByteArray() + for index in range(written, written + run_length): + raw[index] = palette[palette_index] + written += run_length + if offset != compressed.size() or written != raw_size: + return PackedByteArray() + return raw + + class PendingJob: var task := -1 var kind := "generate" var version := 0 var config_revision := 0 var lod := false + var want_collision := false var slot: Dictionary = {} @@ -183,26 +269,133 @@ func _exit_tree() -> void: if _debug_task >= 0: WorkerThreadPool.wait_for_task_completion(_debug_task) _debug_task = -1 + flush_edit_store() ## Must run before setup_player() and must not be called while chunk jobs are ## in flight: recreating the noise set invalidates running workers. -func configure(world_config: Dictionary, render_distance_chunks: int) -> void: +func configure(world_config: Dictionary, render_distance_chunks: int, + stream_lod_mode: int = LOD_MODE_FULL) -> void: render_distance = maxi(render_distance_chunks, 1) - lod_distance = mini(render_distance, MAX_FULL_DETAIL_DISTANCE) + lod_mode = clampi(stream_lod_mode, LOD_MODE_FULL, LOD_MODE_BALANCED) + _update_lod_distance() unload_radius = render_distance + 2 _generator.configure(world_config) _worldgen_revision += 1 +## Attaches the main-thread persistence boundary. Region files are hydrated +## before immutable edit snapshots enter worker jobs; workers never perform I/O. +func set_edit_store(store: WorldStorage) -> void: + _edit_store = store + _hydrated_edit_chunks.clear() + _edited_blocks.clear() + _edits_by_chunk.clear() + + +func flush_edit_store() -> Error: + if _edit_store == null: + return OK + return _edit_store.flush_dirty_regions() + + func set_render_distance(value: int) -> void: render_distance = maxi(value, 1) - lod_distance = mini(render_distance, MAX_FULL_DETAIL_DISTANCE) + _update_lod_distance() unload_radius = render_distance + 2 _rebuild_desired() _unload_far() +func set_lod_mode(value: int) -> void: + var normalized := clampi(value, LOD_MODE_FULL, LOD_MODE_BALANCED) + if lod_mode == normalized: + return + lod_mode = normalized + _update_lod_distance() + _rebuild_desired() + + +func _update_lod_distance() -> void: + # Full Detail always means authoritative full chunks through the selected + # render distance, including Extreme values. Balanced is the explicit opt-in + # memory/performance mode and alone enables compact distance terrain. + lod_distance = mini(render_distance, BALANCED_FULL_DETAIL_DISTANCE) \ + if lod_mode == LOD_MODE_BALANCED else render_distance + + +## Physics uses this readiness boundary to suspend gravity while a teleported +## or fast-flying player waits for the authoritative collision mesh beneath the +## current horizontal position. +func is_collision_ready_at(world_position: Vector3) -> bool: + return _is_chunk_collision_ready(_chunk_for_position(world_position)) + + +func _is_chunk_collision_ready(chunk_position: Vector2i) -> bool: + var chunk: Chunk = _chunks.get(chunk_position) + return chunk != null and not chunk.lod and chunk.shape != null and chunk.shape.shape != null + + +## Sweeps horizontal movement through the chunk grid and returns the fraction +## that remains inside resident, rendered chunks. Collision readiness is a +## separate gravity guard: a visible LOD/full chunk must not behave like an +## invisible wall while its nearby collision rebuild catches up. +func loaded_motion_fraction(from: Vector3, to: Vector3) -> float: + var delta := Vector2(to.x - from.x, to.z - from.z) + var distance := delta.length() + if distance <= 0.000001: + return 1.0 + var chunk := _chunk_for_position(from) + if not _chunks.has(chunk): + return 0.0 + var target := _chunk_for_position(to) + if target == chunk: + return 1.0 + var step_x := 1 if delta.x > 0.0 else (-1 if delta.x < 0.0 else 0) + var step_z := 1 if delta.y > 0.0 else (-1 if delta.y < 0.0 else 0) + var next_x := float((chunk.x + 1) * VoxelDefs.CHUNK_SIZE) if step_x > 0 \ + else float(chunk.x * VoxelDefs.CHUNK_SIZE) + var next_z := float((chunk.y + 1) * VoxelDefs.CHUNK_SIZE) if step_z > 0 \ + else float(chunk.y * VoxelDefs.CHUNK_SIZE) + var t_max_x := (next_x - from.x) / delta.x if step_x != 0 else INF + var t_max_z := (next_z - from.z) / delta.y if step_z != 0 else INF + var t_delta_x := float(VoxelDefs.CHUNK_SIZE) / absf(delta.x) if step_x != 0 else INF + var t_delta_z := float(VoxelDefs.CHUNK_SIZE) / absf(delta.y) if step_z != 0 else INF + while chunk != target: + var entry_t: float + if is_equal_approx(t_max_x, t_max_z): + entry_t = t_max_x + chunk += Vector2i(step_x, step_z) + t_max_x += t_delta_x + t_max_z += t_delta_z + elif t_max_x < t_max_z: + entry_t = t_max_x + chunk.x += step_x + t_max_x += t_delta_x + else: + entry_t = t_max_z + chunk.y += step_z + t_max_z += t_delta_z + if not _chunks.has(chunk): + return clampf(entry_t - STREAM_BOUNDARY_MARGIN / distance, 0.0, 1.0) + return 1.0 + + +## Saved positions can come from an interrupted run that previously fell into +## unloaded terrain. Validate the two blocks occupied by the standing capsule +## after the local spawn ring is available before accepting that position. +func is_player_volume_clear(world_position: Vector3) -> bool: + var block_x := floori(world_position.x) + var block_z := floori(world_position.z) + for block_y in [floori(world_position.y + 0.05), floori(world_position.y + 1.7)]: + var block_id := get_block_world(Vector3i(block_x, block_y, block_z)) + if block_id == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(block_id) \ + or _blocks.has_flag(block_id, BlockRegistry.FLAG_CROSS): + continue + return false + return true + + ## Points streaming at a new node. The initial setup and any caller that is ## about to place physics on the target (photo mode returning to the player) ## ask for sync_spawn_area: it commits the 3x3 ring on the main thread so the @@ -227,30 +420,55 @@ func _stream_tick() -> void: _drop_far_collision() _collect_jobs() _process_commit_queue() - _schedule_jobs() + # Discover missing nearby collision before assigning newly-opened worker + # slots. Otherwise generation can refill every slot first while boosted + # flight waits on a floor remesh that was only queued afterward. _ensure_near_collision() + _schedule_jobs() ## Distant full chunks are committed without a collision shape. Rebuilds are ## only requested as the player gets close, which keeps shape memory bounded to ## the local area while the world stays full-detail everywhere in range. func _ensure_near_collision() -> void: + var candidates: Array[Vector2i] = [] for dz in range(-COLLISION_DISTANCE, COLLISION_DISTANCE + 1): for dx in range(-COLLISION_DISTANCE, COLLISION_DISTANCE + 1): var pos := _stream_center + Vector2i(dx, dz) var chunk: Chunk = _chunks.get(pos) - if chunk == null or chunk.lod or chunk.shape.shape != null: + if chunk == null or chunk.lod: continue - _queue_rebuild(pos) + _restore_chunk_data(chunk) + if chunk.shape != null and chunk.shape.shape != null: + continue + var pending: PendingJob = _pending.get(pos) + if pending != null: + # A mesh submitted while this chunk was distant contains no collision. + # Mark it stale now so completion immediately requeues a collision build. + if pending.kind == "mesh" and not pending.lod and not pending.want_collision: + _dirty[pos] = true + continue + candidates.append(pos) + # `_queue_rebuild()` pushes normal work to the front. Queue far-to-near so + # the current chunk and its closest floor ring finish first, regardless of + # dictionary/scan order or older dirty work already in the mesh queue. + candidates.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: + return (a - _stream_center).length_squared() > (b - _stream_center).length_squared() + ) + for pos in candidates: + _queue_rebuild(pos) + if _mesh_queued.has(pos): + _mesh_queue.erase(pos) + _mesh_queue.push_front(pos) func _drop_far_collision() -> void: for pos in _chunks.keys(): var chunk: Chunk = _chunks[pos] - if chunk.shape.shape == null: - continue - if maxi(absi(pos.x - _stream_center.x), absi(pos.y - _stream_center.y)) > COLLISION_DISTANCE + 1: - chunk.shape.shape = null + if chunk.shape != null and not _within_collision_range(pos): + _remove_chunk_collision_nodes(chunk) + if ENABLE_COLD_CHUNK_COMPRESSION: + _compress_distant_chunks() func _generate_spawn_area() -> void: @@ -274,23 +492,25 @@ func _rebuild_desired() -> void: _mesh_queue.clear() _mesh_queued.clear() var wanted: Array[Vector2i] = [] - for dx in range(-render_distance, render_distance + 1): - for dz in range(-render_distance, render_distance + 1): - var pos := _stream_center + Vector2i(dx, dz) - _desired[pos] = true - var want_lod := _chunk_uses_lod(pos) - if _chunks.has(pos) and (_chunks[pos] as Chunk).lod != want_lod: - _dirty[pos] = true - var staged: TerrainGenerator.GenResult = _generated.get(pos) - if staged != null and (staged.lod != want_lod \ - or staged.config_revision != _worldgen_revision): - _generated.erase(pos) - if not _chunks.has(pos) and not _generated.has(pos) and not _pending.has(pos): - wanted.append(pos) - var center := _stream_center - wanted.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: - return (a - center).length_squared() < (b - center).length_squared() - ) + # Build in nearest-first Chebyshev rings instead of filling a square and + # sorting thousands of entries every time the player crosses a chunk edge. + # At extreme distances this removes a large main-thread O(n log n) hitch. + for ring in range(render_distance + 1): + for dx in range(-ring, ring + 1): + for dz in range(-ring, ring + 1): + if maxi(absi(dx), absi(dz)) != ring: + continue + var pos := _stream_center + Vector2i(dx, dz) + _desired[pos] = true + var want_lod := _chunk_uses_lod(pos) + if _chunks.has(pos) and (_chunks[pos] as Chunk).lod != want_lod: + _dirty[pos] = true + var staged: TerrainGenerator.GenResult = _generated.get(pos) + if staged != null and (staged.lod != want_lod \ + or staged.config_revision != _worldgen_revision): + _generated.erase(pos) + if not _chunks.has(pos) and not _generated.has(pos) and not _pending.has(pos): + wanted.append(pos) _gen_queue = wanted _gen_queued.clear() for pos in _gen_queue: @@ -308,14 +528,36 @@ func _rebuild_desired() -> void: func _schedule_jobs() -> void: if _gen_queue.is_empty() and _mesh_queue.is_empty(): return - while _pending.size() < _max_active_jobs and (not _mesh_queue.is_empty() or not _gen_queue.is_empty()): - var mesh_job := not _mesh_queue.is_empty() + while not _mesh_queue.is_empty() or not _gen_queue.is_empty(): + var mesh_job := false + var queue_index := 0 + if _pending.size() > _max_active_jobs: + break + var urgent_mesh_index := _urgent_queue_index(_mesh_queue) + var urgent_generation_index := _urgent_queue_index(_gen_queue) + if urgent_mesh_index >= 0: + mesh_job = true + queue_index = urgent_mesh_index + elif urgent_generation_index >= 0: + queue_index = urgent_generation_index + elif _pending.size() < _max_active_jobs: + # Away from the player, finish already-generated meshes before doing + # more terrain work. Near the player, the urgent branches above always + # fill real chunk holes before unrelated distance remeshes. + mesh_job = not _mesh_queue.is_empty() + else: + break + # At the normal cap, only the urgent branches can reach this point. This + # is the single bounded overflow slot; WorkerThreadPool high priority runs + # it as soon as stale distance work releases a worker. var pos: Vector2i if mesh_job: - pos = _mesh_queue.pop_front() + pos = _mesh_queue[queue_index] + _mesh_queue.remove_at(queue_index) _mesh_queued.erase(pos) else: - pos = _gen_queue.pop_front() + pos = _gen_queue[queue_index] + _gen_queue.remove_at(queue_index) _gen_queued.erase(pos) if _pending.has(pos): continue @@ -331,6 +573,7 @@ func _schedule_jobs() -> void: job.lod = lod job.slot = {} var high_priority := not lod and _within_collision_range(pos) + job.want_collision = high_priority var chunk: Chunk = _chunks.get(pos) if mesh_job and chunk != null and chunk.lod == lod: job.kind = "mesh" @@ -344,7 +587,7 @@ func _schedule_jobs() -> void: false, "voxel_lod_remesh") else: job.task = WorkerThreadPool.add_task( - _run_full_remesh_job.bind(chunk.data.duplicate(), chunk.foliage_tints.duplicate(), + _run_full_remesh_job.bind(_chunk_data_snapshot(chunk), chunk.foliage_tints.duplicate(), chunk.water_tints.duplicate(), _gather_neighbors(pos), job.slot, high_priority), high_priority, "voxel_full_remesh") elif mesh_job: @@ -365,11 +608,22 @@ func _schedule_jobs() -> void: _pending[pos] = job +func _urgent_queue_index(queue: Array[Vector2i]) -> int: + for index in queue.size(): + var pos := queue[index] + if not _chunk_uses_lod(pos) and _within_collision_range(pos): + return index + return -1 + + func _process_commit_queue() -> void: var start := Time.get_ticks_msec() while not _commit_queue.is_empty(): - var item: CommitItem = _commit_queue.pop_front() + var commit_index := _next_commit_index() + var item: CommitItem = _commit_queue[commit_index] + _commit_queue.remove_at(commit_index) if not _desired.has(item.pos): + _discard_unloaded_chunk_state(item.pos) continue var mode_stale := item.lod != _chunk_uses_lod(item.pos) if item.version != _chunk_edit_version.get(item.pos, 0) or item.config_revision != _worldgen_revision or mode_stale: @@ -380,6 +634,23 @@ func _process_commit_queue() -> void: break +func _next_commit_index() -> int: + var best_index := 0 + var best_priority := 3 + var best_distance := 1 << 30 + for index in _commit_queue.size(): + var item: CommitItem = _commit_queue[index] + var distance := (item.pos - _stream_center).length_squared() + var priority := 2 + if not item.lod and _within_collision_range(item.pos): + priority = 0 if item.result.build_collision else 1 + if priority < best_priority or (priority == best_priority and distance < best_distance): + best_index = index + best_priority = priority + best_distance = distance + return best_index + + func _collect_jobs() -> void: for pos in _pending.keys(): var job: PendingJob = _pending[pos] @@ -387,6 +658,11 @@ func _collect_jobs() -> void: continue WorkerThreadPool.wait_for_task_completion(job.task) _pending.erase(pos) + if not _desired.has(pos): + # No off-screen result is useful. Purging only after the worker has + # completed preserves its edit-version stale check until this point. + _discard_unloaded_chunk_state(pos) + continue if _dirty.has(pos): # An edit invalidated this job's neighbor snapshot while it was running. # Discard the stale result and immediately schedule a fresh build. @@ -572,7 +848,7 @@ func _gather_generated_neighbors(pos: Vector2i, lod: bool): chunk.lod_sub_id.duplicate(), chunk.lod_water_y.duplicate()) else: out.samples[direction] = ChunkMesher.NeighborSample.new( - chunk.data.duplicate(), chunk.max_y, chunk.heights.duplicate()) + _chunk_data_snapshot(chunk), chunk.max_y, chunk.heights.duplicate()) elif _generated.has(neighbor_pos): var generated: TerrainGenerator.GenResult = _generated[neighbor_pos] if generated.lod: @@ -604,6 +880,7 @@ func _build_lod_edge(chunk: Chunk, direction: Vector2i) -> ChunkMesher.LodEdge: var edge := ChunkMesher.LodEdge.new() edge.solid.resize(VoxelDefs.CHUNK_SIZE) edge.water.resize(VoxelDefs.CHUNK_SIZE) + var data := _chunk_data_snapshot(chunk) if not chunk.lod else PackedByteArray() for index in VoxelDefs.CHUNK_SIZE: var local_x := index if direction.y != 0 else (0 if direction.x > 0 else VoxelDefs.CHUNK_SIZE - 1) var local_z := index if direction.x != 0 else (0 if direction.y > 0 else VoxelDefs.CHUNK_SIZE - 1) @@ -615,7 +892,7 @@ func _build_lod_edge(chunk: Chunk, direction: Vector2i) -> ChunkMesher.LodEdge: var solid := ChunkMesher.LOD_NONE var water := ChunkMesher.LOD_NONE for y in range(chunk.max_y, -1, -1): - var id: int = chunk.data[column + y * VoxelDefs.DATA_STRIDE_Y] + var id: int = data[column + y * VoxelDefs.DATA_STRIDE_Y] if id == BlockRegistry.BLOCK_AIR: continue if _blocks.is_water_id(id): @@ -672,11 +949,42 @@ func _gather_neighbors(pos: Vector2i) -> ChunkMesher.NeighborSet: chunk.lod_solid_y.duplicate(), chunk.lod_solid_id.duplicate(), chunk.lod_sub_id.duplicate(), chunk.lod_water_y.duplicate()) else: - out.samples[direction] = ChunkMesher.NeighborSample.new(chunk.data.duplicate(), chunk.max_y, chunk.heights.duplicate()) + out.samples[direction] = ChunkMesher.NeighborSample.new( + _chunk_data_snapshot(chunk), chunk.max_y, chunk.heights.duplicate()) out.mask |= (1 << index) return out +## Worker jobs own their snapshots. Decoding here never exposes the compressed +## backing array to a worker and does not make a distant chunk mutable. +func _chunk_data_snapshot(chunk: Chunk) -> PackedByteArray: + if not chunk.data.is_empty(): + return chunk.data.duplicate() + return decompress_chunk_data(chunk.compressed_data) + + +func _restore_chunk_data(chunk: Chunk) -> void: + if chunk.lod or not chunk.data.is_empty() or chunk.compressed_data.is_empty(): + return + var restored := decompress_chunk_data(chunk.compressed_data) + if restored.size() != VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT: + push_error("voxel_world: invalid compressed full-detail chunk data") + return + chunk.data = restored + chunk.compressed_data.clear() + + +func _compress_distant_chunks() -> void: + for pos in _chunks: + var chunk: Chunk = _chunks[pos] + if chunk.lod or _within_collision_range(pos) or chunk.data.is_empty(): + continue + var compressed := compress_chunk_data(chunk.data) + if compressed.size() < chunk.data.size(): + chunk.compressed_data = compressed + chunk.data.clear() + + func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> void: _generated.erase(pos) var chunk: Chunk = _chunks.get(pos) @@ -686,6 +994,7 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi _chunks[pos] = chunk chunk.lod = lod chunk.data = res.data + chunk.compressed_data.clear() chunk.heights = res.heights chunk.foliage_tints = res.foliage_tints chunk.water_tints = res.water_tints @@ -701,15 +1010,18 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi chunk.mesh.mesh = ChunkMesher.arrays_to_mesh(res.verts, res.normals, res.uvs, res.colors, res.indices, _blocks.material, res.light, res.layers) chunk.water.mesh = ChunkMesher.arrays_to_mesh(res.water_verts, res.water_normals, res.water_uvs, res.water_colors, res.water_indices, _blocks.water_material, res.water_light) if not lod and _within_collision_range(pos) and not res.collision.is_empty(): + _ensure_chunk_collision_nodes(chunk, pos) var shape := ConcavePolygonShape3D.new() shape.set_faces(res.collision) shape.backface_collision = true chunk.shape.shape = shape else: - chunk.shape.shape = null + _remove_chunk_collision_nodes(chunk) _remesh_on_commit_neighbors(pos) if mode_changed: _invalidate_mode_change_neighbors(pos) + if ENABLE_COLD_CHUNK_COMPRESSION: + _compress_distant_chunks() func _invalidate_mode_change_neighbors(pos: Vector2i) -> void: @@ -736,15 +1048,31 @@ func _create_chunk_nodes(pos: Vector2i) -> Chunk: chunk.water.position = chunk_origin chunk.water.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(chunk.water) + return chunk + + +func _ensure_chunk_collision_nodes(chunk: Chunk, pos: Vector2i) -> void: + if chunk.body != null and is_instance_valid(chunk.body) and chunk.shape != null \ + and is_instance_valid(chunk.shape): + return chunk.body = StaticBody3D.new() chunk.body.name = "Body_%d_%d" % [pos.x, pos.y] - chunk.body.position = chunk_origin + chunk.body.position = Vector3(pos.x * VoxelDefs.CHUNK_SIZE, 0.0, + pos.y * VoxelDefs.CHUNK_SIZE) chunk.body.collision_layer = VoxelDefs.COLLISION_LAYER_WORLD chunk.body.collision_mask = 0 add_child(chunk.body) chunk.shape = CollisionShape3D.new() chunk.body.add_child(chunk.shape) - return chunk + + +func _remove_chunk_collision_nodes(chunk: Chunk) -> void: + if chunk.shape != null and is_instance_valid(chunk.shape): + chunk.shape.shape = null + if chunk.body != null and is_instance_valid(chunk.body): + chunk.body.queue_free() + chunk.shape = null + chunk.body = null func _remesh_on_commit_neighbors(pos: Vector2i) -> void: @@ -803,16 +1131,62 @@ func _unload_far() -> void: for pos in _chunks.keys(): if maxi(absi(pos.x - _stream_center.x), absi(pos.y - _stream_center.y)) > unload_radius: _free_chunk(pos) + _discard_unloaded_stream_state() func _free_chunk(pos: Vector2i) -> void: var chunk: Chunk = _chunks.get(pos) if chunk == null: return + # queue_free() is deferred. Remove physics immediately so a large render- + # distance contraction cannot leave one-frame ghost walls from old bodies. + _remove_chunk_collision_nodes(chunk) chunk.mesh.queue_free() chunk.water.queue_free() - chunk.body.queue_free() _chunks.erase(pos) + _discard_unloaded_chunk_state(pos) + + +## A disk-backed chunk can drop its hydrated edit snapshot once its scene nodes +## are gone: WorldStorage remains the authoritative copy. Keep versions while a +## worker or commit item still refers to them, otherwise a late stale result +## could look current after a fast return to the same stream center. In-memory +## worlds intentionally retain their edit buckets so procedural reloads still +## replay unsaved edits. +func _discard_unloaded_stream_state() -> void: + var candidates: Dictionary = {} + for pos in _dirty: + candidates[pos] = true + for pos in _generated: + candidates[pos] = true + for pos in _chunk_edit_version: + candidates[pos] = true + if _edit_store != null: + for pos in _hydrated_edit_chunks: + candidates[pos] = true + for pos in _edits_by_chunk: + candidates[pos] = true + for pos in candidates: + _discard_unloaded_chunk_state(pos) + + +func _discard_unloaded_chunk_state(pos: Vector2i) -> void: + if _chunks.has(pos) or _desired.has(pos) or _pending.has(pos): + return + for queued_item in _commit_queue: + var item: CommitItem = queued_item + if item.pos == pos: + return + _generated.erase(pos) + _dirty.erase(pos) + _chunk_edit_version.erase(pos) + if _edit_store == null: + return + var edits: Dictionary = _edits_by_chunk.get(pos, {}) + for position in edits: + _edited_blocks.erase(position) + _edits_by_chunk.erase(pos) + _hydrated_edit_chunks.erase(pos) func _chunk_for_position(world_position: Vector3) -> Vector2i: @@ -846,6 +1220,7 @@ func get_block_world(block_position: Vector3i) -> int: var chunk := _loaded_chunk_for(block_position) if chunk == null or chunk.lod: return BlockRegistry.BLOCK_AIR + _restore_chunk_data(chunk) return chunk.data[_data_index(block_position)] @@ -930,13 +1305,14 @@ func break_block(block_position: Vector3i) -> int: var chunk: Chunk = _chunks.get(chunk_position) if chunk == null or chunk.lod: return BlockRegistry.BLOCK_AIR + _restore_chunk_data(chunk) var index := _data_index(block_position) var block_id: int = chunk.data[index] if not _blocks.is_breakable(block_id): return BlockRegistry.BLOCK_AIR chunk.data[index] = BlockRegistry.BLOCK_AIR _record_edit(block_position, BlockRegistry.BLOCK_AIR) - _touch_chunk(chunk_position, block_position) + _touch_chunk(chunk_position, block_position, block_id, BlockRegistry.BLOCK_AIR) _seed_water(block_position) return block_id @@ -950,13 +1326,14 @@ func place_block(block_position: Vector3i, block_id: int) -> bool: var chunk: Chunk = _chunks.get(chunk_position) if chunk == null or chunk.lod: return false + _restore_chunk_data(chunk) var index := _data_index(block_position) var existing: int = chunk.data[index] if existing != BlockRegistry.BLOCK_AIR and not _blocks.is_water_id(existing): return false chunk.data[index] = block_id _record_edit(block_position, block_id) - _touch_chunk(chunk_position, block_position) + _touch_chunk(chunk_position, block_position, existing, block_id) _seed_water(block_position) return true @@ -1008,6 +1385,7 @@ func carve_sphere(center: Vector3i, radius: int) -> int: var chunk: Chunk = _chunks.get(chunk_position) if chunk == null or chunk.lod: continue + _restore_chunk_data(chunk) var local_x := x - chunk_position.x * VoxelDefs.CHUNK_SIZE var local_z := z - chunk_position.y * VoxelDefs.CHUNK_SIZE var column_index := local_x + local_z * VoxelDefs.DATA_STRIDE_Z @@ -1035,6 +1413,7 @@ func carve_sphere(center: Vector3i, radius: int) -> int: changed_chunks[chunk_position] = true for chunk_position in changed_chunks: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + _stage_chunk_edits(chunk_position) rebuild_chunks[chunk_position] = true _add_loaded_light_ring(rebuild_chunks, chunk_position) for chunk_position in rebuild_chunks: @@ -1110,6 +1489,7 @@ func _ignite_fire_cell(block_position: Vector3i, life: int, changed_chunks: Dict var chunk := _loaded_chunk_for(block_position) if chunk == null or chunk.lod: return false + _restore_chunk_data(chunk) var existing := get_block_world(block_position) if existing == BlockRegistry.BLOCK_FIRE: _fire_life[block_position] = maxi(int(_fire_life.get(block_position, 0)), life) @@ -1315,6 +1695,7 @@ func _extinguish_fire(position: Vector3i, changed_chunks: Dictionary) -> void: var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return + _restore_chunk_data(chunk) if get_block_world(position) != BlockRegistry.BLOCK_FIRE: return chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR @@ -1330,6 +1711,7 @@ func _destroy_burnt_block(position: Vector3i, changed_chunks: Dictionary) -> voi var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return + _restore_chunk_data(chunk) chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) _seed_water(position) @@ -1344,6 +1726,7 @@ func _flush_fire_changes(changed_chunks: Dictionary) -> void: var rebuild_chunks := {} for chunk_position in changed_chunks: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + _stage_chunk_edits(chunk_position) rebuild_chunks[chunk_position] = true _add_loaded_light_ring(rebuild_chunks, chunk_position) for chunk_position in rebuild_chunks: @@ -1364,12 +1747,34 @@ func _record_edit_in_chunk(block_position: Vector3i, block_id: int, chunk_positi func _chunk_edits_for(pos: Vector2i) -> Dictionary: + _hydrate_chunk_edits(pos) var bucket = _edits_by_chunk.get(pos) if bucket == null: return {} return bucket.duplicate() +func _hydrate_chunk_edits(pos: Vector2i) -> void: + if _hydrated_edit_chunks.has(pos): + return + _hydrated_edit_chunks[pos] = true + if _edit_store == null: + return + var edits := _edit_store.load_chunk_edits(pos) + if edits.is_empty(): + return + _edits_by_chunk[pos] = edits + for position in edits: + _edited_blocks[position] = edits[position] + + +func _stage_chunk_edits(pos: Vector2i) -> void: + if _edit_store == null: + return + var bucket: Dictionary = _edits_by_chunk.get(pos, {}) + _edit_store.stage_chunk_edits(pos, bucket) + + ## A regenerated chunk can bring persisted fire back from `_edited_blocks`. ## Re-arm those cells so a still-burning edit resumes its live simulation ## instead of sitting as an immortal flame after an unload/reload cycle. @@ -1416,6 +1821,7 @@ func _water_tick() -> void: var rebuild_chunks := {} for chunk_position in changed_chunks: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + _stage_chunk_edits(chunk_position) rebuild_chunks[chunk_position] = true _add_loaded_light_ring(rebuild_chunks, chunk_position) for chunk_position in rebuild_chunks: @@ -1468,6 +1874,7 @@ func _water_place(position: Vector3i, block_id: int, changed_chunks: Dictionary) var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return + _restore_chunk_data(chunk) var index := _data_index(position) if chunk.data[index] == block_id: return @@ -1478,15 +1885,160 @@ func _water_place(position: Vector3i, block_id: int, changed_chunks: Dictionary) _queue_water(position + offset) -func _touch_chunk(chunk_position: Vector2i, _block_position: Vector3i) -> void: +## Single-cell edits always remesh their owner. Neighbor snapshots only need +## invalidating when light can actually change, except at the edited chunk's +## boundary where equal-attenuation blocks can still change emitted faces/AO. +## Batch edits intentionally retain `_add_loaded_light_ring()` because keeping +## every changed position to make this decision would defeat their batching. +func _touch_chunk(chunk_position: Vector2i, block_position: Vector3i, + old_block_id: int, new_block_id: int) -> void: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 - var rebuild_chunks := {chunk_position: true} - # A full light volume consumes all eight neighboring chunks. Sky and block - # light can travel MAX_LIGHT_LEVEL - 1 cells, so even a non-edge edit can - # alter baked light in an adjacent chunk; invalidate the complete 3x3 ring. - _add_loaded_light_ring(rebuild_chunks, chunk_position) - for rebuild_position in rebuild_chunks: - _queue_rebuild(rebuild_position, true) + _stage_chunk_edits(chunk_position) + var rebuild_neighbors: Array[Vector2i] = [] + for direction in VoxelDefs.DIRS_8: + var neighbor_position: Vector2i = chunk_position + direction + if _chunks.has(neighbor_position) \ + and _single_edit_can_invalidate_neighbor(old_block_id, new_block_id, + block_position, neighbor_position): + rebuild_neighbors.append(neighbor_position) + # Neighbor light/seam updates remain required, but the edited owner controls + # interaction feedback and collision. Queue neighbors at the back and the + # owner last at the front so mining/placing cannot sit behind several heavy + # light-volume remeshes while extreme-distance streaming is active. + for neighbor_position in rebuild_neighbors: + _queue_rebuild(neighbor_position, true, true) + _queue_rebuild(chunk_position, true) + + +## Light volumes are identical outside the owner unless a cell's attenuation +## or emission changes. Comparing the actual emission color also catches two +## different colored emitters (for example, torch -> glowstone). +static func _single_edit_requires_light_neighbor_rebuild(old_block_id: int, + new_block_id: int) -> bool: + return _light_attenuation_for_id(old_block_id) != _light_attenuation_for_id(new_block_id) \ + or _is_emissive_id(old_block_id) != _is_emissive_id(new_block_id) \ + or _emission_color_for_id(old_block_id) != _emission_color_for_id(new_block_id) + + +## Equal attenuation does not imply equal boundary mesh topology. Transparent +## cube faces are culled against an equal neighbour, while water levels alter +## water-face culling. Opaque swaps and like-for-like foliage remain stable. +static func _single_edit_can_change_boundary_visibility(old_block_id: int, + new_block_id: int) -> bool: + if old_block_id == new_block_id \ + or _light_attenuation_for_id(old_block_id) != _light_attenuation_for_id(new_block_id): + return false + if _water_level_for_id(old_block_id) != _water_level_for_id(new_block_id): + return true + if _is_cross_id(old_block_id) != _is_cross_id(new_block_id): + return true + return _is_nonopaque_cube_id(old_block_id) or _is_nonopaque_cube_id(new_block_id) + + +## Selects one loaded neighbour for a one-cell edit. Lighting may reach into a +## nearby chunk within its finite Manhattan range; equal-attenuation geometry +## only reaches chunks sharing the edited cell's edge or corner. +static func _single_edit_can_invalidate_neighbor(old_block_id: int, new_block_id: int, + block_position: Vector3i, neighbor_position: Vector2i) -> bool: + if _single_edit_requires_light_neighbor_rebuild(old_block_id, new_block_id): + return _light_can_reach_chunk_horizontally(block_position, neighbor_position) + return _single_edit_can_change_boundary_visibility(old_block_id, new_block_id) \ + and _block_touches_chunk_boundary(block_position, neighbor_position) + + +static func _light_attenuation_for_id(block_id: int) -> int: + if _is_opaque_id(block_id): + return BlockRegistry.MAX_LIGHT_LEVEL + if _is_water_id(block_id): + return BlockRegistry.ATTENUATION_WATER + if (_block_flags_for_id(block_id) & BlockRegistry.FLAG_LEAVES) != 0: + return BlockRegistry.ATTENUATION_LEAVES + return 0 + + +static func _is_opaque_id(block_id: int) -> bool: + return (_block_flags_for_id(block_id) & BlockRegistry.FLAG_OPAQUE) != 0 + + +static func _is_nonopaque_cube_id(block_id: int) -> bool: + return block_id != BlockRegistry.BLOCK_AIR and not _is_opaque_id(block_id) \ + and not _is_water_id(block_id) \ + and (_block_flags_for_id(block_id) & (BlockRegistry.FLAG_CROSS | BlockRegistry.FLAG_LEAVES)) == 0 + + +static func _is_cross_id(block_id: int) -> bool: + return (_block_flags_for_id(block_id) & BlockRegistry.FLAG_CROSS) != 0 + + +static func _is_water_id(block_id: int) -> bool: + return block_id == BlockRegistry.BLOCK_WATER \ + or (block_id >= BlockRegistry.BLOCK_WATER_FLOW_7 and block_id <= BlockRegistry.BLOCK_WATER_FLOW_1) + + +static func _water_level_for_id(block_id: int) -> int: + if block_id == BlockRegistry.BLOCK_WATER: + return 8 + if block_id >= BlockRegistry.BLOCK_WATER_FLOW_7 and block_id <= BlockRegistry.BLOCK_WATER_FLOW_1: + return BlockRegistry.BLOCK_WATER_FLOW_1 - block_id + 1 + return 0 + + +static func _is_emissive_id(block_id: int) -> bool: + return ((_block_flags_for_id(block_id) & BlockRegistry.FLAG_EMISSIVE) != 0 \ + or BlockRegistry.EMISSIVE_COLORS.has(block_id)) and block_id > BlockRegistry.BLOCK_AIR + + +static func _emission_color_for_id(block_id: int) -> Color: + return BlockRegistry.EMISSIVE_COLORS.get(block_id, Color.BLACK) + + +static func _block_flags_for_id(block_id: int) -> int: + if block_id < BlockRegistry.BLOCK_AIR or block_id >= BlockRegistry.BLOCK_DEFS.size(): + return 0 + return int(BlockRegistry.BLOCK_DEFS[block_id][5]) + + +static func _block_touches_chunk_boundary(block_position: Vector3i, + neighbor_position: Vector2i) -> bool: + var owner := Vector2i( + floori(float(block_position.x) / float(VoxelDefs.CHUNK_SIZE)), + floori(float(block_position.z) / float(VoxelDefs.CHUNK_SIZE)) + ) + var direction := neighbor_position - owner + if absi(direction.x) > 1 or absi(direction.y) > 1 or direction == Vector2i.ZERO: + return false + var local_x := block_position.x - owner.x * VoxelDefs.CHUNK_SIZE + var local_z := block_position.z - owner.y * VoxelDefs.CHUNK_SIZE + var touches_x := direction.x == 0 \ + or (direction.x < 0 and local_x == 0) \ + or (direction.x > 0 and local_x == VoxelDefs.CHUNK_SIZE - 1) + var touches_z := direction.y == 0 \ + or (direction.y < 0 and local_z == 0) \ + or (direction.y > 0 and local_z == VoxelDefs.CHUNK_SIZE - 1) + return touches_x and touches_z + + +## Returns whether any horizontal cell in `chunk_position` lies within the +## bounded baked-light propagation range of `block_position`. The interval +## calculation is valid for negative world/chunk coordinates and naturally +## handles diagonals through Manhattan distance. +static func _light_can_reach_chunk_horizontally(block_position: Vector3i, + chunk_position: Vector2i) -> bool: + var min_x := chunk_position.x * VoxelDefs.CHUNK_SIZE + var min_z := chunk_position.y * VoxelDefs.CHUNK_SIZE + var max_x := min_x + VoxelDefs.CHUNK_SIZE - 1 + var max_z := min_z + VoxelDefs.CHUNK_SIZE - 1 + var x_distance := _distance_to_closed_interval(block_position.x, min_x, max_x) + var z_distance := _distance_to_closed_interval(block_position.z, min_z, max_z) + return x_distance + z_distance <= LIGHT_MAX_PROPAGATION_DISTANCE + + +static func _distance_to_closed_interval(value: int, minimum: int, maximum: int) -> int: + if value < minimum: + return minimum - value + if value > maximum: + return value - maximum + return 0 func _add_loaded_light_ring(rebuilds: Dictionary, chunk_position: Vector2i) -> void: diff --git a/world/weather_system.gd b/world/weather_system.gd index 9cdd7b1..312d2dd 100644 --- a/world/weather_system.gd +++ b/world/weather_system.gd @@ -93,6 +93,17 @@ func set_state(new_state: State) -> void: weather_changed.emit(state) +func persistent_state() -> Dictionary: + return {"state": int(state)} + + +func restore_persistent_state(value: Dictionary) -> void: + set_state(clampi(int(value.get("state", State.SUNNY)), State.SUNNY, State.RAIN) as State) + rain_amount = 1.0 if state == State.RAIN else 0.0 + if _day_night != null: + _day_night.set_weather_dim(rain_amount) + + func toggle() -> void: set_state(State.SUNNY if state == State.RAIN else State.RAIN) diff --git a/world/world_storage.gd b/world/world_storage.gd new file mode 100644 index 0000000..db6f838 --- /dev/null +++ b/world/world_storage.gd @@ -0,0 +1,673 @@ +class_name WorldStorage +extends RefCounted + +## Durable world metadata plus sparse final block edits. Procedural chunk data is +## never saved: chunks regenerate from their frozen worldgen config and replay +## these edits last. + +const METADATA_MAGIC := "redotcraft-world" +const METADATA_VERSION := 1 +const REGION_MAGIC := "RCRG" +const REGION_VERSION := 2 +const REGION_VERSION_LEGACY := 1 +const REGION_CHUNKS := 32 +const MAX_REGION_CHUNKS := REGION_CHUNKS * REGION_CHUNKS +const MAX_REGION_EDITS := 2_000_000 +const MAX_PALETTE_ENTRIES := 256 +const MAX_CACHED_REGIONS := 16 +const DEFAULT_ROOT := "user://worlds" +const LAST_WORLD_FILE := "last_world.txt" + +var root_path := DEFAULT_ROOT +var world_id := "" +var metadata: Dictionary = {} + +var _regions: Dictionary = {} +var _loaded_regions: Dictionary = {} +var _dirty_regions: Dictionary = {} +var _region_access: Dictionary = {} +var _access_tick := 0 +var _regions_loaded_from_backup: Dictionary = {} +var _metadata_loaded_from_backup := false + + +func _init(p_root_path: String = DEFAULT_ROOT) -> void: + root_path = p_root_path.trim_suffix("/") + + +func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requested_id: String = "") -> Dictionary: + _reset_cache() + var normalized_config := WorldGenConfig.new(world_config).to_dictionary() + world_id = _safe_id(requested_id) + if world_id.is_empty(): + world_id = "%d-%d-%d" % [ + int(Time.get_unix_time_from_system()), + abs(int(normalized_config.get("seed", 0))), + Time.get_ticks_usec(), + ] + var now := int(Time.get_unix_time_from_system()) + metadata = { + "magic": METADATA_MAGIC, + "format_version": METADATA_VERSION, + "id": world_id, + "name": "World %d" % int(normalized_config.get("seed", 0)), + "created_unix": now, + "updated_unix": now, + "worldgen": normalized_config, + "layout": { + "chunk_size": VoxelDefs.CHUNK_SIZE, + "world_height": VoxelDefs.WORLD_HEIGHT, + "region_chunks": REGION_CHUNKS, + }, + "block_registry_revision": 1, + "state": initial_state.duplicate(true), + } + if not _ensure_world_directories(): + metadata = {} + return {} + if _write_metadata() != OK: + metadata = {} + return {} + _write_last_world_id() + return metadata.duplicate(true) + + +func open_world(p_world_id: String) -> Dictionary: + _reset_cache() + world_id = _safe_id(p_world_id) + if world_id.is_empty(): + return {} + metadata = _read_metadata_file(_metadata_path()) + if metadata.is_empty() or not _validate_metadata(metadata): + metadata = {} + return {} + _write_last_world_id() + return metadata.duplicate(true) + + +func load_chunk_edits(chunk_pos: Vector2i) -> Dictionary: + var region_pos := _chunk_region(chunk_pos) + _load_region(region_pos) + var region: Dictionary = _regions.get(region_pos, {}) + var edits: Dictionary = region.get(chunk_pos, {}) + return edits.duplicate() + + +func stage_chunk_edits(chunk_pos: Vector2i, edits: Dictionary) -> void: + var region_pos := _chunk_region(chunk_pos) + _load_region(region_pos) + var region: Dictionary = _regions.get(region_pos, {}) + if edits.is_empty(): + region.erase(chunk_pos) + else: + region[chunk_pos] = edits.duplicate() + _regions[region_pos] = region + _dirty_regions[region_pos] = true + + +func flush(state: Dictionary = {}) -> Error: + var region_error := flush_dirty_regions() + if region_error != OK: + return region_error + if not state.is_empty(): + metadata["state"] = state.duplicate(true) + metadata["updated_unix"] = int(Time.get_unix_time_from_system()) + var metadata_error := _write_metadata() + if metadata_error == OK: + _write_last_world_id() + return metadata_error + + +func flush_dirty_regions() -> Error: + var dirty: Array[Vector2i] = [] + for key in _dirty_regions: + dirty.append(key) + dirty.sort_custom(_vector2_less) + for region_pos in dirty: + var error := _write_region(region_pos) + if error != OK: + return error + _dirty_regions.erase(region_pos) + _evict_clean_regions() + return OK + + +func has_dirty_regions() -> bool: + return not _dirty_regions.is_empty() + + +static func latest_world_metadata(p_root_path: String = DEFAULT_ROOT) -> Dictionary: + var root := p_root_path.trim_suffix("/") + var last_path := root + "/" + LAST_WORLD_FILE + if not FileAccess.file_exists(last_path): + return {} + var id := FileAccess.get_file_as_string(last_path).strip_edges() + if id.is_empty(): + return {} + var storage := WorldStorage.new(root) + return storage.open_world(id) + + +## Read-only library view for menus. Unlike open_world(), listing never changes +## the last-played pointer. Worlds from a newer build remain visible but are +## marked incompatible so players can still delete them deliberately. +static func list_world_summaries(p_root_path: String = DEFAULT_ROOT) -> Array[Dictionary]: + var root := p_root_path.trim_suffix("/") + var found: Array[Dictionary] = [] + var directory := DirAccess.open(root) + if directory == null: + return found + directory.list_dir_begin() + var entry := directory.get_next() + while not entry.is_empty(): + if entry != "." and entry != ".." and directory.current_is_dir() \ + and _safe_id(entry) == entry: + var summary := _read_world_summary(root, entry) + if not summary.is_empty(): + found.append(summary) + entry = directory.get_next() + directory.list_dir_end() + found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_time := int(a.get("updated_unix", 0)) + var b_time := int(b.get("updated_unix", 0)) + return a_time > b_time or (a_time == b_time and String(a.get("id", "")) < String(b.get("id", ""))) + ) + return found + + +## Permanently removes one validated world directory. The caller owns user +## confirmation; this boundary rejects traversal and unrelated directories. +static func delete_world(p_world_id: String, p_root_path: String = DEFAULT_ROOT) -> bool: + var id := _safe_id(p_world_id) + if id.is_empty() or id != p_world_id: + return false + var root := p_root_path.trim_suffix("/") + var world_path := root + "/" + id + if _read_world_summary(root, id).is_empty() \ + or not _remove_directory_tree(ProjectSettings.globalize_path(world_path)): + return false + var last_path := root + "/" + LAST_WORLD_FILE + if FileAccess.file_exists(last_path) \ + and FileAccess.get_file_as_string(last_path).strip_edges() == id: + DirAccess.remove_absolute(ProjectSettings.globalize_path(last_path)) + return true + + +static func _read_world_summary(root: String, id: String) -> Dictionary: + var path := root + "/" + id + "/metadata.json" + for candidate in [path, path + ".bak"]: + if not FileAccess.file_exists(candidate): + continue + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(candidate)) + if typeof(parsed) != TYPE_DICTIONARY: + continue + var summary := _summary_from_metadata(parsed, id) + if not summary.is_empty(): + return summary + return {} + + +static func _summary_from_metadata(value: Dictionary, id: String) -> Dictionary: + if value.get("magic", "") != METADATA_MAGIC or String(value.get("id", "")) != id \ + or typeof(value.get("worldgen", null)) != TYPE_DICTIONARY: + return {} + var worldgen: Dictionary = value["worldgen"] + var worldgen_version := int(worldgen.get("worldgen_version", -1)) + return { + "id": id, + "name": String(value.get("name", "")), + "created_unix": int(value.get("created_unix", 0)), + "updated_unix": int(value.get("updated_unix", 0)), + "seed": int(worldgen.get("seed", 0)), + "world_type": clampi(int(worldgen.get("world_type", 0)), 0, 2), + "worldgen_version": worldgen_version, + "compatible": int(value.get("format_version", -1)) == METADATA_VERSION \ + and worldgen_version >= 1 and worldgen_version <= WorldGenConfig.CURRENT_VERSION, + } + + +static func _remove_directory_tree(path: String) -> bool: + var directory := DirAccess.open(path) + if directory == null: + return false + var files: Array[String] = [] + var directories: Array[String] = [] + directory.list_dir_begin() + var entry := directory.get_next() + while not entry.is_empty(): + if entry != "." and entry != "..": + if directory.current_is_dir(): + directories.append(entry) + else: + files.append(entry) + entry = directory.get_next() + directory.list_dir_end() + for file_name in files: + if DirAccess.remove_absolute(path.path_join(file_name)) != OK: + return false + for directory_name in directories: + if not _remove_directory_tree(path.path_join(directory_name)): + return false + return DirAccess.remove_absolute(path) == OK + + +func _load_region(region_pos: Vector2i) -> void: + if _loaded_regions.has(region_pos): + _touch_region(region_pos) + return + _loaded_regions[region_pos] = true + var loaded := _read_region_file(_region_path(region_pos)) + if not bool(loaded.get("valid", false)) and FileAccess.file_exists(_region_backup_path(region_pos)): + loaded = _read_region_file(_region_backup_path(region_pos)) + if bool(loaded.get("valid", false)): + _regions_loaded_from_backup[region_pos] = true + _regions[region_pos] = loaded.get("region", {}) if bool(loaded.get("valid", false)) else {} + _touch_region(region_pos) + _evict_clean_regions() + + +func _write_region(region_pos: Vector2i) -> Error: + if not _ensure_world_directories(): + return ERR_CANT_CREATE + var region: Dictionary = _regions.get(region_pos, {}) + if not _validate_region_for_write(region_pos, region): + return ERR_INVALID_DATA + var path := _region_path(region_pos) + var temporary := path + ".tmp" + var file := FileAccess.open_compressed(temporary, FileAccess.WRITE, FileAccess.COMPRESSION_ZSTD) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(REGION_MAGIC.to_utf8_buffer()) + file.store_16(REGION_VERSION) + var chunks: Array[Vector2i] = [] + for key in region: + if key is Vector2i and not (region[key] as Dictionary).is_empty(): + chunks.append(key) + chunks.sort_custom(_vector2_less) + file.store_32(chunks.size()) + for chunk_pos in chunks: + var edits: Dictionary = region[chunk_pos] + var positions: Array[Vector3i] = [] + var palette_values: Dictionary = {} + for key in edits: + if key is Vector3i: + positions.append(key) + palette_values[int(edits[key])] = true + positions.sort_custom(_vector3_less) + var palette: Array[int] = [] + for block_id in palette_values: + palette.append(int(block_id)) + palette.sort() + var palette_indices: Dictionary = {} + for palette_index in range(palette.size()): + palette_indices[palette[palette_index]] = palette_index + file.store_32(chunk_pos.x) + file.store_32(chunk_pos.y) + file.store_16(palette.size()) + for block_id in palette: + file.store_8(block_id) + file.store_32(positions.size()) + var runs: Array[Vector3i] = [] # x=start local index, y=length, z=palette index + for position in positions: + var local_x := position.x - chunk_pos.x * VoxelDefs.CHUNK_SIZE + var local_z := position.z - chunk_pos.y * VoxelDefs.CHUNK_SIZE + var local_index := local_x + local_z * VoxelDefs.CHUNK_SIZE \ + + position.y * VoxelDefs.CHUNK_AREA + var palette_index := int(palette_indices[int(edits[position])]) + if not runs.is_empty(): + var last := runs[runs.size() - 1] + if last.x + last.y == local_index and last.z == palette_index and last.y < 65535: + last.y += 1 + runs[runs.size() - 1] = last + continue + runs.append(Vector3i(local_index, 1, palette_index)) + file.store_32(runs.size()) + var previous_end := 0 + for run in runs: + file.store_32(run.x - previous_end) + file.store_16(run.y) + file.store_16(run.z) + previous_end = run.x + run.y + file.flush() + var write_error := file.get_error() + file = null + if write_error != OK: + DirAccess.remove_absolute(ProjectSettings.globalize_path(temporary)) + return write_error + var result := _replace_with_backup(temporary, path, _region_backup_path(region_pos), + bool(_regions_loaded_from_backup.get(region_pos, false))) + if result == OK: + _regions_loaded_from_backup.erase(region_pos) + return result + + +func _read_region_file(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"valid": false, "region": {}} + var file := FileAccess.open_compressed(path, FileAccess.READ, FileAccess.COMPRESSION_ZSTD) + if file == null or not _can_read(file, 6): + return {"valid": false, "region": {}} + if file.get_buffer(4).get_string_from_utf8() != REGION_MAGIC: + return {"valid": false, "region": {}} + var version := int(file.get_16()) + if version == REGION_VERSION_LEGACY: + return _read_region_v1(file, path) if file.get_error() == OK else {"valid": false, "region": {}} + if version != REGION_VERSION: + return {"valid": false, "region": {}} + return _read_region_v2(file, path) if file.get_error() == OK else {"valid": false, "region": {}} + + +func _read_region_v1(file: FileAccess, path: String) -> Dictionary: + if not _can_read(file, 4): + return {"valid": false, "region": {}} + var chunk_count := int(file.get_32()) + if chunk_count < 0 or chunk_count > MAX_REGION_CHUNKS: + return {"valid": false, "region": {}} + var result: Dictionary = {} + var total_edits := 0 + var expected_region := _chunk_region_from_path(path) + for _chunk_index in range(chunk_count): + if not _can_read(file, 12): + return {"valid": false, "region": {}} + var chunk_pos := Vector2i(_signed_32(file.get_32()), _signed_32(file.get_32())) + var edit_count := int(file.get_32()) + total_edits += edit_count + if edit_count < 0 or total_edits > MAX_REGION_EDITS or result.has(chunk_pos) \ + or _chunk_region(chunk_pos) != expected_region: + return {"valid": false, "region": {}} + var edits: Dictionary = {} + for _edit_index in range(edit_count): + if not _can_read(file, 11): + return {"valid": false, "region": {}} + var x := _signed_32(file.get_32()) + var y := int(file.get_16()) + var z := _signed_32(file.get_32()) + var block_id := int(file.get_8()) + if y < 0 or y >= VoxelDefs.WORLD_HEIGHT or not _is_valid_block_id(block_id): + return {"valid": false, "region": {}} + var position := Vector3i(x, y, z) + if edits.has(position) or _chunk_for_block(position) != chunk_pos: + return {"valid": false, "region": {}} + edits[position] = block_id + result[chunk_pos] = edits + return {"valid": file.get_position() == file.get_length(), "region": result} + + +func _read_region_v2(file: FileAccess, path: String) -> Dictionary: + if not _can_read(file, 4): + return {"valid": false, "region": {}} + var chunk_count := int(file.get_32()) + if chunk_count < 0 or chunk_count > MAX_REGION_CHUNKS: + return {"valid": false, "region": {}} + var result: Dictionary = {} + var total_edits := 0 + var expected_region := _chunk_region_from_path(path) + for _chunk_index in range(chunk_count): + if not _can_read(file, 10): + return {"valid": false, "region": {}} + var chunk_pos := Vector2i(_signed_32(file.get_32()), _signed_32(file.get_32())) + var palette_count := int(file.get_16()) + if palette_count <= 0 or palette_count > MAX_PALETTE_ENTRIES or result.has(chunk_pos) \ + or _chunk_region(chunk_pos) != expected_region or not _can_read(file, palette_count + 8): + return {"valid": false, "region": {}} + var palette := PackedByteArray() + palette.resize(palette_count) + var previous_block_id := -1 + for palette_index in range(palette_count): + palette[palette_index] = file.get_8() + if int(palette[palette_index]) <= previous_block_id or not _is_valid_block_id(palette[palette_index]): + return {"valid": false, "region": {}} + previous_block_id = int(palette[palette_index]) + var edit_count := int(file.get_32()) + var run_count := int(file.get_32()) + total_edits += edit_count + if edit_count <= 0 or total_edits > MAX_REGION_EDITS or run_count <= 0 \ + or run_count > edit_count: + return {"valid": false, "region": {}} + var edits: Dictionary = {} + var previous_end := 0 + var decoded := 0 + for _run_index in range(run_count): + if not _can_read(file, 8): + return {"valid": false, "region": {}} + var delta := int(file.get_32()) + var length := int(file.get_16()) + var palette_index := int(file.get_16()) + var start := previous_end + delta + if delta < 0 or length <= 0 or palette_index < 0 or palette_index >= palette_count \ + or start < previous_end or start + length > VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT: + return {"valid": false, "region": {}} + for local_index in range(start, start + length): + var y := local_index / VoxelDefs.CHUNK_AREA + var horizontal := local_index % VoxelDefs.CHUNK_AREA + var local_z := horizontal / VoxelDefs.CHUNK_SIZE + var local_x := horizontal % VoxelDefs.CHUNK_SIZE + var position := Vector3i( + chunk_pos.x * VoxelDefs.CHUNK_SIZE + local_x, y, + chunk_pos.y * VoxelDefs.CHUNK_SIZE + local_z) + edits[position] = int(palette[palette_index]) + decoded += length + previous_end = start + length + if decoded != edit_count: + return {"valid": false, "region": {}} + result[chunk_pos] = edits + return {"valid": file.get_position() == file.get_length(), "region": result} + + +func _write_metadata() -> Error: + if not _ensure_world_directories() or not _validate_metadata(metadata): + return ERR_INVALID_DATA + var path := _metadata_path() + var temporary := path + ".tmp" + var file := FileAccess.open(temporary, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_string(JSON.stringify(metadata, "\t")) + file.flush() + var write_error := file.get_error() + file = null + if write_error != OK: + DirAccess.remove_absolute(ProjectSettings.globalize_path(temporary)) + return write_error + var result := _replace_with_backup(temporary, path, path + ".bak", _metadata_loaded_from_backup) + if result == OK: + _metadata_loaded_from_backup = false + return result + + +func _read_metadata_file(path: String) -> Dictionary: + _metadata_loaded_from_backup = false + if FileAccess.file_exists(path): + var primary: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + if typeof(primary) == TYPE_DICTIONARY and _validate_metadata(primary): + return primary + var backup := path + ".bak" + if FileAccess.file_exists(backup): + var recovered: Variant = JSON.parse_string(FileAccess.get_file_as_string(backup)) + if typeof(recovered) == TYPE_DICTIONARY and _validate_metadata(recovered): + _metadata_loaded_from_backup = true + return recovered + return {} + + +func _validate_metadata(value: Dictionary) -> bool: + if value.get("magic", "") != METADATA_MAGIC or int(value.get("format_version", -1)) != METADATA_VERSION: + return false + if _safe_id(String(value.get("id", ""))) != String(value.get("id", "")): + return false + if typeof(value.get("worldgen", null)) != TYPE_DICTIONARY or typeof(value.get("state", null)) != TYPE_DICTIONARY: + return false + var worldgen: Dictionary = value["worldgen"] + var worldgen_version := int(worldgen.get("worldgen_version", -1)) + if worldgen_version < 1 or worldgen_version > WorldGenConfig.CURRENT_VERSION: + return false + var layout: Variant = value.get("layout", {}) + if typeof(layout) != TYPE_DICTIONARY: + return false + return int(layout.get("chunk_size", -1)) == VoxelDefs.CHUNK_SIZE \ + and int(layout.get("world_height", -1)) == VoxelDefs.WORLD_HEIGHT \ + and int(layout.get("region_chunks", -1)) == REGION_CHUNKS + + +func _ensure_world_directories() -> bool: + if world_id.is_empty(): + return false + var error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_regions_path())) + return error == OK or error == ERR_ALREADY_EXISTS + + +func _replace_with_backup(temporary: String, path: String, backup: String, + preserve_recovery_backup: bool = false) -> Error: + var temp_abs := ProjectSettings.globalize_path(temporary) + var path_abs := ProjectSettings.globalize_path(path) + var backup_abs := ProjectSettings.globalize_path(backup) + if preserve_recovery_backup: + if FileAccess.file_exists(path): + var remove_error := DirAccess.remove_absolute(path_abs) + if remove_error != OK: + DirAccess.remove_absolute(temp_abs) + return remove_error + elif FileAccess.file_exists(path): + if FileAccess.file_exists(backup): + DirAccess.remove_absolute(backup_abs) + var backup_error := DirAccess.rename_absolute(path_abs, backup_abs) + if backup_error != OK: + DirAccess.remove_absolute(temp_abs) + return backup_error + var error := DirAccess.rename_absolute(temp_abs, path_abs) + if error != OK and not preserve_recovery_backup and FileAccess.file_exists(backup): + DirAccess.rename_absolute(backup_abs, path_abs) + return error + + +func _reset_cache() -> void: + _regions.clear() + _loaded_regions.clear() + _dirty_regions.clear() + _region_access.clear() + _regions_loaded_from_backup.clear() + _metadata_loaded_from_backup = false + _access_tick = 0 + + +func _touch_region(region_pos: Vector2i) -> void: + _access_tick += 1 + _region_access[region_pos] = _access_tick + + +func _evict_clean_regions() -> void: + while _regions.size() > MAX_CACHED_REGIONS: + var oldest_position: Variant = null + var oldest_tick := 9223372036854775807 + for region_pos in _regions: + if _dirty_regions.has(region_pos): + continue + var tick := int(_region_access.get(region_pos, 0)) + if tick < oldest_tick: + oldest_tick = tick + oldest_position = region_pos + if oldest_position == null: + return + _regions.erase(oldest_position) + _loaded_regions.erase(oldest_position) + _region_access.erase(oldest_position) + _regions_loaded_from_backup.erase(oldest_position) + + +func _validate_region_for_write(region_pos: Vector2i, region: Dictionary) -> bool: + if region.size() > MAX_REGION_CHUNKS: + return false + var total_edits := 0 + for chunk_value in region: + if not chunk_value is Vector2i or _chunk_region(chunk_value) != region_pos: + return false + var edits: Variant = region[chunk_value] + if typeof(edits) != TYPE_DICTIONARY: + return false + total_edits += edits.size() + if total_edits > MAX_REGION_EDITS: + return false + for position_value in edits: + if not position_value is Vector3i: + return false + var position: Vector3i = position_value + var block_value: Variant = edits[position] + if position.y < 0 or position.y >= VoxelDefs.WORLD_HEIGHT \ + or _chunk_for_block(position) != chunk_value \ + or typeof(block_value) != TYPE_INT or not _is_valid_block_id(block_value): + return false + return true + + +func _write_last_world_id() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(root_path)) + var file := FileAccess.open(root_path + "/" + LAST_WORLD_FILE, FileAccess.WRITE) + if file != null: + file.store_string(world_id) + + +func _metadata_path() -> String: + return _world_path() + "/metadata.json" + + +func _regions_path() -> String: + return _world_path() + "/regions" + + +func _region_path(region_pos: Vector2i) -> String: + return _regions_path() + "/r.%d.%d.rcregion" % [region_pos.x, region_pos.y] + + +func _region_backup_path(region_pos: Vector2i) -> String: + return _region_path(region_pos) + ".bak" + + +func _world_path() -> String: + return root_path + "/" + world_id + + +func _chunk_region(chunk_pos: Vector2i) -> Vector2i: + return Vector2i(WorldGenHash.floor_div(chunk_pos.x, REGION_CHUNKS), WorldGenHash.floor_div(chunk_pos.y, REGION_CHUNKS)) + + +func _chunk_region_from_path(path: String) -> Vector2i: + var file_name := path.get_file().trim_suffix(".bak") + var parts := file_name.split(".") + if parts.size() < 4: + return Vector2i(2147483647, 2147483647) + return Vector2i(int(parts[1]), int(parts[2])) + + +static func _chunk_for_block(position: Vector3i) -> Vector2i: + return Vector2i(floori(float(position.x) / float(VoxelDefs.CHUNK_SIZE)), floori(float(position.z) / float(VoxelDefs.CHUNK_SIZE))) + + +static func _signed_32(value: int) -> int: + return value - 4294967296 if value > 2147483647 else value + + +static func _can_read(file: FileAccess, byte_count: int) -> bool: + return byte_count >= 0 and file.get_position() <= file.get_length() - byte_count + + +static func _is_valid_block_id(block_id: int) -> bool: + for definition in BlockRegistry.BLOCK_DEFS: + if int(definition[0]) == block_id: + return true + return false + + +static func _safe_id(value: String) -> String: + var result := "" + for character in value: + if character.to_lower() in "abcdefghijklmnopqrstuvwxyz0123456789-_": + result += character + return result + + +static func _vector2_less(a: Vector2i, b: Vector2i) -> bool: + return a.y < b.y or (a.y == b.y and a.x < b.x) + + +static func _vector3_less(a: Vector3i, b: Vector3i) -> bool: + return a.y < b.y or (a.y == b.y and (a.z < b.z or (a.z == b.z and a.x < b.x))) diff --git a/world/world_storage.gd.uid b/world/world_storage.gd.uid new file mode 100644 index 0000000..db9bbf6 --- /dev/null +++ b/world/world_storage.gd.uid @@ -0,0 +1 @@ +uid://bwv0ldm1w4w37 diff --git a/world/worldgen/README.md b/world/worldgen/README.md index ff17177..07e054b 100644 --- a/world/worldgen/README.md +++ b/world/worldgen/README.md @@ -14,7 +14,8 @@ catalogs/samplers used by worker jobs. A chunk then runs these ordered stages: 2. Domain-warped continentalness and blended terrain profiles establish ocean, coast, plains, hills, plateaus, and mountain relief. 3. Analytic erosion and optional cached hydraulic erosion shape raw heights; - the river channel is then carved from cached corridor fields, and climate + the river channel is then carved from cached corridor fields, optional v10 + fixed-level or v11 routed elevated hydrology lowers supported lake/reach beds, and climate shaping/smoothing follows before surface selection. 4. Temperature/moisture choose primary and secondary biomes. Continuous fields shape terrain and tint foliage/water; broad terrain-aware ecotones carry the @@ -22,11 +23,11 @@ catalogs/samplers used by worker jobs. A chunk then runs these ordered stages: per-block biome dithering. 5. `VoxelPopulator.populate()` fills strata, carves caves, adds liquids and ore veins, cave biomes/dressing and geodes, stamps global-cell surface - decorations, then applies player edits last. + decorations and v11 region POIs, then applies player edits last. 6. `VoxelPopulator.populate_lod()` handles distance chunks: it writes compact - per-column top/sub/water arrays instead of a full voxel volume and skips - caves, ores, and ground flora. Real tree crowns are baked in using in-field - anchors only and the shared stamp functions; site-validity probes are + per-column top/sub/water arrays instead of a full voxel volume and skips + caves, ores, and detailed flora. It retains floor patches, low scrub, and real + tree crowns using in-field anchors and shared stamp functions; site-validity probes are skipped because they leave the padded field and cost nearly full population. `ChunkMesher.build_lod()` consumes those arrays directly, and full chunks expand compact neighbors on the worker thread. Keep the compact @@ -62,8 +63,9 @@ Compare like-for-like runs on the same machine. ## Main tuning controls -Defaults and persisted values live in `autoload/game_config.gd`; validation and -ranges live in `world_gen_config.gd`. +Canonical defaults, validation, and import ranges live in `world_gen_config.gd`; +`GameConfig` clones that default dictionary for the next world. The Advanced UI +intentionally exposes a narrower, conservative tuning envelope than imports. - `terrain_scale`: multiplies local relief; Amplified applies an additional scale. - `macro_scale`: size of continents and broad terrain regions. @@ -75,11 +77,35 @@ ranges live in `world_gen_config.gd`. - `erosion_strength`: local talus smoothing and profile terracing. - `regional_erosion`: broad rainfall/transport erosion strength. - `hydraulic_erosion`: deterministic 64x64 droplet tiles. This is deliberately - off by default because cold generation is substantially slower. + off by default because cold generation is substantially slower. It uses + `regional_erosion` as its strength and is inactive when that value is zero. - `cave_density`: spaghetti thickness plus cross-link, chamber, cavern, and mega-cave frequency. A thin canonical trunk remains at every nonzero value. - `tree_density`: multiplier for tree-class decorations only. -- `decoration_density`: multiplier for all surface decoration. +- `decoration_density`: multiplier for trees, feature-lattice decoration, + ground cover, and underwater plants. Floor patches are a separate pass. +- `spline_terrain`: v10 experimental monotone cubic continentalness/profile + remapping; v11 expands it to a monotone 2D continentalness/landform grid. Off + by default. +- `elevated_hydrology`: v10 experimental fixed-level highland lakes/reaches with + lowered banks and supported beds; v11 routes deterministic downhill reaches, + lakes, and stepped waterfall sections. Off by default. +- `climate_variants`: v11 third climate channel enabling snowy taiga, wooded + badlands, and stony shore variants. On by default. +- `region_structures`: v11 deterministic region-cell camps and watchtower ruins. + On by default; player edits are applied after their cross-chunk stamps. + +`worldgen_version` is persisted as a compatibility boundary. Version 8 retains +the original cave-biome classifier and excludes later boulders; version 9 adds +dripstone cave regions and highland boulders; version 10 preserves the original +one-dimensional spline and fixed-level hydrology outputs; version 11 adds the +variant climate channel, 2D spline grid, routed hydrology, organic cave regions, +large trees, improved lava basins, region POIs, and asymptotic height ceiling; +version 12 replaces frequent raised cobblestone outcrop props with low stone +pebbles while preserving rare boulders and intentional structure masonry; version +13 removes the rigid fallen-log and driftwood props from natural decoration. +Output-changing changes require an explicit legacy path before `CURRENT_VERSION` +is raised. Tune profile geometry in `terrain_profile_catalog.gd`, biome climate/layers/tints in `biome_catalog.gd`, and weighted feature sets in `decoration_catalog.gd`. @@ -148,8 +174,8 @@ use `dominant_biome` for discrete surface/vegetation choices and keep the smooth primary/secondary blend for foliage and water tinting. Local detail is controlled separately from mountain height: each profile has -`FIELD_LOCAL_RELIEF` (24/64-block knolls and shoulders, with shallow 48-block -gullies in uplands) and `FIELD_SURFACE_DETAIL` (10-block undulations). These are +`FIELD_LOCAL_RELIEF` (32/80-block knolls and shoulders, with shallow 52-block +gullies in uplands) and `FIELD_SURFACE_DETAIL` (13-block undulations). These are amplitudes in blocks, smoothly blended across profiles and faded near shores. Increase these rather than shortening the mountain-region wavelength. The fine layer is deliberately weakest in plains so walking terrain stays usable. @@ -193,6 +219,18 @@ field/point parity and seams hold. The lowland and continental gates keep channels sea-connected and prevent cuts through uplands; only downward motion is applied, so natural hollows are never filled. +### Versioned optional terrain + +Spline terrain uses monotone piecewise cubic curves to remap continentalness +and profile response without overshooting control points; v11 selects among a +second landform axis. Elevated hydrology is a global-cell system rather than a +local flood simulation: every touched chunk independently resolves the same +route, water level, bed, and bank shape, and +`ChunkTerrainData.inland_water_y` is authoritative for both full and compact +population. River biome ownership suppresses land decoration inside these +water cells. Both switches default off so worlds retain the established terrain +unless explicitly enabled. + ### Terrain-shape guardrails - Mountain regions use the broad landform field, independently of ridge detail. @@ -271,7 +309,7 @@ to review it; an already-running world retains its configured sampler and chunks deterministic output, neighboring seams, edit priority, concurrent generation, biome/river coverage, height limits, field/point parity, flat-world height, coherent surface blankets, and adjacent height/slope limits plus the share of - 2-block steps (`rough_ratio`) near and far from the origin. It also checks + steps greater than 2 blocks (`rough_ratio`) near and far from the origin. It also checks that all six underwater biomes appear at the right depth/climate, that no land biome strands below sea level, that reef mounds raise the shelf floor without breaking the water surface, and that seabed plants stay on their biome's @@ -282,12 +320,30 @@ to review it; an already-running world retains its configured sampler and chunks - `redot --headless --path . --script res://tools/worldgen_cave_verify.gd` checks cave-biome classification and dressing, hollow layered geodes, crystal emission, sealed cave darkness, opening falloff, and mesher light bounds. +- `redot --headless --path . --script res://tools/worldgen_water_verify.gd` + checks aquifer determinism, cross-chunk continuity, stable water tables, and + surface/river protection for positive and negative chunk coordinates. +- `redot --headless --path . --script res://tools/worldgen_ore_verify.gd` + checks ordered ore-catalog selection and frozen chunk SHA-256 fixtures. +- `redot --headless --path . --script res://tools/worldgen_structure_verify.gd` + checks version-gated highland boulders and cross-chunk footprints. +- `redot --headless --path . --script res://tools/worldgen_spline_verify.gd` + checks v10 compatibility, the v11 2D profile grid, monotonic remapping, seams, + and disabled-output compatibility. +- `redot --headless --path . --script res://tools/worldgen_elevated_hydrology_verify.gd` + checks v10 compatibility plus v11 downhill routes, lakes, waterfall steps, + highland water seams, supported beds/banks, decoration ownership, and + full/compact parity. - `redot --headless --path . --script res://tools/worldgen_mesh_benchmark.gd` - records full and LOD mesh CPU time for pinned chunks. + records full and LOD mesh CPU time for pinned chunks, then emits a paste-ready + Markdown table. Its light assembly, sky light, block light, padding, and face + emission columns describe the full mesh only. - `redot --headless --path . --script res://tools/worldgen_tree_verify.gd` checks spruce taper/tips and repeatable, bounded broadleaf/spruce geometry across chunk edges. The main verifier also checks nonzero local detail and its amplitude budget, to guard against both flattening and runaway roughness. +- `redot --headless --path . --script res://tools/worldgen_cactus_verify.gd` + checks bounded cactus geometry and deterministic cross-chunk stamping. - `redot --headless --path . --script res://tools/worldgen_biome_verify.gd` checks biome-neighbor coherence, ecotone width and secondary ownership, contiguous forest/swamp/jungle/taiga territory radius, signature vegetation @@ -313,11 +369,13 @@ to review it; an already-running world retains its configured sampler and chunks checks that the render distance really renders full chunks: no LOD inside the configured distance, collision only near the player, and collision added when approaching a distant chunk. +- `redot --headless --path . --script res://tools/lod_mode_verify.gd` + checks Full Detail/Balanced policy transitions and streams an RD 10 mixed ring. - `redot --headless --path . --script res://tools/worldgen_stream_benchmark.gd` records ring-load wall time and throughput at render distances 10/16/32 and several job-concurrency levels. -The pinned normal-generation sample currently averages about 44 ms for full +The pinned normal-generation sample currently averages about 57 ms for full voxel generation and 15 ms for compact LOD data on the development machine. Full mesh CPU remains much more expensive because it builds a 3x3 light volume, floods sky and RGB light, computes AO, and emits collision triangles; keep that diff --git a/world/worldgen/biome_catalog.gd b/world/worldgen/biome_catalog.gd index 35abce6..ec151d6 100644 --- a/world/worldgen/biome_catalog.gd +++ b/world/worldgen/biome_catalog.gd @@ -29,8 +29,18 @@ const FROZEN_OCEAN: int = 18 ## selection; VoxelPopulator uses them only on exposed cave surfaces. const LUSH_CAVES: int = 19 const DEEP_DARK: int = 20 +const DRIPSTONE_CAVES: int = 21 +## V11 surface variants selected by the third climate channel. Appending keeps +## every persisted v1-v10 biome ID stable. +const SNOWY_TAIGA: int = 22 +const WOODED_BADLANDS: int = 23 +const STONY_SHORE: int = 24 const CAVE_BIOME_NONE: int = -1 +## V1-v10 used these horizontal cells directly. V11 keeps their fixed global +## footprint, but samples their hashed corners as a continuous 3D field. const CAVE_REGION_SIZE: int = 96 +const CAVE_REGION_HEIGHT: int = 48 +const CAVE_HASH_MAX: float = 2147483647.0 const DECORATION_NONE: int = 0 const DECORATION_GRASSLAND: int = 1 @@ -87,6 +97,7 @@ const BLOCK_CORAL_SUBSTRATE: int = 52 const BLOCK_MOSS: int = 62 const BLOCK_DEEPSTONE: int = 64 const BLOCK_SCULK: int = 65 +const BLOCK_CALCITE: int = 66 var _names: PackedStringArray = PackedStringArray() var _temperature_centers: PackedFloat32Array = PackedFloat32Array() @@ -122,6 +133,10 @@ func _init() -> void: _add("frozen_sea", 0.08, 0.60, BLOCK_GRAVEL, BLOCK_STONE, BLOCK_GRAVEL, 2, Color("#9fc6d4"), DECORATION_FROZEN_SEA, TYPE_NONE) _add("lush_caves", 0.52, 1.0, BLOCK_MOSS, BLOCK_STONE, BLOCK_STONE, 2, Color("#67a855"), DECORATION_NONE, TYPE_NONE) _add("deep_dark", 0.18, 0.38, BLOCK_DEEPSTONE, BLOCK_SCULK, BLOCK_DEEPSTONE, 2, Color("#24525a"), DECORATION_NONE, TYPE_NONE) + _add("dripstone_caves", 0.42, 0.35, BLOCK_CALCITE, BLOCK_STONE, BLOCK_STONE, 2, Color("#8f765d"), DECORATION_NONE, TYPE_NONE) + _add("snowy_taiga", 0.22, 0.60, BLOCK_SNOW, BLOCK_DIRT, BLOCK_GRAVEL, 3, Color("#78958a"), DECORATION_TAIGA, TYPE_SPRUCE) + _add("wooded_badlands", 0.76, 0.38, BLOCK_RED_SAND, BLOCK_TERRACOTTA, BLOCK_RED_SAND, 5, Color("#9f7045"), DECORATION_SAVANNA, TYPE_ACACIA) + _add("stony_shore", 0.45, 0.48, BLOCK_GRAVEL, BLOCK_STONE, BLOCK_GRAVEL, 2, Color("#7d8978"), DECORATION_BEACH, TYPE_NONE) func biome_count() -> int: @@ -154,7 +169,7 @@ func is_ocean_biome(biome: int) -> bool: ## can classify a biome id without constructing a catalog. static func is_cold_biome(biome: int) -> bool: match biome: - SNOW, TAIGA, HIGHLANDS, FROZEN_OCEAN: + SNOW, TAIGA, SNOWY_TAIGA, HIGHLANDS, FROZEN_OCEAN: return true return false @@ -167,28 +182,96 @@ static func is_wetland_biome(biome: int) -> bool: ## Coherent 3D cave-region identity. Surface biomes remain a 2D climate layer; ## callers must additionally verify that the queried voxel is underground air. -## Broad global cells keep the result deterministic and worker-safe while the -## depth gates reserve lush caves for the damp middle band and deep dark for -## the lowest caverns. -static func cave_biome_at(seed: int, world_x: int, y: int, world_z: int) -> int: +## V1-v10 intentionally retain their exact column classifier for persisted +## worlds. V11 samples a stateless, global value-noise field, so labels change +## gradually across voxel and chunk boundaries rather than at cell edges. +static func cave_biome_at(seed: int, world_x: int, y: int, world_z: int, + worldgen_version: int = 9) -> int: if y < 5 or y > 78: return CAVE_BIOME_NONE + if worldgen_version <= 10: + return _legacy_cave_biome_at(seed, world_x, y, world_z, worldgen_version) + var region_value := cave_region_value_at(seed, world_x, y, world_z) + var depth := clampf(float(78 - y) / 73.0, 0.0, 1.0) + # Deep dark progressively recedes before the middle cave band, avoiding a + # horizontal biome shelf while reserving the lowest caverns for sculk. + var deep_dark_threshold := 0.38 * _smooth_curve(clampf(float(42 - y) / 37.0, 0.0, 1.0)) + # Damp lush regions are more common with depth. Dripstone occupies the + # higher-valued portion of the same smooth field, with its upper limit also + # varying by depth so it does not form a vertical column. + var lush_threshold := 0.68 + depth * 0.12 + var dripstone_threshold := 0.87 + depth * 0.07 + if region_value < deep_dark_threshold: + return DEEP_DARK + if region_value < lush_threshold: + return LUSH_CAVES + if region_value < dripstone_threshold: + return DRIPSTONE_CAVES + return CAVE_BIOME_NONE + + +## Stateless 3D value noise used by v11+ cave classification. Exposing the +## scalar lets verification assert continuity without coupling to thresholds. +static func cave_region_value_at(seed: int, world_x: int, y: int, world_z: int) -> float: + var cell_x := floori(float(world_x) / float(CAVE_REGION_SIZE)) + var cell_y := floori(float(y) / float(CAVE_REGION_HEIGHT)) + var cell_z := floori(float(world_z) / float(CAVE_REGION_SIZE)) + var x_fraction := float(world_x - cell_x * CAVE_REGION_SIZE) / float(CAVE_REGION_SIZE) + var y_fraction := float(y - cell_y * CAVE_REGION_HEIGHT) / float(CAVE_REGION_HEIGHT) + var z_fraction := float(world_z - cell_z * CAVE_REGION_SIZE) / float(CAVE_REGION_SIZE) + var x_weight := _smooth_curve(x_fraction) + var y_weight := _smooth_curve(y_fraction) + var z_weight := _smooth_curve(z_fraction) + var low_front := _lerp_cave_corners(seed, cell_x, cell_y, cell_z, x_weight, z_weight) + var high_front := _lerp_cave_corners(seed, cell_x, cell_y + 1, cell_z, x_weight, z_weight) + return lerpf(low_front, high_front, y_weight) + + +static func _legacy_cave_biome_at(seed: int, world_x: int, y: int, world_z: int, + worldgen_version: int) -> int: var cell_x := floori(float(world_x) / float(CAVE_REGION_SIZE)) var cell_z := floori(float(world_z) / float(CAVE_REGION_SIZE)) var hash_value := _cave_hash(seed, cell_x, cell_z) var roll := hash_value % 100 + if worldgen_version <= 8: + if y <= 34: + if roll < 45: + return DEEP_DARK + if roll < 90: + return LUSH_CAVES + elif roll < 90: + return LUSH_CAVES + return CAVE_BIOME_NONE if y <= 34: if roll < 45: return DEEP_DARK + if roll < 65: + return DRIPSTONE_CAVES if roll < 90: return LUSH_CAVES - elif roll < 90: - return LUSH_CAVES + else: + if roll < 70: + return LUSH_CAVES + if roll < 90: + return DRIPSTONE_CAVES return CAVE_BIOME_NONE +static func _lerp_cave_corners(seed: int, cell_x: int, cell_y: int, cell_z: int, + x_weight: float, z_weight: float) -> float: + var front_low := float(_cave_hash_3d(seed, cell_x, cell_y, cell_z)) / CAVE_HASH_MAX + var front_high := float(_cave_hash_3d(seed, cell_x + 1, cell_y, cell_z)) / CAVE_HASH_MAX + var back_low := float(_cave_hash_3d(seed, cell_x, cell_y, cell_z + 1)) / CAVE_HASH_MAX + var back_high := float(_cave_hash_3d(seed, cell_x + 1, cell_y, cell_z + 1)) / CAVE_HASH_MAX + return lerpf(lerpf(front_low, front_high, x_weight), lerpf(back_low, back_high, x_weight), z_weight) + + +static func _smooth_curve(value: float) -> float: + return value * value * (3.0 - 2.0 * value) + + static func is_cave_biome(biome: int) -> bool: - return biome == LUSH_CAVES or biome == DEEP_DARK + return biome == LUSH_CAVES or biome == DEEP_DARK or biome == DRIPSTONE_CAVES static func cave_surface_block(biome: int, selector: int = 0) -> int: @@ -196,6 +279,8 @@ static func cave_surface_block(biome: int, selector: int = 0) -> int: return BLOCK_MOSS if biome == DEEP_DARK: return BLOCK_SCULK if selector % 5 == 0 else BLOCK_DEEPSTONE + if biome == DRIPSTONE_CAVES: + return BLOCK_CALCITE return BLOCK_STONE @@ -204,6 +289,8 @@ static func cave_ambience_color(biome: int) -> Color: return Color("#315c3e") if biome == DEEP_DARK: return Color("#102b35") + if biome == DRIPSTONE_CAVES: + return Color("#59483b") return Color("#242936") @@ -212,6 +299,8 @@ static func cave_display_name(biome: int) -> String: return "LUSH CAVES" if biome == DEEP_DARK: return "DEEP DARK" + if biome == DRIPSTONE_CAVES: + return "DRIPSTONE CAVES" return "CAVES" @@ -222,6 +311,13 @@ static func _cave_hash(seed: int, x: int, z: int) -> int: return (value ^ (value >> 16)) & 0x7fffffff +static func _cave_hash_3d(seed: int, x: int, y: int, z: int) -> int: + var value: int = seed ^ (x * 73856093) ^ (y * 83492791) ^ (z * 19349663) ^ 0x51EAD5B + value = ((value ^ (value >> 16)) * 0x45D9F3B) & 0x7fffffff + value = ((value ^ (value >> 16)) * 0x45D9F3B) & 0x7fffffff + return (value ^ (value >> 16)) & 0x7fffffff + + func temperature_center(biome: int) -> float: return _temperature_centers[_safe_id(biome)] @@ -322,9 +418,9 @@ func water_tint(biome: int) -> Color: return Color(0.68, 0.86, 0.62, 1.0) RIVER: return Color(0.82, 1.04, 1.08, 1.0) - SNOW, TAIGA: + SNOW, TAIGA, SNOWY_TAIGA: return Color(0.82, 1.02, 1.12, 1.0) - DESERT, BADLANDS: + DESERT, BADLANDS, WOODED_BADLANDS: return Color(1.08, 1.03, 0.84, 1.0) _: return Color.WHITE diff --git a/world/worldgen/chunk_terrain_data.gd b/world/worldgen/chunk_terrain_data.gd index fb05f78..9caccb4 100644 --- a/world/worldgen/chunk_terrain_data.gd +++ b/world/worldgen/chunk_terrain_data.gd @@ -17,6 +17,7 @@ var base_height: PackedFloat32Array = PackedFloat32Array() var final_height: PackedFloat32Array = PackedFloat32Array() var slope: PackedFloat32Array = PackedFloat32Array() var river: PackedFloat32Array = PackedFloat32Array() +var inland_water_y: PackedInt32Array = PackedInt32Array() var temperature: PackedFloat32Array = PackedFloat32Array() var moisture: PackedFloat32Array = PackedFloat32Array() @@ -108,6 +109,8 @@ func _resize_fields() -> void: final_height.resize(CELL_COUNT) slope.resize(CELL_COUNT) river.resize(CELL_COUNT) + inland_water_y.resize(CELL_COUNT) + inland_water_y.fill(-1) temperature.resize(CELL_COUNT) moisture.resize(CELL_COUNT) profile_id.resize(CELL_COUNT) diff --git a/world/worldgen/decoration_catalog.gd b/world/worldgen/decoration_catalog.gd index e57f50b..75ee8f8 100644 --- a/world/worldgen/decoration_catalog.gd +++ b/world/worldgen/decoration_catalog.gd @@ -56,7 +56,7 @@ var _sets: Dictionary = {} var _underwater_sets: Dictionary = {} -func _init() -> void: +func _init(worldgen_version: int = 9) -> void: # Entries are [feature type, relative weight, independent occurrence chance, # placement flags]. The occurrence chance is multiplied by world settings. _sets = { @@ -77,10 +77,9 @@ func _init() -> void: ], BiomeCatalog.DECORATION_FOREST: [ [FEATURE_OAK, 30, 0.44, FLAG_TREE], [FEATURE_BIRCH, 15, 0.30, FLAG_TREE], - [FEATURE_LARGE_TREE, 7, 0.0, FLAG_TREE], [FEATURE_YELLOW_FLOWER, 5, 0.35, 0], [FEATURE_RED_FLOWER, 5, 0.35, 0], [FEATURE_BUSH, 10, 0.24, 0], [FEATURE_PEBBLE, 3, 0.05, 0], - [FEATURE_FALLEN_LOG, 6, 0.14, 0], [FEATURE_STUMP, 5, 0.12, 0], + [FEATURE_STUMP, 5, 0.12, 0], [FEATURE_BROWN_MUSHROOM, 8, 0.40, FLAG_SHADE], [FEATURE_RED_MUSHROOM, 3, 0.20, FLAG_SHADE], ], BiomeCatalog.DECORATION_DESERT: [ @@ -90,44 +89,64 @@ func _init() -> void: BiomeCatalog.DECORATION_SWAMP: [ [FEATURE_MANGROVE, 28, 0.72, FLAG_TREE | FLAG_WATER_EDGE], [FEATURE_REEDS, 48, 0.90, FLAG_WATER_EDGE], [FEATURE_VINE, 20, 0.70, FLAG_WATER_EDGE], [FEATURE_BROWN_MUSHROOM, 8, 0.42, FLAG_SHADE], - [FEATURE_RED_MUSHROOM, 4, 0.24, FLAG_SHADE], [FEATURE_DRIFTWOOD, 5, 0.14, FLAG_WATER_EDGE], + [FEATURE_RED_MUSHROOM, 4, 0.24, FLAG_SHADE], [FEATURE_STUMP, 3, 0.10, 0], ], BiomeCatalog.DECORATION_RIVERBANK: [ [FEATURE_REEDS, 58, 0.92, FLAG_WATER_EDGE], [FEATURE_YELLOW_FLOWER, 8, 0.36, FLAG_WATER_EDGE], - [FEATURE_RED_FLOWER, 6, 0.32, FLAG_WATER_EDGE], [FEATURE_DRIFTWOOD, 8, 0.20, FLAG_WATER_EDGE], + [FEATURE_RED_FLOWER, 6, 0.32, FLAG_WATER_EDGE], [FEATURE_PEBBLE, 5, 0.12, FLAG_WATER_EDGE], ], BiomeCatalog.DECORATION_BEACH: [ - [FEATURE_DRIFTWOOD, 12, 0.26, 0], [FEATURE_PEBBLE, 10, 0.22, 0], + [FEATURE_PEBBLE, 10, 0.22, 0], [FEATURE_REEDS, 8, 0.16, FLAG_WATER_EDGE], ], BiomeCatalog.DECORATION_TROPICAL: [ - [FEATURE_JUNGLE, 34, 0.62, FLAG_TREE], [FEATURE_LARGE_TREE, 6, 0.0, FLAG_TREE], + [FEATURE_JUNGLE, 34, 0.62, FLAG_TREE], [FEATURE_BAMBOO, 28, 0.80, 0], [FEATURE_VINE, 22, 0.75, 0], [FEATURE_BUSH, 10, 0.22, 0], [FEATURE_MELON, 10, 0.30, 0], - [FEATURE_FALLEN_LOG, 5, 0.12, 0], [FEATURE_STUMP, 4, 0.10, 0], + [FEATURE_STUMP, 4, 0.10, 0], ], BiomeCatalog.DECORATION_TAIGA: [ [FEATURE_SPRUCE, 48, 0.62, FLAG_TREE], [FEATURE_BUSH, 6, 0.16, 0], [FEATURE_BROWN_MUSHROOM, 10, 0.38, FLAG_SHADE], [FEATURE_RED_MUSHROOM, 4, 0.20, FLAG_SHADE], - [FEATURE_STUMP, 6, 0.14, 0], [FEATURE_FALLEN_LOG, 6, 0.14, 0], + [FEATURE_STUMP, 6, 0.14, 0], [FEATURE_DEAD_TREE, 3, 0.07, 0], [FEATURE_PEBBLE, 3, 0.06, 0], ], BiomeCatalog.DECORATION_SNOWFIELD: [ [FEATURE_SPRUCE, 18, 0.20, FLAG_TREE], - [FEATURE_ROCK_OUTCROP, 4, 0.10, 0], [FEATURE_PEBBLE, 4, 0.10, 0], + [FEATURE_PEBBLE if worldgen_version >= 12 else FEATURE_ROCK_OUTCROP, 4, 0.10, 0], + [FEATURE_PEBBLE, 4, 0.10, 0], ], BiomeCatalog.DECORATION_BADLANDS: [ [FEATURE_CACTUS, 20, 0.34, FLAG_DRY_GROUND], [FEATURE_DEAD_BUSH, 38, 0.56, FLAG_DRY_GROUND], - [FEATURE_PEBBLE, 10, 0.20, FLAG_DRY_GROUND], [FEATURE_ROCK_OUTCROP, 6, 0.14, FLAG_DRY_GROUND], + [FEATURE_PEBBLE, 10, 0.20, FLAG_DRY_GROUND], + [FEATURE_PEBBLE if worldgen_version >= 12 else FEATURE_ROCK_OUTCROP, 6, 0.14, FLAG_DRY_GROUND], [FEATURE_DEAD_TREE, 3, 0.08, FLAG_DRY_GROUND], ], BiomeCatalog.DECORATION_ALPINE: [ [FEATURE_SPRUCE, 12, 0.18, FLAG_TREE], - [FEATURE_ROCK_OUTCROP, 8, 0.18, 0], [FEATURE_PEBBLE, 6, 0.14, 0], + [FEATURE_PEBBLE if worldgen_version >= 12 else FEATURE_ROCK_OUTCROP, 8, 0.18, 0], + [FEATURE_PEBBLE, 6, 0.14, 0], ], } + if worldgen_version <= 12: + # Preserve the exact pre-v13 weighted order for existing worlds. New worlds + # omit these rigid horizontal log props entirely. + _sets[BiomeCatalog.DECORATION_FOREST].insert(6, [FEATURE_FALLEN_LOG, 6, 0.14, 0]) + _sets[BiomeCatalog.DECORATION_SWAMP].insert(5, [FEATURE_DRIFTWOOD, 5, 0.14, FLAG_WATER_EDGE]) + _sets[BiomeCatalog.DECORATION_RIVERBANK].insert(3, [FEATURE_DRIFTWOOD, 8, 0.20, FLAG_WATER_EDGE]) + _sets[BiomeCatalog.DECORATION_BEACH].insert(0, [FEATURE_DRIFTWOOD, 12, 0.26, 0]) + _sets[BiomeCatalog.DECORATION_TROPICAL].insert(5, [FEATURE_FALLEN_LOG, 5, 0.12, 0]) + _sets[BiomeCatalog.DECORATION_TAIGA].insert(5, [FEATURE_FALLEN_LOG, 6, 0.14, 0]) + if worldgen_version >= 9: + _sets[BiomeCatalog.DECORATION_ALPINE].append([FEATURE_BOULDER, 2, 0.06, 0]) + if worldgen_version >= 11: + # The old rows used a zero occurrence chance and were permanently dead. + # Activate the existing large-tree stamp only for v11+ so legacy worlds + # remain byte-identical. + _sets[BiomeCatalog.DECORATION_FOREST].append([FEATURE_LARGE_TREE, 4, 0.035, FLAG_TREE]) + _sets[BiomeCatalog.DECORATION_TROPICAL].append([FEATURE_LARGE_TREE, 3, 0.025, FLAG_TREE]) # Entry shape matches the land sets: [feature, weight, chance, flags]. _underwater_sets = { BiomeCatalog.DECORATION_SHELF: [ @@ -199,15 +218,11 @@ func tree_entries_for_set(decoration_set: int) -> Array: func choose_tree(decoration_set: int, selector: float) -> Array: - return _choose_from(tree_entries_for_set(decoration_set), selector) + return _choose_filtered(entries_for_set(decoration_set), selector, true) func choose_non_tree(decoration_set: int, selector: float) -> Array: - var decorations: Array = [] - for entry in entries_for_set(decoration_set): - if (int(entry[3]) & FLAG_TREE) == 0: - decorations.append(entry) - return _choose_from(decorations, selector) + return _choose_filtered(entries_for_set(decoration_set), selector, false) ## Ground cover is populated independently of the one-feature-per-cell lottery. @@ -249,3 +264,27 @@ func _choose_from(entries: Array, selector: float) -> Array: if target < running: return entry return entries[entries.size() - 1] + + +## Weighted selection without allocating a filtered Array for every candidate. +## The two ordered passes intentionally match `_choose_from()`'s arithmetic and +## fallback so generation output remains byte-for-byte deterministic. +func _choose_filtered(entries: Array, selector: float, want_tree: bool) -> Array: + var total_weight := 0 + var last_match: Array = [] + for entry in entries: + if ((int(entry[3]) & FLAG_TREE) != 0) != want_tree: + continue + total_weight += int(entry[1]) + last_match = entry + if total_weight <= 0: + return [] + var target := clampf(selector, 0.0, 0.999999) * float(total_weight) + var running := 0.0 + for entry in entries: + if ((int(entry[3]) & FLAG_TREE) != 0) != want_tree: + continue + running += float(entry[1]) + if target < running: + return entry + return last_match diff --git a/world/worldgen/ore_catalog.gd b/world/worldgen/ore_catalog.gd new file mode 100644 index 0000000..38ba392 --- /dev/null +++ b/world/worldgen/ore_catalog.gd @@ -0,0 +1,45 @@ +## Ordered ore selection rules. Rule order and cumulative roll thresholds are +## part of deterministic world compatibility; do not convert this to a map. +class_name OreCatalog +extends RefCounted + +const BlockRegistryScript = preload("res://world/block_registry.gd") + +const LEGACY_VERSION_MAX := 13 + + +class OreRule extends RefCounted: + var block_id: int + var max_anchor_y_exclusive: int + var roll_exclusive: int + + func _init(p_block_id: int, p_max_anchor_y_exclusive: int, p_roll_exclusive: int) -> void: + block_id = p_block_id + max_anchor_y_exclusive = p_max_anchor_y_exclusive + roll_exclusive = p_roll_exclusive + + +var rules: Array[OreRule] = [] + + +func _init(worldgen_version: int) -> void: + # Version 8 is the first cataloged layout. Later versions change other systems; + # all supported revisions retain this layout until a distinct ore catalog exists. + assert(worldgen_version >= 1 and worldgen_version <= LEGACY_VERSION_MAX) + rules = _legacy_v8_rules() + + +func select(hash_value: int, anchor_y: int) -> int: + var roll := hash_value % 100 + for rule in rules: + if anchor_y < rule.max_anchor_y_exclusive and roll < rule.roll_exclusive: + return rule.block_id + return BlockRegistryScript.BLOCK_AIR + + +static func _legacy_v8_rules() -> Array[OreRule]: + var result: Array[OreRule] = [] + result.append(OreRule.new(BlockRegistryScript.BLOCK_GOLD_ORE, 32, 17)) + result.append(OreRule.new(BlockRegistryScript.BLOCK_IRON_ORE, 58, 34)) + result.append(OreRule.new(BlockRegistryScript.BLOCK_COAL_ORE, 90, 57)) + return result diff --git a/world/worldgen/ore_catalog.gd.uid b/world/worldgen/ore_catalog.gd.uid new file mode 100644 index 0000000..452cb72 --- /dev/null +++ b/world/worldgen/ore_catalog.gd.uid @@ -0,0 +1 @@ +uid://dkx00bdkhse6l diff --git a/world/worldgen/structure_catalog.gd b/world/worldgen/structure_catalog.gd new file mode 100644 index 0000000..7271f8b --- /dev/null +++ b/world/worldgen/structure_catalog.gd @@ -0,0 +1,87 @@ +## Immutable definitions for sparse, region-scale surface POIs. +## +## Owner cells select candidates globally; the populator clips their fixed bounds +## into every affected chunk. This catalog holds no per-generation state. +class_name StructureCatalog +extends RefCounted + +const BlockRegistryScript = preload("res://world/block_registry.gd") +const WorldGenHashScript = preload("res://world/worldgen/world_gen_hash.gd") + +const OWNER_CELL_SIZE: int = 160 +const HORIZONTAL_HALO: int = 3 +const CLEAR_HEIGHT: int = 14 +const TYPE_ABANDONED_CAMP: int = 0 +const TYPE_STONE_WATCHTOWER: int = 1 + + +class Candidate: + var kind: int + var anchor: Vector2i + var orientation: int + var hash_value: int + + func _init(kind_value: int, anchor_value: Vector2i, orientation_value: int, hash_value: int) -> void: + kind = kind_value + anchor = anchor_value + orientation = orientation_value + self.hash_value = hash_value + + +## One candidate is owned by each 160-block cell. The admission lottery keeps +## landmarks rare while retaining a fixed, deterministic anchor/orientation/type. +static func candidate_for(seed: int, owner_x: int, owner_z: int) -> Candidate: + var hash_value: int = WorldGenHashScript.hash_2d(seed + 1601, owner_x, owner_z) + if hash_value % 5 >= 3: + return null + var anchor := Vector2i( + owner_x * OWNER_CELL_SIZE + 20 + hash_value % (OWNER_CELL_SIZE - 40), + owner_z * OWNER_CELL_SIZE + 20 + (hash_value / 37) % (OWNER_CELL_SIZE - 40)) + return Candidate.new((hash_value / 97) % 2, anchor, (hash_value / 211) % 4, hash_value) + + +static func clear_radius(_kind: int) -> int: + return HORIZONTAL_HALO + + +static func max_height(kind: int) -> int: + return 6 if kind == TYPE_STONE_WATCHTOWER else 3 + + +## Returns the generated block at an anchor-relative world offset. The compact +## definitions intentionally have supported vertical columns: their LOD top and +## immediate-below material can be reproduced without materializing a chunk. +static func block_at(kind: int, orientation: int, offset_x: int, local_y: int, offset_z: int) -> int: + var local := _unrotate(offset_x, offset_z, orientation) + var u: int = local.x + var v: int = local.y + if kind == TYPE_ABANDONED_CAMP: + if local_y == 1 and absi(u) <= 3 and absi(v) <= 2: + return BlockRegistryScript.BLOCK_COBBLESTONE + if absi(u) == 3 and absi(v) == 2 and local_y >= 2 and local_y <= 3: + return BlockRegistryScript.BLOCK_LOG + if u == 0 and v == 0 and local_y == 2: + return BlockRegistryScript.BLOCK_TORCH + return BlockRegistryScript.BLOCK_AIR + # A compact, roofless stone watchtower: continuous perimeter walls retain a + # readable silhouette while the open centre keeps it from becoming a bunker. + if absi(u) <= 2 and absi(v) <= 2: + if local_y == 1: + return BlockRegistryScript.BLOCK_COBBLESTONE + if (absi(u) == 2 or absi(v) == 2) and local_y >= 2 and local_y <= 6: + return BlockRegistryScript.BLOCK_LOG if absi(u) == 2 and absi(v) == 2 and local_y < 6 else BlockRegistryScript.BLOCK_COBBLESTONE + if u == 0 and v == 0 and local_y == 2: + return BlockRegistryScript.BLOCK_TORCH + return BlockRegistryScript.BLOCK_AIR + + +static func _unrotate(offset_x: int, offset_z: int, orientation: int) -> Vector2i: + match orientation & 3: + 0: + return Vector2i(offset_x, offset_z) + 1: + return Vector2i(offset_z, -offset_x) + 2: + return Vector2i(-offset_x, -offset_z) + _: + return Vector2i(-offset_z, offset_x) diff --git a/world/worldgen/structure_catalog.gd.uid b/world/worldgen/structure_catalog.gd.uid new file mode 100644 index 0000000..5947a27 --- /dev/null +++ b/world/worldgen/structure_catalog.gd.uid @@ -0,0 +1 @@ +uid://cgm343w2ky8ft diff --git a/world/worldgen/terrain_profile_catalog.gd b/world/worldgen/terrain_profile_catalog.gd index a54a42d..7a5639f 100644 --- a/world/worldgen/terrain_profile_catalog.gd +++ b/world/worldgen/terrain_profile_catalog.gd @@ -85,7 +85,7 @@ func _set_defaults() -> void: _names = PackedStringArray(["plains", "hills", "plateau", "mountains", "ridged_mountains"]) _values = PackedFloat32Array([ # base, relief, broad detail frequency/strength, ridge, erosion, - # local (24-64 block) relief, fine (10 block) surface detail. + # local (32-80 block) relief, fine (13 block) surface detail. 54.0, 5.0, 0.012, 1.5, 0.0, 0.75, 1.9, 0.22, 61.0, 14.0, 0.014, 3.0, 0.18, 0.60, 3.4, 0.30, 70.0, 18.0, 0.010, 2.5, 0.12, 0.35, 2.6, 0.24, diff --git a/world/worldgen/terrain_sampler.gd b/world/worldgen/terrain_sampler.gd index 59a0360..cd375b4 100644 --- a/world/worldgen/terrain_sampler.gd +++ b/world/worldgen/terrain_sampler.gd @@ -7,6 +7,44 @@ extends RefCounted const VoxelDefsScript = preload("res://world/voxel_defs.gd") const HydraulicErosionScript = preload("res://world/worldgen/hydraulic_erosion.gd") +const WorldGenConfigScript = preload("res://world/worldgen/world_gen_config.gd") + +const SPLINE_X := [0.0, 0.25, 0.50, 0.75, 1.0] +const CONTINENT_SPLINE_Y := [0.0, 0.16, 0.55, 0.86, 1.0] +const PROFILE_SPLINE_Y := [0.0, 0.12, 0.43, 0.80, 1.0] +## V11's two-dimensional profile spline. Rows are continentalness anchors and +## columns are broad landform anchors; values are normalized profile positions. +const PROFILE_GRID := [ + [0.00, 0.00, 0.00, 0.00, 0.00], + [0.00, 0.04, 0.10, 0.16, 0.22], + [0.00, 0.14, 0.34, 0.58, 0.78], + [0.00, 0.24, 0.54, 0.82, 1.00], + [0.00, 0.30, 0.62, 0.90, 1.00], +] +## A distinct, broad third climate axis. It deliberately lives at a longer +## wavelength than temperature/moisture so a variant reads as a region, not a +## per-column decoration lottery. +const VARIANT_SCALE_MULTIPLIER: float = 1.45 +const SNOWY_TAIGA_VARIANT_THRESHOLD: float = 0.66 +const WOODED_BADLANDS_VARIANT_THRESHOLD: float = 0.64 +const STONY_SHORE_VARIANT_THRESHOLD: float = 0.62 +const INLAND_WATER_CELL_SIZE := 192 +const INLAND_WATER_Y := VoxelDefsScript.SEA_LEVEL + 8 +const INLAND_LAKE_RADIUS_MIN := 16.0 +const INLAND_LAKE_RADIUS_RANGE := 13 +const INLAND_REACH_HALF_LENGTH := 36.0 +const INLAND_REACH_RADIUS := 4.0 +const INLAND_BANK_WIDTH := 8.0 +# V11 hydrology retains the v10 owner-cell lake distribution, but routes each +# accepted source across several fixed-length, cardinal reaches. The route is +# resolved entirely from the immutable pre-hydrology field; it never samples a +# previously carved water result. +const INLAND_ROUTE_REACH_LENGTH := 48 +const INLAND_ROUTE_REACH_COUNT := 4 +const INLAND_ROUTE_SOURCE_CHANCE := 18 +const INLAND_ROUTE_MIN_SOURCE_HEIGHT := VoxelDefsScript.SEA_LEVEL + 16 +const INLAND_ROUTE_MIN_WATER_Y := VoxelDefsScript.SEA_LEVEL + 4 +const INLAND_ROUTE_DIRECTIONS := [Vector2i.RIGHT, Vector2i.DOWN, Vector2i.LEFT, Vector2i.UP] const CHANNEL_CONTINENT: int = 0 const CHANNEL_WARP_X: int = 1 @@ -26,10 +64,12 @@ const CHANNEL_SEABED: int = 14 const CHANNEL_ECOTONE: int = 15 const CHANNEL_LARGE_ISLAND: int = 16 const CHANNEL_SMALL_ISLAND: int = 17 -const CHANNEL_COUNT: int = 18 +const CHANNEL_VARIANT: int = 18 +const CHANNEL_COUNT: int = 19 const MIN_TERRAIN_HEIGHT: float = 3.0 const HEIGHT_MARGIN: float = 8.0 +const SOFT_CEILING_BAND: float = 24.0 # Underwater split. The same continental band shapes the seabed depth curve, # so a sea biome's label and its geometry cannot disagree. Patch fields decide @@ -257,10 +297,13 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: raw_value = _apply_river_carve( world_x, world_z, raw_value, river_distance, source_mainland[source_index], _river_width_scale_at(world_x, world_z)) - raw_height[raw_index] = clampf(raw_value, MIN_TERRAIN_HEIGHT, float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN) + raw_height[raw_index] = _bounded_height(raw_value) var final_height := PackedFloat64Array() + var final_water_y := PackedInt32Array() final_height.resize(FINAL_SIDE * FINAL_SIDE) + final_water_y.resize(FINAL_SIDE * FINAL_SIDE) + final_water_y.fill(-1) for local_z in range(FINAL_MIN, FINAL_MAX + 1): var world_z: int = origin_z + local_z var final_row: int = (local_z - FINAL_MIN) * FINAL_SIDE @@ -299,10 +342,18 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: var south := raw_height[raw_index + RAW_SIDE] var gradient := sqrt((east - west) * (east - west) + (south - north) * (south - north)) * 0.5 var local_relief := absf(raw_height[raw_index] - (west + east + north + south) * 0.25) - final_height[final_index] = _apply_climate_terrain_shape( + var shaped_height := _apply_climate_terrain_shape( height, source_profile[source_index], gradient, local_relief, temperature_value + climate_altitude * 0.0035, moisture_value + climate_altitude * 0.0015) + if _config.elevated_hydrology: + var hydrology := _elevated_hydrology_at(world_x, world_z, shaped_height) + final_height[final_index] = hydrology.x + final_water_y[final_index] = roundi(hydrology.y) + else: + # Do not round-trip v1-v10 heights through Vector2 (float32). + # Legacy worlds retain their original Float64 field values exactly. + final_height[final_index] = shaped_height # Point queries and chunk fields must classify the same final height. climate = _climate_at(world_x, world_z, final_height[final_index], source_river[source_index]) final_temperature[final_index] = climate.x @@ -328,6 +379,9 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: var south: float = final_height[final_index + FINAL_SIDE] var slope_value: float = sqrt((east - west) * (east - west) + (south - north) * (south - north)) * 0.5 var river_value: float = source_river[source_index] + var inland_water: int = final_water_y[final_index] + if inland_water > VoxelDefsScript.SEA_LEVEL: + river_value = 1.0 var temperature_value: float = final_temperature[final_index] var moisture_value: float = final_moisture[final_index] var biome_choice := _biome_choice(temperature_value, moisture_value) @@ -337,9 +391,10 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: if profile == TerrainProfileCatalog.RIDGED_MOUNTAINS: profile_fraction = 0.0 var continental: float = source_continental[source_index] - var biome: int = _apply_biome_override( - biome_choice.x, continental, final_value, river_value, profile, - temperature_value, world_x, world_z) + var biome: int = BiomeCatalog.RIVER if inland_water > VoxelDefsScript.SEA_LEVEL else _apply_biome_variant( + _apply_biome_override(biome_choice.x, continental, final_value, river_value, profile, + temperature_value, world_x, world_z), + continental, final_value, profile, world_x, world_z) var secondary: int = biome_choice.y if biome == biome_choice.x else biome var transition := _ecotone_choice( biome, secondary, biome_choice.z if biome == biome_choice.x else 0, @@ -353,6 +408,7 @@ func build_field(chunk_pos: Vector2i) -> ChunkTerrainData: field.final_height[field_index] = final_value field.slope[field_index] = slope_value field.river[field_index] = river_value + field.inland_water_y[field_index] = inland_water field.temperature[field_index] = temperature_value field.moisture[field_index] = moisture_value field.profile_id[field_index] = profile @@ -374,7 +430,12 @@ func sample_point(x: int, z: int) -> Dictionary: var continental: float = _continentalness_from_mainland(x, z, mainland) var base: float = _base_height_at(x, z, continental) var raw: float = _raw_height_at(x, z) - var final_value: float = _final_height_at(x, z) + var final_value: float = _final_height_without_hydrology_at(x, z) + var inland_water := -1 + if _config.elevated_hydrology: + var hydrology := _elevated_hydrology_at(x, z, final_value) + final_value = hydrology.x + inland_water = roundi(hydrology.y) var river_value: float = _river_at(x, z, mainland) var climate := _climate_at(x, z, final_value, river_value) var temperature_value: float = climate.x @@ -382,8 +443,11 @@ func sample_point(x: int, z: int) -> Dictionary: var profile_position: float = _profile_position_at(x, z, continental) var profile: int = clampi(floori(profile_position), TerrainProfileCatalog.PLAINS, TerrainProfileCatalog.RIDGED_MOUNTAINS) var choice := _biome_choice(temperature_value, moisture_value) - var biome: int = _apply_biome_override( - choice.x, continental, final_value, river_value, profile, temperature_value, x, z) + if inland_water > VoxelDefsScript.SEA_LEVEL: + river_value = 1.0 + var biome: int = BiomeCatalog.RIVER if inland_water > VoxelDefsScript.SEA_LEVEL else _apply_biome_variant( + _apply_biome_override(choice.x, continental, final_value, river_value, profile, temperature_value, x, z), + continental, final_value, profile, x, z) var secondary: int = choice.y if biome == choice.x else biome var transition := _ecotone_choice( biome, secondary, choice.z if biome == choice.x else 0, @@ -398,6 +462,7 @@ func sample_point(x: int, z: int) -> Dictionary: "final_height": final_value, "slope": _slope_at(x, z), "river": river_value, + "inland_water_y": inland_water, "temperature": temperature_value, "moisture": moisture_value, "profile_id": profile, @@ -407,6 +472,8 @@ func sample_point(x: int, z: int) -> Dictionary: "biome_blend": transition.y / 255.0, "ecotone_strength": transition.z / 255.0, "dominant_biome_id": dominant, + "variant": _variant_value_at(x, z), + "base_biome_id": choice.x, } @@ -417,14 +484,20 @@ func sample_decoration_ground(x: int, z: int) -> Vector2i: _ensure_configured() var mainland := _mainland_continentalness_at(x, z) var continental := _continentalness_from_mainland(x, z, mainland) - var final_value := _final_height_at(x, z) + var final_value := _final_height_without_hydrology_at(x, z) + var inland_water := -1 + if _config.elevated_hydrology: + var hydrology := _elevated_hydrology_at(x, z, final_value) + final_value = hydrology.x + inland_water = roundi(hydrology.y) var river_value := _river_at(x, z, mainland) var climate := _climate_at(x, z, final_value, river_value) var profile_position := _profile_position_at(x, z, continental) var profile := clampi(floori(profile_position), TerrainProfileCatalog.PLAINS, TerrainProfileCatalog.RIDGED_MOUNTAINS) var choice := _biome_choice(climate.x, climate.y) - var biome := _apply_biome_override( - choice.x, continental, final_value, river_value, profile, climate.x, x, z) + var biome := BiomeCatalog.RIVER if inland_water > VoxelDefsScript.SEA_LEVEL else _apply_biome_variant( + _apply_biome_override(choice.x, continental, final_value, river_value, profile, climate.x, x, z), + continental, final_value, profile, x, z) var secondary: int = choice.y if biome == choice.x else biome var transition := _ecotone_choice( biome, secondary, choice.z if biome == choice.x else 0, @@ -471,13 +544,21 @@ func sample_debug_point(mode: String, x: int, z: int) -> Dictionary: var profile := clampi(floori(profile_position), TerrainProfileCatalog.PLAINS, TerrainProfileCatalog.RIDGED_MOUNTAINS) if mode == "profile": return {"profile_id": profile} - var biome := _apply_biome_override( - choice.x, continental, height, river_value, profile, climate.x, x, z) + if mode == "variant": + return {"variant": _variant_value_at(x, z)} + var biome := _apply_biome_variant( + _apply_biome_override(choice.x, continental, height, river_value, profile, climate.x, x, z), + continental, height, profile, x, z) var secondary: int = choice.y if biome == choice.x else biome var dominant: int = _ecotone_choice( biome, secondary, choice.z if biome == choice.x else 0, x, z, height, profile).x - return {"dominant_biome_id": dominant} + return { + "biome_id": biome, + "dominant_biome_id": dominant, + "variant": _variant_value_at(x, z), + "base_biome_id": choice.x, + } func _copy_catalogs(profiles: TerrainProfileCatalog, biomes: BiomeCatalog) -> void: @@ -513,6 +594,7 @@ func _make_noise_channels() -> Array[FastNoiseLite]: [FastNoiseLite.TYPE_SIMPLEX_SMOOTH, 1.0, 2, 1601], [FastNoiseLite.TYPE_SIMPLEX_SMOOTH, 1.0, 2, 1709], [FastNoiseLite.TYPE_SIMPLEX_SMOOTH, 1.0, 2, 1801], + [FastNoiseLite.TYPE_SIMPLEX_SMOOTH, 1.0, 2, 1907], ] var result: Array[FastNoiseLite] = [] for definition in definitions: @@ -528,7 +610,7 @@ func _make_noise_channels() -> Array[FastNoiseLite]: # GDScript warp channels. Point queries evaluate the corridor many times per # decoration, so the native single-pass warp keeps that path affordable. var river_noise: FastNoiseLite = result[CHANNEL_RIVER] - var sample_scale: float = _config.macro_scale * 0.70 + var sample_scale: float = _config.macro_scale * WorldGenConfigScript.RIVER_CORRIDOR_SCALE river_noise.domain_warp_enabled = true river_noise.domain_warp_type = FastNoiseLite.DOMAIN_WARP_SIMPLEX river_noise.domain_warp_amplitude = (_config.macro_scale * RIVER_MEANDER_AMOUNT) / sample_scale @@ -555,16 +637,21 @@ func _mainland_continentalness_at(x: int, z: int) -> float: func _continentalness_from_mainland(x: int, z: int, mainland: float) -> float: - if mainland >= 0.38: - return mainland - var large_value: float = _noises[CHANNEL_LARGE_ISLAND].get_noise_2d( - float(x) / LARGE_ISLAND_SCALE, float(z) / LARGE_ISLAND_SCALE) * 0.5 + 0.5 - var small_value: float = _noises[CHANNEL_SMALL_ISLAND].get_noise_2d( - float(x) / SMALL_ISLAND_SCALE, float(z) / SMALL_ISLAND_SCALE) * 0.5 + 0.5 - var large_island: float = _smoothstep(0.66, 0.82, large_value) * 0.70 - var small_island: float = _smoothstep(0.72, 0.86, small_value) * 0.58 - var coast_fade: float = 1.0 - _smoothstep(0.24, 0.34, mainland) - return maxf(mainland, maxf(large_island, small_island) * coast_fade) + var effective := mainland + if mainland < 0.38: + var large_value: float = _noises[CHANNEL_LARGE_ISLAND].get_noise_2d( + float(x) / LARGE_ISLAND_SCALE, float(z) / LARGE_ISLAND_SCALE) * 0.5 + 0.5 + var small_value: float = _noises[CHANNEL_SMALL_ISLAND].get_noise_2d( + float(x) / SMALL_ISLAND_SCALE, float(z) / SMALL_ISLAND_SCALE) * 0.5 + 0.5 + var large_island: float = _smoothstep(0.66, 0.82, large_value) * 0.70 + var small_island: float = _smoothstep(0.72, 0.86, small_value) * 0.58 + var coast_fade: float = 1.0 - _smoothstep(0.24, 0.34, mainland) + effective = maxf(mainland, maxf(large_island, small_island) * coast_fade) + if not _config.spline_terrain: + return effective + if _config.worldgen_version >= 11: + return _monotone_cubic_sample(effective, CONTINENT_SPLINE_Y) + return _piecewise_cubic_remap(effective, CONTINENT_SPLINE_Y) func _base_height_at(x: int, z: int, continental: float) -> float: @@ -718,7 +805,7 @@ func _apply_climate_terrain_shape(height: float, profile_position: float, gradie height += 2.0 * tropical_weight * _smoothstep(0.6, 2.0, profile_position) var cold_weight := 1.0 - _smoothstep(0.18, 0.36, temperature) height += 3.0 * cold_weight * _smoothstep(2.2, 3.8, profile_position) - return clampf(height, MIN_TERRAIN_HEIGHT, float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN) + return _bounded_height(height) func _raw_height_at(x: int, z: int) -> float: @@ -729,7 +816,7 @@ func _raw_height_at(x: int, z: int) -> float: height = _apply_river_carve( x, z, height, _river_distance_at(x, z), _mainland_continentalness_at(x, z), _river_width_scale_at(x, z)) - return clampf(height, MIN_TERRAIN_HEIGHT, float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN) + return _bounded_height(height) ## The analytic regional modifier remains the seam-safe foundation; the optional @@ -738,10 +825,17 @@ func _height_with_regional_erosion(x: int, z: int) -> float: var height: float = _height_without_regional_erosion(x, z) if _config.world_type != WorldGenConfig.WORLD_TYPE_FLAT: height += _regional_erosion.modifier(x, z, _erosion_height_source) - return clampf(height, MIN_TERRAIN_HEIGHT, float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN) + return _bounded_height(height) func _final_height_at(x: int, z: int) -> float: + var terrain_height := _final_height_without_hydrology_at(x, z) + if not _config.elevated_hydrology: + return terrain_height + return _elevated_hydrology_at(x, z, terrain_height).x + + +func _final_height_without_hydrology_at(x: int, z: int) -> float: var raw: float = _raw_height_at(x, z) if _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT: return raw @@ -763,6 +857,203 @@ func _final_height_at(x: int, z: int) -> float: climate.y + climate_altitude * 0.0015) +## Experimental elevated water. V10's fixed-level lake/reach experiment is +## kept byte-for-byte as a compatibility branch. V11 routes source lakes over +## downhill coarse terrain with independently reproducible owner-cell routes. +func _elevated_hydrology_at(x: int, z: int, terrain_height: float) -> Vector2: + if not _config.elevated_hydrology or _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT: + return Vector2(terrain_height, -1.0) + if _config.worldgen_version >= 11: + return _routed_elevated_hydrology_at(x, z, terrain_height) + return _legacy_elevated_hydrology_at(x, z, terrain_height) + + +## The v10 implementation deliberately remains isolated from v11. Existing +## saved v10 worlds therefore keep the exact fixed water surface and terrain +## values they had before routed hydrology was introduced. +func _legacy_elevated_hydrology_at(x: int, z: int, terrain_height: float) -> Vector2: + var owner_x := WorldGenHash.floor_div(x, INLAND_WATER_CELL_SIZE) + var owner_z := WorldGenHash.floor_div(z, INLAND_WATER_CELL_SIZE) + var nearest := INF + for cell_z in range(owner_z - 1, owner_z + 2): + for cell_x in range(owner_x - 1, owner_x + 2): + var hash_value := WorldGenHash.hash_2d(_config.seed + 2213, cell_x, cell_z) + if hash_value % 100 >= 30: + continue + var center_x := cell_x * INLAND_WATER_CELL_SIZE + 32 + (hash_value / 101) % 128 + var center_z := cell_z * INLAND_WATER_CELL_SIZE + 32 + (hash_value / 307) % 128 + var mainland := _mainland_continentalness_at(center_x, center_z) + if mainland < 0.55: + continue + var continental := _continentalness_from_mainland(center_x, center_z, mainland) + if _profile_position_at(center_x, center_z, continental) < 1.0: + continue + var radius := INLAND_LAKE_RADIUS_MIN + float((hash_value / 997) % INLAND_LAKE_RADIUS_RANGE) + var dx := float(x - center_x) + var dz := float(z - center_z) + var lake_distance := sqrt(dx * dx + dz * dz) - radius + var direction := _inland_reach_direction(hash_value) + var reach_distance := _distance_to_segment( + Vector2(float(x), float(z)), + Vector2(float(center_x), float(center_z)) - direction * INLAND_REACH_HALF_LENGTH, + Vector2(float(center_x), float(center_z)) + direction * INLAND_REACH_HALF_LENGTH) \ + - INLAND_REACH_RADIUS + nearest = minf(nearest, minf(lake_distance, reach_distance)) + if nearest == INF or nearest >= INLAND_BANK_WIDTH: + return Vector2(terrain_height, -1.0) + if nearest < 0.0 and terrain_height >= float(INLAND_WATER_Y): + var depth := 1.0 + 2.0 * _smoothstep(0.0, 3.0, -nearest) + return Vector2(minf(terrain_height, float(INLAND_WATER_Y) - depth), float(INLAND_WATER_Y)) + var bank_target := lerpf(float(INLAND_WATER_Y), float(INLAND_WATER_Y + 2), + _smoothstep(0.0, INLAND_BANK_WIDTH, maxf(nearest, 0.0))) + return Vector2(minf(terrain_height, bank_target), -1.0) + + +## V11 resolves all nearby global owners, selecting the closest signed lake or +## reach shape. A water column always lowers to a solid bed at least one block +## below its own surface. Dry banks are only lowered, so neither path nor bank +## can form a raised/floating ribbon over the original terrain. +func _routed_elevated_hydrology_at(x: int, z: int, terrain_height: float) -> Vector2: + var owner_x: int = WorldGenHash.floor_div(x, INLAND_WATER_CELL_SIZE) + var owner_z: int = WorldGenHash.floor_div(z, INLAND_WATER_CELL_SIZE) + var nearest: float = INF + var water_y: int = -1 + for cell_z in range(owner_z - 1, owner_z + 2): + for cell_x in range(owner_x - 1, owner_x + 2): + var route := _v11_route_for_source(cell_x, cell_z) + if route.is_empty(): + continue + var center: Vector2i = route["center"] + var lake_distance: float = Vector2(float(x - center.x), float(z - center.y)).length() - float(route["radius"]) + if lake_distance < nearest: + nearest = lake_distance + water_y = int(route["source_water_y"]) + var points: Array = route["points"] + var levels: PackedInt32Array = route["levels"] + for reach in levels.size(): + var reach_distance: float = _distance_to_segment( + Vector2(float(x), float(z)), Vector2(points[reach]), Vector2(points[reach + 1])) \ + - INLAND_REACH_RADIUS + if reach_distance < nearest: + nearest = reach_distance + water_y = levels[reach] + if nearest == INF or nearest >= INLAND_BANK_WIDTH: + return Vector2(terrain_height, -1.0) + if nearest < 0.0 and water_y > VoxelDefsScript.SEA_LEVEL: + var depth: float = 1.0 + 2.0 * _smoothstep(0.0, 3.0, -nearest) + return Vector2(minf(terrain_height, float(water_y) - depth), float(water_y)) + var bank_target: float = lerpf(float(water_y), float(water_y + 2), + _smoothstep(0.0, INLAND_BANK_WIDTH, maxf(nearest, 0.0))) + return Vector2(minf(terrain_height, bank_target), -1.0) + + +## Returns a complete immutable route description for one owner cell. The +## source and every endpoint are evaluated using _coarse_pre_hydrology_height_at +## instead of _final_height_at, preventing hydrology recursion by construction. +## The dictionary is call-local scratch; no route state is cached or mutated. +func _v11_route_for_source(cell_x: int, cell_z: int) -> Dictionary: + var hash_value: int = WorldGenHash.hash_2d(_config.seed + 2213, cell_x, cell_z) + if hash_value % 100 >= INLAND_ROUTE_SOURCE_CHANCE: + return {} + var center := Vector2i( + cell_x * INLAND_WATER_CELL_SIZE + 32 + (hash_value / 101) % 128, + cell_z * INLAND_WATER_CELL_SIZE + 32 + (hash_value / 307) % 128) + var mainland: float = _mainland_continentalness_at(center.x, center.y) + if mainland < 0.55: + return {} + var continental: float = _continentalness_from_mainland(center.x, center.y, mainland) + if _profile_position_at(center.x, center.y, continental) < 1.0: + return {} + var source_height: float = _coarse_pre_hydrology_height_at(center.x, center.y) + if source_height < float(INLAND_ROUTE_MIN_SOURCE_HEIGHT): + return {} + var points: Array = [center] + var levels := PackedInt32Array() + var current: Vector2i = center + var current_height: float = source_height + var current_water_y: int = clampi(floori(source_height) - 3, INLAND_ROUTE_MIN_WATER_Y, + VoxelDefsScript.WORLD_HEIGHT - 12) + for reach in INLAND_ROUTE_REACH_COUNT: + var downhill := _v11_downhill_endpoint(current, current_height, cell_x, cell_z, reach) + if downhill.is_empty(): + break + var finish: Vector2i = downhill["point"] + var finish_height: float = downhill["height"] + # A reach only falls when its pre-hydrology terrain is sufficiently below + # the carried water. This produces explicit level steps (and thus exposed + # water side faces) instead of a sloped, raised water ribbon. + var next_water_y: int = mini(current_water_y, floori(finish_height) - 1) + next_water_y = maxi(next_water_y, INLAND_ROUTE_MIN_WATER_Y) + points.append(finish) + levels.append(next_water_y) + current = finish + current_height = finish_height + current_water_y = next_water_y + # A v11 source is a routed system, not a v10-style lake with an incidental + # stub. Reject incomplete candidates so every emitted v11 feature has at + # least two independently selected downhill reaches. + if levels.size() < 2: + return {} + return { + "center": center, + "radius": INLAND_LAKE_RADIUS_MIN + float((hash_value / 997) % INLAND_LAKE_RADIUS_RANGE), + "source_water_y": clampi(floori(source_height) - 3, INLAND_ROUTE_MIN_WATER_Y, + VoxelDefsScript.WORLD_HEIGHT - 12), + "points": points, + "levels": levels, + } + + +## Chooses the lowest strictly downhill cardinal coarse sample. Hash-derived +## rotation only breaks exact ties, so route direction cannot depend on chunk +## generation order or which adjacent field first evaluates the owner. +func _v11_downhill_endpoint(start: Vector2i, start_height: float, owner_x: int, owner_z: int, + reach: int) -> Dictionary: + var rotation: int = WorldGenHash.positive_mod(WorldGenHash.hash_3d( + _config.seed + 2269, owner_x, reach, owner_z), INLAND_ROUTE_DIRECTIONS.size()) + var best_height: float = start_height + var best_point := Vector2i.ZERO + for offset in INLAND_ROUTE_DIRECTIONS.size(): + var direction: Vector2i = INLAND_ROUTE_DIRECTIONS[(rotation + offset) % INLAND_ROUTE_DIRECTIONS.size()] + var candidate := start + direction * INLAND_ROUTE_REACH_LENGTH + var candidate_height: float = _coarse_pre_hydrology_height_at(candidate.x, candidate.y) + if candidate_height < best_height - 0.25: + best_height = candidate_height + best_point = candidate + if best_point == Vector2i.ZERO: + return {} + return {"point": best_point, "height": best_height} + + +## Coarse, terrain-only routing source. It is intentionally earlier than +## regional erosion, river carving, smoothing, climate shape, and hydrology; +## that makes it cheap, globally point-evaluable, and impossible to recurse +## through the carved elevated-water output. +func _coarse_pre_hydrology_height_at(x: int, z: int) -> float: + return _height_without_regional_erosion(x, z) + + +static func _inland_reach_direction(hash_value: int) -> Vector2: + match (hash_value / 37) % 4: + 0: + return Vector2.RIGHT + 1: + return Vector2.DOWN + 2: + return Vector2(0.70710678, 0.70710678) + _: + return Vector2(0.70710678, -0.70710678) + + +static func _distance_to_segment(point: Vector2, start: Vector2, finish: Vector2) -> float: + var segment := finish - start + var length_squared := segment.length_squared() + if length_squared <= 0.0001: + return point.distance_to(start) + var amount := clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return point.distance_to(start + segment * amount) + + func _final_from_raw_neighborhood(x: int, z: int, raw: float, west: float, east: float, north: float, south: float) -> float: if _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT: return raw @@ -794,7 +1085,7 @@ func _final_from_raw_neighborhood_with_profile(raw: float, west: float, east: fl var terrace_weight: float = maxf(1.0 - absf(profile_position - 2.0) * 2.0, 0.0) * _smoothstep(0.2, 2.0, gradient) * _config.erosion_strength var step: float = 2.0 + profile_position * 0.25 var terraced: float = floorf(talus / step + 0.5) * step - return clampf(lerpf(talus, terraced, terrace_weight * 0.22), MIN_TERRAIN_HEIGHT, float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN) + return _bounded_height(lerpf(talus, terraced, terrace_weight * 0.22)) func _slope_at(x: int, z: int) -> float: @@ -811,11 +1102,70 @@ func _profile_position_at(x: int, z: int, continental: float) -> float: warped.x / (_config.macro_scale * 1.25), warped.y / (_config.macro_scale * 1.25)) * 0.5 + 0.5 # Broad landform regions choose the profile. Fine ridge detail must never # switch a plain into a mountain over a handful of columns. + if _config.spline_terrain and _config.worldgen_version >= 11: + return clampf(_profile_grid_sample(continental, landform) \ + * float(TerrainProfileCatalog.RIDGED_MOUNTAINS), 0.0, + float(TerrainProfileCatalog.RIDGED_MOUNTAINS)) var position: float = _smoothstep(0.30, 0.82, landform) * 4.0 position *= _smoothstep(0.44, 0.78, continental) + if _config.spline_terrain: + position = _piecewise_cubic_remap( + position / float(TerrainProfileCatalog.RIDGED_MOUNTAINS), PROFILE_SPLINE_Y) \ + * float(TerrainProfileCatalog.RIDGED_MOUNTAINS) return clampf(position, 0.0, float(TerrainProfileCatalog.RIDGED_MOUNTAINS)) +## V11's tensor-product monotone spline. Interpolating every landform row +## first, then interpolating those results by continentalness, keeps the grid +## continuous at both sets of cell boundaries and bounded by its four corners. +func _profile_grid_sample(continental: float, landform: float) -> float: + var row_values: Array[float] = [] + for row in PROFILE_GRID: + row_values.append(_monotone_cubic_sample(landform, row)) + return _monotone_cubic_sample(continental, row_values) + + +func _piecewise_cubic_remap(value: float, outputs: Array) -> float: + var normalized := clampf(value, 0.0, 1.0) + var segment := mini(floori(normalized * 4.0), 3) + var t: float = (normalized - float(SPLINE_X[segment])) \ + / (float(SPLINE_X[segment + 1]) - float(SPLINE_X[segment])) + var eased := t * t * (3.0 - 2.0 * t) + return lerpf(float(outputs[segment]), float(outputs[segment + 1]), eased) + + +## Uniform-knot monotone cubic Hermite interpolation. Tangents use the harmonic +## mean of neighboring secants, preventing overshoot while retaining C1 joins. +func _monotone_cubic_sample(value: float, outputs: Array) -> float: + var normalized := clampf(value, 0.0, 1.0) + var count := outputs.size() + if count < 2: + return float(outputs[0]) if count == 1 else normalized + var scaled := normalized * float(count - 1) + var segment := mini(floori(scaled), count - 2) + var t := scaled - float(segment) + var y0 := float(outputs[segment]) + var y1 := float(outputs[segment + 1]) + var delta := y1 - y0 + var previous_delta := delta if segment == 0 else y0 - float(outputs[segment - 1]) + var next_delta := delta if segment + 2 >= count else float(outputs[segment + 2]) - y1 + var m0 := _monotone_tangent(previous_delta, delta) + var m1 := _monotone_tangent(delta, next_delta) + var t2 := t * t + var t3 := t2 * t + return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 \ + + (t3 - 2.0 * t2 + t) * m0 \ + + (-2.0 * t3 + 3.0 * t2) * y1 \ + + (t3 - t2) * m1 + + +static func _monotone_tangent(left_delta: float, right_delta: float) -> float: + if is_zero_approx(left_delta) or is_zero_approx(right_delta) \ + or signf(left_delta) != signf(right_delta): + return 0.0 + return 2.0 * left_delta * right_delta / (left_delta + right_delta) + + ## Raw river corridor field: absolute value of a warped low-frequency noise. ## Its zero level set is the channel centreline. The noise carries a native ## domain warp that bends the corridor into meanders instead of tracing the raw @@ -824,7 +1174,7 @@ func _river_corridor_at(x: int, z: int) -> float: if _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT or _config.river_density <= 0.0: return 10.0 var warped := _macro_warp(x, z) - var sample_scale: float = _config.macro_scale * 0.70 + var sample_scale: float = _config.macro_scale * WorldGenConfigScript.RIVER_CORRIDOR_SCALE return absf(_noises[CHANNEL_RIVER].get_noise_2d( warped.x / sample_scale, warped.y / sample_scale)) @@ -850,7 +1200,7 @@ func _river_distance_at(x: int, z: int) -> float: ## variation, so reaches widen into pools and narrow into riffles instead of ## staying perfectly uniform. func _river_width_scale_at(x: int, z: int) -> float: - var density_norm: float = clampf(_config.river_density * 0.25, 0.0, 1.0) + var density_norm: float = clampf(_config.river_density * WorldGenConfigScript.RIVER_DENSITY_NORMALIZATION, 0.0, 1.0) var base_scale: float = lerpf(RIVER_WIDTH_SCALE_MIN, RIVER_WIDTH_SCALE_MAX, density_norm) if _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT or _config.river_density <= 0.0: return base_scale @@ -862,7 +1212,8 @@ func _river_width_scale_at(x: int, z: int) -> float: func _channel_half_width(width_scale: float) -> float: - return lerpf(RIVER_CHANNEL_HALF_MIN, RIVER_CHANNEL_HALF_MAX, clampf(_config.river_density * 0.25, 0.0, 1.0)) * width_scale + return lerpf(RIVER_CHANNEL_HALF_MIN, RIVER_CHANNEL_HALF_MAX, + clampf(_config.river_density * WorldGenConfigScript.RIVER_DENSITY_NORMALIZATION, 0.0, 1.0)) * width_scale ## Strength of the floodplain grading mask, 0 outside the river corridor and 1 @@ -873,7 +1224,7 @@ func _channel_half_width(width_scale: float) -> float: func _river_floodplain_weight(river_corridor: float, continental: float) -> float: if _config.world_type == WorldGenConfig.WORLD_TYPE_FLAT or _config.river_density <= 0.0: return 0.0 - var density_norm: float = clampf(_config.river_density * 0.25, 0.0, 1.0) + var density_norm: float = clampf(_config.river_density * WorldGenConfigScript.RIVER_DENSITY_NORMALIZATION, 0.0, 1.0) var extent: float = RIVER_FLOODPLAIN_CORRIDOR * lerpf(RIVER_WIDTH_SCALE_MIN, RIVER_WIDTH_SCALE_MAX, density_norm) return _smoothstep(0.43, 0.60, continental) \ * (1.0 - _smoothstep(extent * 0.05, extent, river_corridor)) @@ -948,6 +1299,19 @@ func _climate_at(x: int, z: int, height: float, river_value: float) -> Vector2: return Vector2(clampf(temperature_value, 0.0, 1.0), clampf(moisture_value, 0.0, 1.0)) +## V11's independent climate-variant field. The version/configuration gate is +## here rather than at each caller so every legacy path receives the same +## inert value without changing its selection math. +func _variant_value_at(x: int, z: int) -> float: + if not _config.climate_variants: + return 0.5 + var warped := _macro_warp(x, z) + var value: float = _noises[CHANNEL_VARIANT].get_noise_2d( + warped.x / (_config.biome_scale * VARIANT_SCALE_MULTIPLIER), + warped.y / (_config.biome_scale * VARIANT_SCALE_MULTIPLIER)) + return clampf(value * 0.5 + 0.5, 0.0, 1.0) + + ## x=closest climate biome, y=second closest, z=blend weight toward y (0..255). ## ChunkTerrainData stores both choices for continuous tint blending. Discrete ## surfaces and decorations use the coherent dominant biome chosen by @@ -1022,7 +1386,7 @@ func _apply_biome_override(climate_biome: int, continental: float, height: float # ocean-floor column falls through to the waterline checks below. if continental < OCEAN_CONTINENTAL and height <= float(VoxelDefsScript.SEA_LEVEL): return _underwater_biome(height, temperature, world_x, world_z) - if river_value > 0.62 and continental >= 0.45 and height <= float(VoxelDefsScript.SEA_LEVEL) + 2.0: + if river_value > WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD and continental >= 0.45 and height <= float(VoxelDefsScript.SEA_LEVEL) + 2.0: return BiomeCatalog.RIVER if height <= float(VoxelDefsScript.SEA_LEVEL) + 1.0: return BiomeCatalog.BEACH @@ -1036,6 +1400,30 @@ func _apply_biome_override(climate_biome: int, continental: float, height: float return climate_biome +## Surface variants are applied after hard water/elevation overrides. That +## makes rivers, oceans, and highlands authoritative while the third climate +## channel forms broad, deterministic snowy forest, wooded mesa, and rocky +## shore regions. This is intentionally a no-op for every v1-v10 config. +func _apply_biome_variant(biome: int, continental: float, height: float, profile: int, + world_x: int, world_z: int) -> int: + if not _config.climate_variants: + return biome + var variant := _variant_value_at(world_x, world_z) + match biome: + BiomeCatalog.TAIGA: + if continental >= 0.48 and variant >= SNOWY_TAIGA_VARIANT_THRESHOLD: + return BiomeCatalog.SNOWY_TAIGA + BiomeCatalog.BADLANDS: + if profile >= TerrainProfileCatalog.HILLS \ + and variant >= WOODED_BADLANDS_VARIANT_THRESHOLD: + return BiomeCatalog.WOODED_BADLANDS + BiomeCatalog.BEACH: + if continental >= OCEAN_CONTINENTAL and height > float(VoxelDefsScript.SEA_LEVEL) \ + and variant >= STONY_SHORE_VARIANT_THRESHOLD: + return BiomeCatalog.STONY_SHORE + return biome + + ## Depth- and climate-appropriate underwater biome chosen from the same final ## height and patch fields the seabed geometry uses. Cold water wins first, ## then the abyssal plain, then coral/kelp/seagrass patches cover the shelf; @@ -1078,6 +1466,19 @@ func _smoothstep(edge0: float, edge1: float, value: float) -> float: return t * t * (3.0 - 2.0 * t) +## V11 replaces the hard world-height clip with an asymptotic shoulder. Extreme +## imported/amplified settings retain tall relief without producing broad flat +## mesas at the storage ceiling; legacy versions keep their exact clamp. +func _bounded_height(value: float) -> float: + var ceiling := float(VoxelDefsScript.WORLD_HEIGHT) - HEIGHT_MARGIN + if _config.worldgen_version <= 10: + return clampf(value, MIN_TERRAIN_HEIGHT, ceiling) + var shoulder := ceiling - SOFT_CEILING_BAND + if value > shoulder: + value = shoulder + SOFT_CEILING_BAND * (1.0 - exp(-(value - shoulder) / SOFT_CEILING_BAND)) + return clampf(value, MIN_TERRAIN_HEIGHT, ceiling) + + func _ensure_configured() -> void: if not _configured: configure(WorldGenConfig.new()) diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index 0676d88..106efd8 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -8,6 +8,8 @@ const WorldGenConfigScript = preload("res://world/worldgen/world_gen_config.gd") const BiomeCatalogScript = preload("res://world/worldgen/biome_catalog.gd") const WorldGenHashScript = preload("res://world/worldgen/world_gen_hash.gd") const DecorationCatalogScript = preload("res://world/worldgen/decoration_catalog.gd") +const OreCatalogScript = preload("res://world/worldgen/ore_catalog.gd") +const StructureCatalogScript = preload("res://world/worldgen/structure_catalog.gd") const BlockRegistryScript = preload("res://world/block_registry.gd") const VoxelDefsScript = preload("res://world/voxel_defs.gd") @@ -30,10 +32,87 @@ const CAVE_NETWORK_BANDS: int = 5 const CAVE_NETWORK_BASE_Y: int = 14 const CAVE_NETWORK_BAND_STEP: int = 18 const ORE_CELL_SIZE: int = 20 +# Aquifers are sparse, overlapping global regions. A region is owned by its +# 48-block cell only for candidate enumeration; every column evaluates the same +# nearby candidates from world coordinates, so loading an adjacent chunk cannot +# create or remove a water table at its border. +const AQUIFER_REGION_CELL_SIZE: int = 48 +const AQUIFER_REGION_MIN_RADIUS: int = 22 +const AQUIFER_REGION_RADIUS_RANGE: int = 8 +const AQUIFER_MIN_WATER_LEVEL: int = 15 +const AQUIFER_WATER_LEVEL_RANGE: int = 13 +const FLAT_VOXEL_SURFACE_Y: int = 4 + + +## Call-owned decoration cache. VoxelPopulator instances are shared by worker +## jobs, so mutable scratch must stay local to one populate() invocation. +class DecorationGroundScratch: + const CAPACITY := 512 + const SLOT_MASK := CAPACITY - 1 + var occupied := PackedByteArray() + var world_xs := PackedInt32Array() + var world_zs := PackedInt32Array() + var heights := PackedInt32Array() + var biomes := PackedInt32Array() + + func _init() -> void: + occupied.resize(CAPACITY) + world_xs.resize(CAPACITY) + world_zs.resize(CAPACITY) + heights.resize(CAPACITY) + biomes.resize(CAPACITY) + + func find_slot(world_x: int, world_z: int) -> int: + var slot: int = ((world_x * 73856093) ^ (world_z * 19349663)) & SLOT_MASK + for _probe in CAPACITY: + if occupied[slot] == 0: + return -1 + if world_xs[slot] == world_x and world_zs[slot] == world_z: + return slot + slot = (slot + 1) & SLOT_MASK + return -1 + + func insert(world_x: int, world_z: int, value: Vector2i) -> void: + var slot: int = ((world_x * 73856093) ^ (world_z * 19349663)) & SLOT_MASK + for _probe in CAPACITY: + if occupied[slot] == 0 or (world_xs[slot] == world_x and world_zs[slot] == world_z): + occupied[slot] = 1 + world_xs[slot] = world_x + world_zs[slot] = world_z + heights[slot] = value.x + biomes[slot] = value.y + return + slot = (slot + 1) & SLOT_MASK + + func value_at(slot: int) -> Vector2i: + return Vector2i(heights[slot], biomes[slot]) + + +## Packed struct-of-arrays replacement for one five-Variant Array per tree. +class TreeCandidates: + var features := PackedInt32Array() + var world_xs := PackedInt32Array() + var ground_ys := PackedInt32Array() + var world_zs := PackedInt32Array() + var hashes := PackedInt32Array() + + func append(feature: int, world_x: int, ground_y: int, world_z: int, hash_value: int) -> void: + features.append(feature) + world_xs.append(world_x) + ground_ys.append(ground_y) + world_zs.append(world_z) + hashes.append(hash_value) + + func size() -> int: + return features.size() + + func is_empty() -> bool: + return features.is_empty() var config: WorldGenConfig var biomes: BiomeCatalog var decorations: DecorationCatalog +var _ore_catalog: OreCatalog var terrain_sampler: TerrainSampler var _cave_spaghetti_a: FastNoiseLite var _cave_spaghetti_b: FastNoiseLite @@ -44,7 +123,8 @@ func _init(config_value: WorldGenConfig, biomes_value: BiomeCatalog, sampler_val config = config_value if config_value != null else WorldGenConfigScript.new() biomes = biomes_value if biomes_value != null else BiomeCatalogScript.new() terrain_sampler = sampler_value - decorations = DecorationCatalogScript.new() + decorations = DecorationCatalogScript.new(config.worldgen_version) + _ore_catalog = OreCatalogScript.new(config.worldgen_version) _cave_spaghetti_a = _make_cave_noise(1701, 0.018, 2) _cave_spaghetti_b = _make_cave_noise(1877, 0.015, 2) _cave_cheese = _make_cave_noise(1999, 0.009, 3) @@ -70,8 +150,10 @@ func populate(chunk_pos: Vector2i, field: ChunkTerrainData, edits: Dictionary, f _place_geodes(data, field, origin_x, origin_z) _decorate_caves(data, field, origin_x, origin_z) if full_detail and config.decoration_density > 0.0: - max_y = _decorate(data, field, origin_x, origin_z, max_y) - max_y = _decorate_underwater(data, field, origin_x, origin_z, max_y) + var decoration_scratch := DecorationGroundScratch.new() + max_y = _decorate(data, field, origin_x, origin_z, max_y, decoration_scratch) + max_y = _decorate_underwater(data, field, origin_x, origin_z, max_y, decoration_scratch) + max_y = maxi(max_y, _place_region_structures(data, field, origin_x, origin_z)) max_y = _apply_edits(data, edits, origin_x, origin_z, max_y) return {"data": data, "max_y": _actual_max_y(data, max_y)} @@ -107,10 +189,8 @@ func populate_lod(chunk_pos: Vector2i, field: ChunkTerrainData) -> Dictionary: var surface_y: int = _surface_height(field, field_index) var river: float = field.river[field_index] var is_river: bool = config.world_type != WorldGenConfigScript.WORLD_TYPE_FLAT \ - and river >= 0.62 and surface_y < VoxelDefsScript.SEA_LEVEL - var column_water_y := -1 - if surface_y < VoxelDefsScript.SEA_LEVEL: - column_water_y = VoxelDefsScript.SEA_LEVEL + and river >= WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD and surface_y < VoxelDefsScript.SEA_LEVEL + var column_water_y := _column_water_y(field, field_index, surface_y) var values := _surface_rule_values(field, field_index, surface_y, column_water_y, is_river, field.world_x(local_x), field.world_z(local_z)) solid_y[column] = surface_y @@ -123,8 +203,13 @@ func populate_lod(chunk_pos: Vector2i, field: ChunkTerrainData) -> Dictionary: else: max_y = maxi(max_y, surface_y) _apply_lod_floor_patches(solid_y, solid_id, field, origin_x, origin_z) + var terrain_solid_y: PackedInt32Array = solid_y.duplicate() + var terrain_solid_id: PackedByteArray = solid_id.duplicate() + var terrain_sub_id: PackedByteArray = sub_id.duplicate() max_y = maxi(max_y, _apply_lod_scrub(solid_y, solid_id, sub_id, water_y, field, origin_x, origin_z)) max_y = maxi(max_y, _apply_lod_canopies(solid_y, solid_id, sub_id, water_y, field, origin_x, origin_z)) + max_y = maxi(max_y, _apply_lod_region_structures(solid_y, solid_id, sub_id, water_y, + field, origin_x, origin_z, terrain_solid_y, terrain_solid_id, terrain_sub_id)) return { "solid_y": solid_y, "solid_id": solid_id, @@ -142,15 +227,16 @@ func populate_lod(chunk_pos: Vector2i, field: ChunkTerrainData) -> Dictionary: ## two tree blocks become the column's solid/sub pair so side faces read as ## foliage instead of dirt. func _apply_lod_canopies(solid_y: PackedInt32Array, solid_id: PackedByteArray, sub_id: PackedByteArray, water_y: PackedInt32Array, field: ChunkTerrainData, origin_x: int, origin_z: int) -> int: - var trees: Array = _collect_trees(field, origin_x, origin_z, {}, true) + var trees := _collect_trees(field, origin_x, origin_z, DecorationGroundScratch.new(), true) if trees.is_empty(): return 0 var buffer := PackedByteArray() buffer.resize(VoxelDefsScript.CHUNK_AREA * VoxelDefsScript.WORLD_HEIGHT) var tree_max_y := 0 - for tree in trees: + for tree_index in trees.size(): tree_max_y = maxi(tree_max_y, _stamp_feature( - buffer, origin_x, origin_z, int(tree[1]), int(tree[2]), int(tree[3]), int(tree[0]), int(tree[4]))) + buffer, origin_x, origin_z, trees.world_xs[tree_index], trees.ground_ys[tree_index], + trees.world_zs[tree_index], trees.features[tree_index], trees.hashes[tree_index])) var top_y := PackedInt32Array() var top_id := PackedByteArray() var second_id := PackedByteArray() @@ -179,6 +265,151 @@ func _apply_lod_canopies(solid_y: PackedInt32Array, solid_id: PackedByteArray, s return max_y +## Region structures are a separate, sparse feature tier. Each 160-block owner +## cell supplies one candidate; the fixed structure bounding box expands the +## owner range so every overlapping chunk independently makes the same stamp. +## The terrain-only site test deliberately reads the sampler/scratch rather than +## generated data, which avoids generation-order dependence at chunk borders. +func _place_region_structures(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> int: + var structures := _accepted_region_structures(field, origin_x, origin_z, DecorationGroundScratch.new()) + var max_y := 0 + for entry in structures: + var candidate: StructureCatalog.Candidate = entry[0] + var ground_y: int = int(entry[1]) + _clear_structure_volume(data, origin_x, origin_z, candidate.anchor, ground_y) + for offset_z in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + for offset_x in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + for local_y in range(1, StructureCatalogScript.max_height(candidate.kind) + 1): + var block_id: int = StructureCatalogScript.block_at(candidate.kind, candidate.orientation, + offset_x, local_y, offset_z) + if block_id == BlockRegistryScript.BLOCK_AIR: + continue + _set_structure_block(data, origin_x, origin_z, candidate.anchor.x + offset_x, + ground_y + local_y, candidate.anchor.y + offset_z, block_id) + max_y = maxi(max_y, ground_y + StructureCatalogScript.max_height(candidate.kind)) + return max_y + + +## Compact chunks carry the exact region-structure top columns. The clearing +## reset is equally important: it removes a pre-existing compact tree canopy +## from a POI footprint just as the full stamp clears its voxel volume. +func _apply_lod_region_structures(solid_y: PackedInt32Array, solid_id: PackedByteArray, + sub_id: PackedByteArray, water_y: PackedInt32Array, field: ChunkTerrainData, + origin_x: int, origin_z: int, terrain_solid_y: PackedInt32Array, + terrain_solid_id: PackedByteArray, terrain_sub_id: PackedByteArray) -> int: + var structures := _accepted_region_structures(field, origin_x, origin_z, DecorationGroundScratch.new()) + var max_y := 0 + for entry in structures: + var candidate: StructureCatalog.Candidate = entry[0] + var ground_y: int = int(entry[1]) + for offset_z in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + var local_z: int = candidate.anchor.y + offset_z - origin_z + if local_z < 0 or local_z >= VoxelDefsScript.CHUNK_SIZE: + continue + for offset_x in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + var local_x: int = candidate.anchor.x + offset_x - origin_x + if local_x < 0 or local_x >= VoxelDefsScript.CHUNK_SIZE: + continue + var column: int = local_x + local_z * VoxelDefsScript.DATA_STRIDE_Z + solid_y[column] = terrain_solid_y[column] + solid_id[column] = terrain_solid_id[column] + sub_id[column] = terrain_sub_id[column] + # Accepted structures are above the water line; leave a deterministic + # safety reset in case a future terrain rule adds a shallow water cap. + if water_y[column] > terrain_solid_y[column]: + water_y[column] = -1 + var top_y: int = -1 + var top_id: int = BlockRegistryScript.BLOCK_AIR + for local_y in range(1, StructureCatalogScript.max_height(candidate.kind) + 1): + var block_id: int = StructureCatalogScript.block_at(candidate.kind, candidate.orientation, + offset_x, local_y, offset_z) + # Compact columns intentionally omit cross blocks, matching the + # full-detail LOD scan (a torch is light/decoration, not terrain). + if block_id != BlockRegistryScript.BLOCK_AIR and block_id != BlockRegistryScript.BLOCK_TORCH: + top_y = ground_y + local_y + top_id = block_id + if top_y < 0: + continue + solid_y[column] = top_y + solid_id[column] = top_id + var below_id: int = StructureCatalogScript.block_at(candidate.kind, candidate.orientation, + offset_x, top_y - ground_y - 1, offset_z) + sub_id[column] = terrain_solid_id[column] if below_id == BlockRegistryScript.BLOCK_AIR else below_id + max_y = maxi(max_y, top_y) + return max_y + + +func _accepted_region_structures(field: ChunkTerrainData, origin_x: int, origin_z: int, + ground_scratch: DecorationGroundScratch) -> Array: + var accepted: Array = [] + if not config.region_structures or config.worldgen_version < WorldGenConfigScript.VARIANT_WORLDGEN_VERSION \ + or terrain_sampler == null: + return accepted + var first_x: int = WorldGenHashScript.floor_div(origin_x - StructureCatalogScript.HORIZONTAL_HALO, + StructureCatalogScript.OWNER_CELL_SIZE) + var last_x: int = WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + + StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.OWNER_CELL_SIZE) + var first_z: int = WorldGenHashScript.floor_div(origin_z - StructureCatalogScript.HORIZONTAL_HALO, + StructureCatalogScript.OWNER_CELL_SIZE) + var last_z: int = WorldGenHashScript.floor_div(origin_z + VoxelDefsScript.CHUNK_SIZE - 1 + + StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.OWNER_CELL_SIZE) + for owner_z in range(first_z, last_z + 1): + for owner_x in range(first_x, last_x + 1): + var candidate: StructureCatalog.Candidate = StructureCatalogScript.candidate_for(config.seed, owner_x, owner_z) + if candidate == null: + continue + var ground := _cached_decoration_ground(field, candidate.anchor.x, candidate.anchor.y, ground_scratch) + if _region_structure_site_is_valid(field, ground_scratch, candidate, ground.x): + accepted.append([candidate, ground.x]) + return accepted + + +func _region_structure_site_is_valid(field: ChunkTerrainData, ground_scratch: DecorationGroundScratch, + candidate: StructureCatalog.Candidate, ground_y: int) -> bool: + if ground_y <= VoxelDefsScript.SEA_LEVEL + 2 \ + or ground_y + StructureCatalogScript.CLEAR_HEIGHT >= VoxelDefsScript.WORLD_HEIGHT: + return false + var radius: int = StructureCatalogScript.clear_radius(candidate.kind) + for offset_z in range(-radius, radius + 1): + for offset_x in range(-radius, radius + 1): + var sample := _cached_decoration_ground(field, candidate.anchor.x + offset_x, + candidate.anchor.y + offset_z, ground_scratch) + if sample.x != ground_y or biomes.is_ocean_biome(sample.y) \ + or sample.y == BiomeCatalogScript.RIVER or sample.y == BiomeCatalogScript.SWAMP: + return false + # Entrances are the one cave stage allowed to reach the surface. Reject + # their small immutable exclusion so a structure foundation never spans + # an entrance and full/compact columns retain the same support. + if _tree_root_is_near_cave_entrance(candidate.anchor.x + offset_x, + candidate.anchor.y + offset_z): + return false + return true + + +func _clear_structure_volume(data: PackedByteArray, origin_x: int, origin_z: int, + anchor: Vector2i, ground_y: int) -> void: + for offset_z in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + var local_z: int = anchor.y + offset_z - origin_z + if local_z < 0 or local_z >= VoxelDefsScript.CHUNK_SIZE: + continue + for offset_x in range(-StructureCatalogScript.HORIZONTAL_HALO, StructureCatalogScript.HORIZONTAL_HALO + 1): + var local_x: int = anchor.x + offset_x - origin_x + if local_x < 0 or local_x >= VoxelDefsScript.CHUNK_SIZE: + continue + for y in range(ground_y + 1, ground_y + StructureCatalogScript.CLEAR_HEIGHT + 1): + data[_index(local_x, y, local_z)] = BlockRegistryScript.BLOCK_AIR + + +func _set_structure_block(data: PackedByteArray, origin_x: int, origin_z: int, + world_x: int, y: int, world_z: int, block_id: int) -> void: + var local_x: int = world_x - origin_x + var local_z: int = world_z - origin_z + if local_x < 0 or local_x >= VoxelDefsScript.CHUNK_SIZE or local_z < 0 \ + or local_z >= VoxelDefsScript.CHUNK_SIZE or y < 0 or y >= VoxelDefsScript.WORLD_HEIGHT: + return + data[_index(local_x, y, local_z)] = block_id + + func _fill_base_and_surface(data: PackedByteArray, field: ChunkTerrainData) -> int: var max_y := 0 for local_z in VoxelDefsScript.CHUNK_SIZE: @@ -187,10 +418,8 @@ func _fill_base_and_surface(data: PackedByteArray, field: ChunkTerrainData) -> i var surface_y: int = _surface_height(field, field_index) var river: float = field.river[field_index] var is_river: bool = config.world_type != WorldGenConfigScript.WORLD_TYPE_FLAT \ - and river >= 0.62 and surface_y < VoxelDefsScript.SEA_LEVEL - var water_y := -1 - if surface_y < VoxelDefsScript.SEA_LEVEL: - water_y = VoxelDefsScript.SEA_LEVEL + and river >= WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD and surface_y < VoxelDefsScript.SEA_LEVEL + var water_y := _column_water_y(field, field_index, surface_y) for y in range(surface_y + 1): var block_id := BlockRegistryScript.BLOCK_STONE if y == 0: @@ -206,6 +435,13 @@ func _fill_base_and_surface(data: PackedByteArray, field: ChunkTerrainData) -> i return max_y +func _column_water_y(field: ChunkTerrainData, field_index: int, surface_y: int) -> int: + var inland_water: int = field.inland_water_y[field_index] + if inland_water > surface_y: + return inland_water + return VoxelDefsScript.SEA_LEVEL if surface_y < VoxelDefsScript.SEA_LEVEL else -1 + + func _apply_surface_rule(data: PackedByteArray, field: ChunkTerrainData, local_x: int, local_z: int, surface_y: int, water_y: int, is_river: bool) -> void: var field_index: int = ChunkTerrainDataScript.cell_index(local_x, local_z) var world_x := field.world_x(local_x) @@ -282,8 +518,9 @@ func _carve_noise_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x for local_x in VoxelDefsScript.CHUNK_SIZE: var field_index := ChunkTerrainDataScript.cell_index(local_x, local_z) var surface_limit := _surface_height(field, field_index) - SURFACE_CLEARANCE - 1 - if field.river[field_index] >= 0.62: - surface_limit = mini(surface_limit, VoxelDefsScript.SEA_LEVEL - 3) + if field.river[field_index] >= WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD: + surface_limit = mini(surface_limit, + VoxelDefsScript.SEA_LEVEL - WorldGenConfigScript.RIVER_UNDERGROUND_CLEARANCE) var last_y := mini(surface_limit, VoxelDefsScript.SEA_LEVEL + 52) if last_y < 4: continue @@ -476,8 +713,9 @@ func _carve_ellipsoid(data: PackedByteArray, field: ChunkTerrainData, center: Ve continue var field_index := ChunkTerrainDataScript.cell_index(local_x, local_z) var column_last_y := mini(last_y, _surface_height(field, field_index) + (1 if allow_surface else -SURFACE_CLEARANCE - 1)) - if field.river[field_index] >= 0.62: - column_last_y = mini(column_last_y, VoxelDefsScript.SEA_LEVEL - 3) + if field.river[field_index] >= WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD: + column_last_y = mini(column_last_y, + VoxelDefsScript.SEA_LEVEL - WorldGenConfigScript.RIVER_UNDERGROUND_CLEARANCE) for y in range(base_y, column_last_y + 1): var dy := float(y - center.y) / float(radius_y) if horizontal_squared + dy * dy > 1.0: @@ -493,16 +731,26 @@ func _carve_ellipsoid(data: PackedByteArray, field: ChunkTerrainData, center: Ve func _fill_underground_liquids(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: - var aquifer_cell_x := WorldGenHashScript.floor_div(origin_x, VoxelDefsScript.CHUNK_SIZE) - var aquifer_cell_z := WorldGenHashScript.floor_div(origin_z, VoxelDefsScript.CHUNK_SIZE) - var aquifer_hash := WorldGenHashScript.hash_2d(config.seed + 907, aquifer_cell_x, aquifer_cell_z) - if aquifer_hash % 11 == 0: - var water_level := 15 + (aquifer_hash / 31) % 13 + if config.worldgen_version <= 10: + _fill_legacy_aquifer(data, field, origin_x, origin_z) + else: for local_z in VoxelDefsScript.CHUNK_SIZE: + var world_z: int = origin_z + local_z for local_x in VoxelDefsScript.CHUNK_SIZE: + var world_x: int = origin_x + local_x + var water_level: int = _aquifer_water_level_at(world_x, world_z) + if water_level < 4: + continue var field_index: int = ChunkTerrainDataScript.cell_index(local_x, local_z) var top: int = _surface_height(field, field_index) - for y in range(4, mini(water_level, top - SURFACE_CLEARANCE) + 1): + # Keep the existing underground clearance and match cave carving's + # river ceiling, so an aquifer can never turn a protected channel or + # its banks into a generated surface-water source. + var protected_ceiling: int = top - SURFACE_CLEARANCE + if field.river[field_index] >= WorldGenConfigScript.RIVER_CHANNEL_THRESHOLD: + protected_ceiling = mini(protected_ceiling, + VoxelDefsScript.SEA_LEVEL - WorldGenConfigScript.RIVER_UNDERGROUND_CLEARANCE) + for y in range(4, mini(water_level, protected_ceiling) + 1): var voxel_index: int = _index(local_x, y, local_z) if data[voxel_index] == BlockRegistryScript.BLOCK_AIR: data[voxel_index] = BlockRegistryScript.BLOCK_WATER @@ -519,6 +767,50 @@ func _fill_underground_liquids(data: PackedByteArray, field: ChunkTerrainData, o _fill_lava_lake(data, field, Vector3i(cell_x * 32 + 5 + lava_hash % 22, 5 + (lava_hash / 7) % 6, cell_z * 32 + 5 + (lava_hash / 43) % 22), 4 + lava_hash % 3) +## V1-v10 worlds used one independently-selected aquifer per chunk. Preserve +## that exact layout because untouched saved chunks regenerate from their +## persisted worldgen version. +func _fill_legacy_aquifer(data: PackedByteArray, field: ChunkTerrainData, + origin_x: int, origin_z: int) -> void: + var aquifer_cell_x := WorldGenHashScript.floor_div(origin_x, VoxelDefsScript.CHUNK_SIZE) + var aquifer_cell_z := WorldGenHashScript.floor_div(origin_z, VoxelDefsScript.CHUNK_SIZE) + var aquifer_hash := WorldGenHashScript.hash_2d(config.seed + 907, aquifer_cell_x, aquifer_cell_z) + if aquifer_hash % 11 != 0: + return + var water_level := 15 + (aquifer_hash / 31) % 13 + for local_z in VoxelDefsScript.CHUNK_SIZE: + for local_x in VoxelDefsScript.CHUNK_SIZE: + var field_index: int = ChunkTerrainDataScript.cell_index(local_x, local_z) + var top: int = _surface_height(field, field_index) + for y in range(4, mini(water_level, top - SURFACE_CLEARANCE) + 1): + var voxel_index: int = _index(local_x, y, local_z) + if data[voxel_index] == BlockRegistryScript.BLOCK_AIR: + data[voxel_index] = BlockRegistryScript.BLOCK_WATER + + +## Returns the table for the strongest nearby global aquifer region, or -1. +## The lookup deliberately has no chunk coordinate: regions are independently +## reproduced by every chunk they overlap, regardless of worker timing/order. +func _aquifer_water_level_at(world_x: int, world_z: int) -> int: + var owner_x: int = WorldGenHashScript.floor_div(world_x, AQUIFER_REGION_CELL_SIZE) + var owner_z: int = WorldGenHashScript.floor_div(world_z, AQUIFER_REGION_CELL_SIZE) + var water_level := -1 + for cell_z in range(owner_z - 1, owner_z + 2): + for cell_x in range(owner_x - 1, owner_x + 2): + var region_hash: int = WorldGenHashScript.hash_2d(config.seed + 907, cell_x, cell_z) + if region_hash % 11 != 0: + continue + var center_x: int = cell_x * AQUIFER_REGION_CELL_SIZE + 8 + region_hash % 32 + var center_z: int = cell_z * AQUIFER_REGION_CELL_SIZE + 8 + (region_hash / 31) % 32 + var radius: int = AQUIFER_REGION_MIN_RADIUS + (region_hash / 61) % AQUIFER_REGION_RADIUS_RANGE + var dx: int = world_x - center_x + var dz: int = world_z - center_z + if dx * dx + dz * dz > radius * radius: + continue + water_level = maxi(water_level, AQUIFER_MIN_WATER_LEVEL + (region_hash / 97) % AQUIFER_WATER_LEVEL_RANGE) + return water_level + + func _fill_lava_lake(data: PackedByteArray, field: ChunkTerrainData, center: Vector3i, radius: int) -> void: for world_z in range(center.z - radius, center.z + radius + 1): var local_z: int = world_z - field.chunk_z * VoxelDefsScript.CHUNK_SIZE @@ -532,9 +824,31 @@ func _fill_lava_lake(data: PackedByteArray, field: ChunkTerrainData, center: Vec var dz: int = world_z - center.z if dx * dx + dz * dz > radius * radius: continue - var voxel_index: int = _index(local_x, center.y, local_z) - if data[voxel_index] == BlockRegistryScript.BLOCK_AIR: - data[voxel_index] = BlockRegistryScript.BLOCK_LAVA + if config.worldgen_version <= 10: + var legacy_index: int = _index(local_x, center.y, local_z) + if data[legacy_index] == BlockRegistryScript.BLOCK_AIR: + data[legacy_index] = BlockRegistryScript.BLOCK_LAVA + continue + # V11 lakes are shallow supported basins rather than one-voxel discs. + # Their surface stays level while the interior gains up to three cells + # of depth; a solid floor is mandatory so lava never floats in a cavern. + var radial: float = sqrt(float(dx * dx + dz * dz)) / float(maxi(radius, 1)) + var depth: int = 1 + roundi((1.0 - clampf(radial, 0.0, 1.0)) * 2.0) + var bottom_y: int = center.y - depth + 1 + if bottom_y <= 1: + continue + var support: int = data[_index(local_x, bottom_y - 1, local_z)] + if support == BlockRegistryScript.BLOCK_AIR or support == BlockRegistryScript.BLOCK_WATER: + continue + var basin_clear := true + for y in range(bottom_y, center.y + 1): + if data[_index(local_x, y, local_z)] != BlockRegistryScript.BLOCK_AIR: + basin_clear = false + break + if not basin_clear: + continue + for y in range(bottom_y, center.y + 1): + data[_index(local_x, y, local_z)] = BlockRegistryScript.BLOCK_LAVA func _place_ore_veins(data: PackedByteArray, origin_x: int, origin_z: int) -> void: @@ -580,7 +894,8 @@ func _decorate_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: i var ceiling_y := _cave_solid_y(data, local_x, y, local_z, 1, 10) if floor_y < 1 and ceiling_y < 1: continue - var cave_biome := BiomeCatalogScript.cave_biome_at(config.seed, world_x, y, world_z) + var cave_biome := BiomeCatalogScript.cave_biome_at( + config.seed, world_x, y, world_z, config.worldgen_version) if floor_y >= 1 and _is_cave_stone(data[_index(local_x, floor_y, local_z)]): if cave_biome != BiomeCatalogScript.CAVE_BIOME_NONE and hash_value % 3 != 0: _paint_cave_floor_patch(data, local_x, floor_y, local_z, cave_biome, hash_value) @@ -590,7 +905,8 @@ func _decorate_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: i _decorate_lush_cave(data, local_x, local_z, floor_y, ceiling_y, hash_value) elif cave_biome == BiomeCatalogScript.DEEP_DARK: _decorate_deep_dark(data, local_x, local_z, floor_y, ceiling_y, hash_value) - if (floor_y >= 1 or ceiling_y >= 1) and hash_value % 13 == 0: + var dripstone_divisor := 3 if cave_biome == BiomeCatalogScript.DRIPSTONE_CAVES else 13 + if (floor_y >= 1 or ceiling_y >= 1) and hash_value % dripstone_divisor == 0: _stamp_dripstone(data, local_x, local_z, floor_y, ceiling_y, hash_value) if floor_y >= 4 and local_x >= 2 and local_x <= VoxelDefsScript.CHUNK_SIZE - 3 \ and local_z >= 2 and local_z <= VoxelDefsScript.CHUNK_SIZE - 3 and hash_value % 41 == 0: @@ -742,14 +1058,7 @@ func _stamp_geode(data: PackedByteArray, field: ChunkTerrainData, center: Vector func _ore_for_anchor(hash_value: int, y: int) -> int: - var roll: int = hash_value % 100 - if y < 32 and roll < 17: - return BlockRegistryScript.BLOCK_GOLD_ORE - if y < 58 and roll < 34: - return BlockRegistryScript.BLOCK_IRON_ORE - if y < 90 and roll < 57: - return BlockRegistryScript.BLOCK_COAL_ORE - return BlockRegistryScript.BLOCK_AIR + return _ore_catalog.select(hash_value, y) func _stamp_ore_segment(data: PackedByteArray, origin_x: int, origin_z: int, start: Vector3i, finish: Vector3i, ore: int) -> void: @@ -771,14 +1080,15 @@ func _stamp_ore_segment(data: PackedByteArray, origin_x: int, origin_z: int, sta data[_index(local_x, y, local_z)] = ore -func _decorate(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int, max_y: int) -> int: - # This cache is strictly per populate() call. It avoids repeatedly resolving - # expensive immutable sampler queries without introducing worker-shared state. - var tree_ground_cache: Dictionary = {} +func _decorate(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int, + max_y: int, ground_scratch: DecorationGroundScratch) -> int: # Dense-biome groves plus the sparse tree lottery are collected once; the # lattice pass below then only has to place non-tree features. - for tree in _collect_trees(field, origin_x, origin_z, tree_ground_cache): - max_y = maxi(max_y, _stamp_feature(data, origin_x, origin_z, int(tree[1]), int(tree[2]), int(tree[3]), int(tree[0]), int(tree[4]))) + var trees := _collect_trees(field, origin_x, origin_z, ground_scratch) + for tree_index in trees.size(): + max_y = maxi(max_y, _stamp_feature(data, origin_x, origin_z, + trees.world_xs[tree_index], trees.ground_ys[tree_index], trees.world_zs[tree_index], + trees.features[tree_index], trees.hashes[tree_index])) var first_x: int = WorldGenHashScript.floor_div(origin_x - FEATURE_HALO, FEATURE_CELL_SIZE) var last_x: int = WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + FEATURE_HALO, FEATURE_CELL_SIZE) var first_z: int = WorldGenHashScript.floor_div(origin_z - FEATURE_HALO, FEATURE_CELL_SIZE) @@ -788,7 +1098,7 @@ func _decorate(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, or var hash_value: int = WorldGenHashScript.hash_2d(config.seed + 1103, cell_x, cell_z) var world_x: int = cell_x * FEATURE_CELL_SIZE + 1 + hash_value % (FEATURE_CELL_SIZE - 2) var world_z: int = cell_z * FEATURE_CELL_SIZE + 1 + (hash_value / 31) % (FEATURE_CELL_SIZE - 2) - var ground := _cached_decoration_ground(field, world_x, world_z, tree_ground_cache) + var ground := _cached_decoration_ground(field, world_x, world_z, ground_scratch) if ground.x < 0: continue var biome: int = ground.y @@ -805,7 +1115,7 @@ func _decorate(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, or var probability: float = float(entry[2]) * config.decoration_density if WorldGenHashScript.float_01_2d(config.seed + 1129, cell_x, cell_z) >= minf(0.94, probability): continue - if not _feature_site_is_valid(field, tree_ground_cache, world_x, ground.x, world_z, flags): + if not _feature_site_is_valid(field, ground_scratch, world_x, ground.x, world_z, flags): continue max_y = maxi(max_y, _stamp_feature(data, origin_x, origin_z, world_x, ground.x, world_z, feature, hash_value)) max_y = maxi(max_y, _decorate_ground_cover(data, field, origin_x, origin_z)) @@ -818,8 +1128,9 @@ func _decorate(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, or ## only considers anchors inside the padded field and skips site-validity ## probes: those probes leave the field and cost nearly a full population, and ## a distant crown on an occasional rejected site is invisible at that range. -func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, ground_cache: Dictionary, lod: bool = false) -> Array: - var trees: Array = [] +func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, + ground_scratch: DecorationGroundScratch, lod: bool = false) -> TreeCandidates: + var trees := TreeCandidates.new() if config.tree_density <= 0.0 or config.decoration_density <= 0.0: return trees var first_grove_x: int = WorldGenHashScript.floor_div(origin_x - TREE_FOOTPRINT_RADIUS, TREE_CELL_SIZE) @@ -836,7 +1147,7 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, groun var world_z: int = cell_z * TREE_CELL_SIZE + 2 + (anchor_hash / 23) % 2 if lod and not ChunkTerrainDataScript.is_valid_local(world_x - origin_x, world_z - origin_z): continue - var ground := _cached_decoration_ground(field, world_x, world_z, ground_cache) + var ground := _cached_decoration_ground(field, world_x, world_z, ground_scratch) if ground.x < VoxelDefsScript.SEA_LEVEL - 3: continue var decoration_set: int = biomes.decoration_set(ground.y) @@ -856,12 +1167,12 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, groun if decoration_set == BiomeCatalogScript.DECORATION_FOREST and grove_strength > 0.75 and anchor_hash % 3 == 0: feature = DecorationCatalog.FEATURE_ANCIENT_TREE if not lod: - if not _feature_site_is_valid(field, ground_cache, world_x, ground.x, world_z, int(entry[3])): + if not _feature_site_is_valid(field, ground_scratch, world_x, ground.x, world_z, int(entry[3])): continue if feature != DecorationCatalog.FEATURE_MANGROVE: - if not _tree_site_is_safe(field, ground_cache, world_x, ground.x, world_z, _tree_footprint_for(feature), _tree_top_offset_for(feature, feature_hash)): + if not _tree_site_is_safe(field, ground_scratch, world_x, ground.x, world_z, _tree_footprint_for(feature), _tree_top_offset_for(feature, feature_hash)): continue - trees.append([feature, world_x, ground.x, world_z, feature_hash]) + trees.append(feature, world_x, ground.x, world_z, feature_hash) # Sparse tree lottery for biomes without grove trees (plains oak, savanna # acacia, cold spruce, ...). Grove biomes use choose_non_tree above, so the # two sources never overlap. @@ -876,7 +1187,7 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, groun var world_z: int = cell_z * FEATURE_CELL_SIZE + 1 + (hash_value / 31) % (FEATURE_CELL_SIZE - 2) if lod and not ChunkTerrainDataScript.is_valid_local(world_x - origin_x, world_z - origin_z): continue - var ground := _cached_decoration_ground(field, world_x, world_z, ground_cache) + var ground := _cached_decoration_ground(field, world_x, world_z, ground_scratch) if ground.x < 0: continue var decoration_set: int = biomes.decoration_set(ground.y) @@ -892,12 +1203,12 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, groun if WorldGenHashScript.float_01_2d(config.seed + 1129, cell_x, cell_z) >= minf(0.94, probability): continue if not lod: - if not _feature_site_is_valid(field, ground_cache, world_x, ground.x, world_z, flags): + if not _feature_site_is_valid(field, ground_scratch, world_x, ground.x, world_z, flags): continue if feature != DecorationCatalog.FEATURE_MANGROVE: - if not _tree_site_is_safe(field, ground_cache, world_x, ground.x, world_z, _tree_footprint_for(feature), _tree_top_offset_for(feature, hash_value)): + if not _tree_site_is_safe(field, ground_scratch, world_x, ground.x, world_z, _tree_footprint_for(feature), _tree_top_offset_for(feature, hash_value)): continue - trees.append([feature, world_x, ground.x, world_z, hash_value]) + trees.append(feature, world_x, ground.x, world_z, hash_value) return trees @@ -948,10 +1259,11 @@ func _grove_tree_probability(decoration_set: int, grove_strength: float) -> floa ## Placement flags are evaluated from immutable terrain samples so every chunk ## touching a cross-border feature reaches the same ecological decision. -func _feature_site_is_valid(field: ChunkTerrainData, ground_cache: Dictionary, world_x: int, ground_y: int, world_z: int, flags: int) -> bool: +func _feature_site_is_valid(field: ChunkTerrainData, ground_scratch: DecorationGroundScratch, + world_x: int, ground_y: int, world_z: int, flags: int) -> bool: if ground_y < 1 or ground_y + 1 >= VoxelDefsScript.WORLD_HEIGHT: return false - var site_biome: int = _cached_decoration_ground(field, world_x, world_z, ground_cache).y + var site_biome: int = _cached_decoration_ground(field, world_x, world_z, ground_scratch).y if (flags & DecorationCatalog.FLAG_DRY_GROUND) != 0: var surface_block: int = biomes.surface_block(site_biome) if surface_block != BlockRegistryScript.BLOCK_SAND and surface_block != BlockRegistryScript.BLOCK_RED_SAND: @@ -962,7 +1274,7 @@ func _feature_site_is_valid(field: ChunkTerrainData, ground_cache: Dictionary, w or (site_biome == BiomeCatalogScript.SWAMP and ground_y <= max_water_edge_y) for direction in VoxelDefsScript.DIRS_4: var neighbor := _cached_decoration_ground( - field, world_x + direction.x * 2, world_z + direction.y * 2, ground_cache) + field, world_x + direction.x * 2, world_z + direction.y * 2, ground_scratch) if neighbor.x < VoxelDefsScript.SEA_LEVEL: near_water = true break @@ -1003,7 +1315,8 @@ func _tree_top_offset_for(feature: int, hash_value: int) -> int: ## a solid top above sea level; the profile probes reject steep/embedded sites, ## and the only cave stage allowed to reach that surface (an entrance) is ## reproduced below as a shared immutable exclusion. -func _tree_site_is_safe(field: ChunkTerrainData, ground_cache: Dictionary, world_x: int, ground_y: int, world_z: int, footprint: int, top_offset: int) -> bool: +func _tree_site_is_safe(field: ChunkTerrainData, ground_scratch: DecorationGroundScratch, + world_x: int, ground_y: int, world_z: int, footprint: int, top_offset: int) -> bool: if ground_y <= VoxelDefsScript.SEA_LEVEL + 1 or ground_y + top_offset >= VoxelDefsScript.WORLD_HEIGHT: return false if _tree_root_is_near_cave_entrance(world_x, world_z): @@ -1012,18 +1325,20 @@ func _tree_site_is_safe(field: ChunkTerrainData, ground_cache: Dictionary, world # the low canopy. Nine fixed probes (root plus this ring) replace the former # 29 point-query footprint scan; results are cached per chunk job. for direction in VoxelDefsScript.DIRS_8: - var nearby_ground := _cached_decoration_ground(field, world_x + direction.x * footprint, world_z + direction.y * footprint, ground_cache) + var nearby_ground := _cached_decoration_ground(field, world_x + direction.x * footprint, + world_z + direction.y * footprint, ground_scratch) if nearby_ground.x < 0 or nearby_ground.x > ground_y + 1 or nearby_ground.x < ground_y - 3: return false return true -func _cached_decoration_ground(field: ChunkTerrainData, world_x: int, world_z: int, ground_cache: Dictionary) -> Vector2i: - var position := Vector2i(world_x, world_z) - if ground_cache.has(position): - return ground_cache[position] +func _cached_decoration_ground(field: ChunkTerrainData, world_x: int, world_z: int, + ground_scratch: DecorationGroundScratch) -> Vector2i: + var slot := ground_scratch.find_slot(world_x, world_z) + if slot >= 0: + return ground_scratch.value_at(slot) var ground := _decoration_ground(field, world_x, world_z) - ground_cache[position] = ground + ground_scratch.insert(world_x, world_z, ground) return ground @@ -1130,8 +1445,8 @@ const UNDERWATER_CELL_SIZE: int = 6 const UNDERWATER_HALO: int = 4 const UNDERWATER_TUFT_RADIUS: int = 2 -func _decorate_underwater(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int, max_y: int) -> int: - var ground_cache: Dictionary = {} +func _decorate_underwater(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int, + max_y: int, ground_scratch: DecorationGroundScratch) -> int: var first_x: int = WorldGenHashScript.floor_div(origin_x - UNDERWATER_HALO, UNDERWATER_CELL_SIZE) var last_x: int = WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + UNDERWATER_HALO, UNDERWATER_CELL_SIZE) var first_z: int = WorldGenHashScript.floor_div(origin_z - UNDERWATER_HALO, UNDERWATER_CELL_SIZE) @@ -1141,7 +1456,7 @@ func _decorate_underwater(data: PackedByteArray, field: ChunkTerrainData, origin var anchor_hash: int = WorldGenHashScript.hash_2d(config.seed + 1423, cell_x, cell_z) var center_x: int = cell_x * UNDERWATER_CELL_SIZE + 2 + anchor_hash % (UNDERWATER_CELL_SIZE - 4) var center_z: int = cell_z * UNDERWATER_CELL_SIZE + 2 + (anchor_hash / 29) % (UNDERWATER_CELL_SIZE - 4) - var ground := _cached_decoration_ground(field, center_x, center_z, ground_cache) + var ground := _cached_decoration_ground(field, center_x, center_z, ground_scratch) if ground.x < 0 or not biomes.is_ocean_biome(ground.y): continue var set_id: int = biomes.decoration_set(ground.y) @@ -1157,7 +1472,7 @@ func _decorate_underwater(data: PackedByteArray, field: ChunkTerrainData, origin var tuft_hash: int = WorldGenHashScript.hash_3d(config.seed + 1451, cell_x, tuft_index, cell_z) var world_x: int = center_x + (tuft_hash % (UNDERWATER_TUFT_RADIUS * 2 + 1)) - UNDERWATER_TUFT_RADIUS var world_z: int = center_z + ((tuft_hash / 13) % (UNDERWATER_TUFT_RADIUS * 2 + 1)) - UNDERWATER_TUFT_RADIUS - var tuft_ground := _cached_decoration_ground(field, world_x, world_z, ground_cache) + var tuft_ground := _cached_decoration_ground(field, world_x, world_z, ground_scratch) if tuft_ground.x < 0 or not biomes.is_ocean_biome(tuft_ground.y): continue var depth: int = VoxelDefsScript.SEA_LEVEL - tuft_ground.x @@ -1666,7 +1981,7 @@ func _actual_max_y(data: PackedByteArray, hinted_max_y: int) -> int: func _surface_height(field: ChunkTerrainData, field_index: int) -> int: if config.world_type == WorldGenConfigScript.WORLD_TYPE_FLAT: - return 4 + return FLAT_VOXEL_SURFACE_Y return clampi(roundi(field.final_height[field_index]), 2, VoxelDefsScript.WORLD_HEIGHT - 2) diff --git a/world/worldgen/world_gen_config.gd b/world/worldgen/world_gen_config.gd index dda75a1..11f0865 100644 --- a/world/worldgen/world_gen_config.gd +++ b/world/worldgen/world_gen_config.gd @@ -3,7 +3,9 @@ class_name WorldGenConfig extends RefCounted -const CURRENT_VERSION: int = 8 +const CURRENT_VERSION: int = 13 +const SPLINE_TERRAIN_VERSION: int = 10 +const VARIANT_WORLDGEN_VERSION: int = 11 const WORLD_TYPE_NORMAL: int = 0 const WORLD_TYPE_FLAT: int = 1 @@ -21,6 +23,34 @@ const DEFAULT_REGIONAL_EROSION: float = 0.5 const DEFAULT_HYDRAULIC_EROSION: bool = false const DEFAULT_CAVE_DENSITY: float = 1.0 const DEFAULT_DECORATION_DENSITY: float = 1.0 +const DEFAULT_SPLINE_TERRAIN: bool = false +const DEFAULT_ELEVATED_HYDROLOGY: bool = false +const DEFAULT_CLIMATE_VARIANTS: bool = true +const DEFAULT_REGION_STRUCTURES: bool = true + +const MIN_TERRAIN_SCALE: float = 0.25 +const MAX_TERRAIN_SCALE: float = 2.0 +const MIN_TREE_DENSITY: float = 0.0 +const MAX_TREE_DENSITY: float = 4.0 +const MIN_MACRO_SCALE: float = 32.0 +const MAX_MACRO_SCALE: float = 8192.0 +const MIN_BIOME_SCALE: float = 128.0 +const MAX_BIOME_SCALE: float = 8192.0 +const MIN_RIVER_DENSITY: float = 0.0 +const MAX_RIVER_DENSITY: float = 4.0 +const MIN_EROSION_STRENGTH: float = 0.0 +const MAX_EROSION_STRENGTH: float = 1.0 +const MIN_CAVE_DENSITY: float = 0.0 +const MAX_CAVE_DENSITY: float = 4.0 +const MIN_DECORATION_DENSITY: float = 0.0 +const MAX_DECORATION_DENSITY: float = 4.0 + +# Shared generation semantics. These are named compatibility values, not extra +# UI controls; changing one requires a versioned generation path. +const RIVER_CHANNEL_THRESHOLD: float = 0.62 +const RIVER_DENSITY_NORMALIZATION: float = 0.25 +const RIVER_CORRIDOR_SCALE: float = 0.70 +const RIVER_UNDERGROUND_CLEARANCE: int = 3 var seed: int var world_type: int @@ -35,28 +65,44 @@ var regional_erosion: float var hydraulic_erosion: bool var cave_density: float var decoration_density: float +var spline_terrain: bool +var elevated_hydrology: bool +var climate_variants: bool +var region_structures: bool func _init(source: Dictionary = {}) -> void: seed = int(source.get("seed", DEFAULT_SEED)) world_type = clampi(int(source.get("world_type", DEFAULT_WORLD_TYPE)), WORLD_TYPE_NORMAL, WORLD_TYPE_AMPLIFIED) - terrain_scale = clampf(float(source.get("terrain_scale", DEFAULT_TERRAIN_SCALE)), 0.25, 4.0) - tree_density = clampf(float(source.get("tree_density", DEFAULT_TREE_DENSITY)), 0.0, 4.0) - worldgen_version = max(1, int(source.get("worldgen_version", CURRENT_VERSION))) - macro_scale = clampf(float(source.get("macro_scale", DEFAULT_MACRO_SCALE)), 32.0, 8192.0) - biome_scale = clampf(float(source.get("biome_scale", DEFAULT_BIOME_SCALE)), 128.0, 8192.0) - river_density = clampf(float(source.get("river_density", DEFAULT_RIVER_DENSITY)), 0.0, 4.0) - erosion_strength = clampf(float(source.get("erosion_strength", DEFAULT_EROSION_STRENGTH)), 0.0, 1.0) - regional_erosion = clampf(float(source.get("regional_erosion", DEFAULT_REGIONAL_EROSION)), 0.0, 1.0) + terrain_scale = clampf(float(source.get("terrain_scale", DEFAULT_TERRAIN_SCALE)), MIN_TERRAIN_SCALE, MAX_TERRAIN_SCALE) + tree_density = clampf(float(source.get("tree_density", DEFAULT_TREE_DENSITY)), MIN_TREE_DENSITY, MAX_TREE_DENSITY) + worldgen_version = clampi(int(source.get("worldgen_version", CURRENT_VERSION)), 1, CURRENT_VERSION) + macro_scale = clampf(float(source.get("macro_scale", DEFAULT_MACRO_SCALE)), MIN_MACRO_SCALE, MAX_MACRO_SCALE) + biome_scale = clampf(float(source.get("biome_scale", DEFAULT_BIOME_SCALE)), MIN_BIOME_SCALE, MAX_BIOME_SCALE) + river_density = clampf(float(source.get("river_density", DEFAULT_RIVER_DENSITY)), MIN_RIVER_DENSITY, MAX_RIVER_DENSITY) + erosion_strength = clampf(float(source.get("erosion_strength", DEFAULT_EROSION_STRENGTH)), MIN_EROSION_STRENGTH, MAX_EROSION_STRENGTH) + regional_erosion = clampf(float(source.get("regional_erosion", DEFAULT_REGIONAL_EROSION)), MIN_EROSION_STRENGTH, MAX_EROSION_STRENGTH) hydraulic_erosion = bool(source.get("hydraulic_erosion", DEFAULT_HYDRAULIC_EROSION)) - cave_density = clampf(float(source.get("cave_density", DEFAULT_CAVE_DENSITY)), 0.0, 4.0) - decoration_density = clampf(float(source.get("decoration_density", DEFAULT_DECORATION_DENSITY)), 0.0, 4.0) + cave_density = clampf(float(source.get("cave_density", DEFAULT_CAVE_DENSITY)), MIN_CAVE_DENSITY, MAX_CAVE_DENSITY) + decoration_density = clampf(float(source.get("decoration_density", DEFAULT_DECORATION_DENSITY)), MIN_DECORATION_DENSITY, MAX_DECORATION_DENSITY) + spline_terrain = worldgen_version >= SPLINE_TERRAIN_VERSION \ + and bool(source.get("spline_terrain", DEFAULT_SPLINE_TERRAIN)) + elevated_hydrology = worldgen_version >= SPLINE_TERRAIN_VERSION \ + and bool(source.get("elevated_hydrology", DEFAULT_ELEVATED_HYDROLOGY)) + climate_variants = worldgen_version >= VARIANT_WORLDGEN_VERSION \ + and bool(source.get("climate_variants", DEFAULT_CLIMATE_VARIANTS)) + region_structures = worldgen_version >= VARIANT_WORLDGEN_VERSION \ + and bool(source.get("region_structures", DEFAULT_REGION_STRUCTURES)) static func from_dictionary(source: Dictionary) -> WorldGenConfig: return WorldGenConfig.new(source) +static func default_dictionary() -> Dictionary: + return WorldGenConfig.new().to_dictionary() + + func to_dictionary() -> Dictionary: return { "seed": seed, @@ -72,4 +118,8 @@ func to_dictionary() -> Dictionary: "hydraulic_erosion": hydraulic_erosion, "cave_density": cave_density, "decoration_density": decoration_density, + "spline_terrain": spline_terrain, + "elevated_hydrology": elevated_hydrology, + "climate_variants": climate_variants, + "region_structures": region_structures, }