From f7404af7cf4c554095c248429372d848b5e5dec6 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 23:53:25 +0100 Subject: [PATCH 1/3] Complete parallel roadmap foundations --- .github/workflows/tests.yml | 9 +- ROADMAP.md | 14 +- assets/README.md | 26 ++ autoload/game_config.gd | 63 +++- game/first_run_hints.gd | 175 ++++++++++ game/first_run_hints.gd.uid | 1 + game/main.gd | 147 +++++++-- game/photo_mode.gd | 26 +- player/player.gd | 31 +- tools/UNIT_TESTS.md | 31 ++ tools/asset_license_verify.gd | 321 ++++++++++++++++++ tools/asset_license_verify.gd.uid | 1 + tools/block_physics_verify.gd | 182 ++++++++++ tools/block_physics_verify.gd.uid | 1 + tools/block_physics_verify.tscn | 6 + tools/first_run_hints_verify.gd | 100 ++++++ tools/first_run_hints_verify.gd.uid | 1 + tools/loading_progress_verify.gd | 54 +++ tools/loading_progress_verify.gd.uid | 1 + tools/motion_camera_verify.gd | 230 +++++++++++++ tools/motion_camera_verify.gd.uid | 1 + tools/motion_camera_verify.tscn | 6 + tools/photo_mode_verify.gd | 4 +- tools/unit/block_registry_unit_test.gd | 53 +++ tools/unit/block_registry_unit_test.gd.uid | 1 + tools/unit/mesher_unit_test.gd | 67 ++++ tools/unit/mesher_unit_test.gd.uid | 1 + tools/unit/unit_suite.gd | 32 ++ tools/unit/unit_suite.gd.uid | 1 + tools/unit/worldgen_unit_test.gd | 66 ++++ tools/unit/worldgen_unit_test.gd.uid | 1 + tools/unit_tests.gd | 23 ++ tools/unit_tests.gd.uid | 1 + tools/world_storage_verify.gd | 366 ++++++++++++++++++--- ui/README.md | 4 + ui/loading_overlay.gd | 161 +++++++++ ui/loading_overlay.gd.uid | 1 + ui/motion.gd | 40 +++ ui/settings_category_panel.gd | 5 +- world/block_registry.gd | 7 + world/voxel_world.gd | 234 +++++++++++++ 41 files changed, 2402 insertions(+), 93 deletions(-) create mode 100644 assets/README.md create mode 100644 game/first_run_hints.gd create mode 100644 game/first_run_hints.gd.uid create mode 100644 tools/UNIT_TESTS.md create mode 100644 tools/asset_license_verify.gd create mode 100644 tools/asset_license_verify.gd.uid create mode 100644 tools/block_physics_verify.gd create mode 100644 tools/block_physics_verify.gd.uid create mode 100644 tools/block_physics_verify.tscn create mode 100644 tools/first_run_hints_verify.gd create mode 100644 tools/first_run_hints_verify.gd.uid create mode 100644 tools/loading_progress_verify.gd create mode 100644 tools/loading_progress_verify.gd.uid create mode 100644 tools/motion_camera_verify.gd create mode 100644 tools/motion_camera_verify.gd.uid create mode 100644 tools/motion_camera_verify.tscn create mode 100644 tools/unit/block_registry_unit_test.gd create mode 100644 tools/unit/block_registry_unit_test.gd.uid create mode 100644 tools/unit/mesher_unit_test.gd create mode 100644 tools/unit/mesher_unit_test.gd.uid create mode 100644 tools/unit/unit_suite.gd create mode 100644 tools/unit/unit_suite.gd.uid create mode 100644 tools/unit/worldgen_unit_test.gd create mode 100644 tools/unit/worldgen_unit_test.gd.uid create mode 100644 tools/unit_tests.gd create mode 100644 tools/unit_tests.gd.uid create mode 100644 ui/loading_overlay.gd create mode 100644 ui/loading_overlay.gd.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ae8a43e..0c8921f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,6 @@ name: PR tests # (4 vCPU) runners can exceed that and fail spuriously. Raise MAX_WAIT_TICKS. # - The parse check only walks scripts the editor loads; an unreferenced # broken script needs a dedicated scanner (load() + can_instantiate()). -# - Unit tests for pure logic still need a runner (ROADMAP "Unit tests"). on: pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -61,11 +60,16 @@ jobs: CHECK_TIMEOUT_SECONDS: "120" run: | bash .github/scripts/run_checks.sh \ + tools/unit_tests.gd \ + tools/asset_license_verify.gd \ tools/audio_verify.gd \ tools/weather_verify.gd \ tools/ui_flow_verify.gd \ tools/ui_scale_verify.gd \ tools/input_rebind_verify.gd \ + tools/loading_progress_verify.gd \ + tools/first_run_hints_verify.gd \ + tools/motion_camera_verify.tscn \ tools/photo_mode_verify.gd \ tools/player_target_verify.tscn \ tools/player_survival_verify.gd \ @@ -179,7 +183,8 @@ jobs: tools/lod_mode_verify.gd \ tools/chunk_data_compression_verify.gd \ tools/world_storage_verify.gd \ - tools/light_invalidation_verify.gd + tools/light_invalidation_verify.gd \ + tools/block_physics_verify.tscn - name: Upload check logs if: failure() diff --git a/ROADMAP.md b/ROADMAP.md index 77623d3..3ce4f51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,13 +23,13 @@ Property names and file references are included so each item is easy to find. - [x] Play and Settings information architecture — Play opens a world setup screen (world type, seed, clipboard import, Create Game) whose Advanced button opens the tunables-only world gen screen; Settings opens a category hub (Display, Graphics, Sound, Gameplay) where Advanced Graphics is itself a hub split into Lighting, Shadows, Sky & Atmosphere, Post-Processing, and Performance section panels. Cancel unwinds one layer at a time with focus restoration, and the pause menu reuses the hub. `tools/ui_flow_verify.gd` covers the flows - [x] Compact HUD — hotbar slots 76 -> 48 px with smaller icons, key hints, and counts, coords/stats chips reduced to ~150 px through the shared compact chip style, and status/selection labels moved above the smaller hotbar - [x] E opens the inventory — `inventory` action rebound from I; main-menu footer and docs updated -- [ ] Loading and world-creation progress — show generation/streaming progress between menu and gameplay; high render distances still need feedback while the refreshed RD 16/32 authoritative-stream measurements are collected +- [x] Loading and world-creation progress — world entry now streams asynchronously behind a themed `LoadingOverlay`, keeps player/photo input locked until the authoritative 3x3 collision ring is ready, recovers invalid saved positions only after that ring commits, and leaves compact loaded/total progress in the HUD while the remaining horizon streams. Extreme-distance telemetry is throttled to avoid repeatedly scanning the full desired square. `tools/loading_progress_verify.gd` covers the presentation and entry-lock wiring - [ ] Chat / command console — `/time`, `/weather`, `/tp`, `/give`, `/seed`, `/gamemode`, and `/help` for testing and moderation; reuses `DayNightCycle.set_time()`, `WeatherSystem.toggle()`, and `GameConfig.world`. Future mode changes must be command-only; there is currently no way to switch an existing world's mode in-game. - [x] Death and respawn flow — `SurvivalUI` presents a focused respawn/menu modal; respawn streams and validates `Player.spawn_position` before resuming. Saved deaths reopen the prompt. - [x] Photo mode and HUD hiding — F1 hides the HUD, F2 saves a HUD-free PNG to `user://screenshots/` (every CanvasLayer, so modals cannot leak into the frame), and P swaps in a detached free camera (`game/photo_mode.gd`, built on the `game/shadow_capture.gd` precedent: opt-in node added by `Main`, own key handling, `status_requested` feedback, `user://` output). The free camera inherits the player camera pose/FOV, flies with WASD/SPACE/SHIFT/CTRL, zooms with the wheel, and P/Esc return to the player. While active, `Main` follows it with chunk streaming (entry streams around the camera asynchronously; exit sync-commits the player's 3x3 ring before unfreezing so a long flight cannot drop the player), rain/cover, underwater grading, and the coords readout, and `Player.set_photo_mode()` freezes input and hides the targeting highlight and held block; entering photo mode hides the HUD, minimap, and F3 worldgen overlay, and exiting restores the pre-entry HUD state (an F1 press inside photo mode persists). `tools/photo_mode_verify.gd` covers HUD state, camera handoff, look/movement, player artifact and UI-layer hiding, and screenshot naming; `_get_shadow_capture_state()` records HUD/camera mode - [x] Minimap and world map overlay — `ui/minimap.gd` adds an optional corner map (`]` toggles, off by default, 64 px detail over a 256-block span) and `ui/map_overlay.gd` a full-screen modal atlas (`M`) that zooms with the wheel or `+`/`-`, pans with drag or arrow keys, and resamples its worker raster against the settled view (128 px detail, 128–1536-block view), sharing view math in `ui/map_view.gd`. Both cycle the nine `WorldgenOverlay` modes with N, sample asynchronously through `VoxelWorld.request_debug_map()` — whose detail cap was raised, with the final-height family downshifting automatically because it resolves the erosion graph per pixel — and the minimap rebuilds on open, mode change, and a 64-block recenter. North-up compass and span captions; footer, pause menu, and the startup toast document the keys; `tools/map_verify.gd` pins mode cycling, view projection, zoom/pan limits, payload packing, and the overlay's pause ownership - [x] Frame pacing options — Display settings gained V-Sync (Off/On/Adaptive/Mailbox), an FPS cap (Unlimited/30-240, stored as the real frame rate), and dynamic resolution: `GameConfig.dynamic_resolution_scale()` is a pure stepping policy (50% floor, 5% steps, 1.05x down / 0.85x up margins) whose budget is the slower of the FPS target and any hard cap (FPS cap or vsync refresh) so capped frames are not read as GPU load. `Main._update_frame_pacing()` smooths frame time and applies one step per 0.5 s to `Viewport.scaling_3d_scale`, never above the configured Render Scale, switching to the FSR2 upscale path whenever the scale is sub-native; the target row hides while the toggle is off, and out-of-table persisted values snap to the nearest option. V-Sync/FPS apply at load and on change through `GameConfig.apply_frame_pacing()`; all four keys persist in `settings.cfg`. Live startup-streaming trace stepped 0.77 -> 0.67 and recovered to 0.77 once frames were cheap; `tools/frame_pacing_verify.gd` pins defaults, key application, option value mapping, and policy steps -- [ ] First-run hints — surface controls gradually (movement, fly double-tap, inventory, targeting) instead of one startup status toast +- [x] First-run hints — `game/first_run_hints.gd` presents one contextual cue at a time for movement, targeting, inventory, and Creative flight, using live rebound key labels and pausing under loading, modals, death, hidden HUD, and photo mode. Completed or dismissed cues persist account-wide in `settings.cfg`; Survival never receives flight guidance. `tools/first_run_hints_verify.gd` covers sequencing, persistence, mode gating, and rebound labels ## Shadows - [x] Near-shadow resolution — `Main.NEAR_SHADOW_DISTANCE` reserves the first cascade for 6 m around the player, keeping close-up shadow texels dense enough to stop edge crawl (user-confirmed fixed) @@ -189,7 +189,7 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor ## World / simulation - [x] Water polish — flowing water levels (IDs 28-34) with a 4 Hz cellular spill/dry sim seeded by edits, falling cascades, settled state persisted through `_edited_blocks`; the mesher renders `_water_top(level)` surfaces with a flowing vertex-color flag. `water.gdshader` uses sharp, gently distorted scene refraction with chromatic split disabled, progressive depth absorption, and faint narrow shore foam. -- [ ] Block physics — gravity blocks (falling sand/gravel) and other environment reactions that must persist through saving +- [x] Block physics — sand, red sand, and gravel use a deduplicated, budgeted 4 Hz gravity queue seeded by placement, support removal, explosions, fire, water changes, and rehydrated edits. Moves only touch resident full chunks, record both source and destination through `_record_edit()`, batch persistence/remesh invalidation, and resume deterministically after reload without treating unloaded/LOD terrain as air. `tools/block_physics_verify.tscn` covers support, stacks, chunk safety, and persistence - [x] Fire, TNT, and explosions — TNT (5-block radius) and nuke (18-block radius) destruction already routes through `VoxelWorld.carve_sphere()`/`_record_edit()`. Burning works in three states and never swaps the block for fire: a `BlockRegistry.FLAG_FLAMMABLE` log/leaf/plant/mushroom enters `_burning` for `FUEL_BURN_TICKS` with its texture and collision intact while `FireOverlay` (`world/fire_overlay.gd` + `fire_overlay.gdshader`/`smoke_overlay.gdshader`, one MultiMesh per effect) draws animated flame and smoke billboards over it; it crawls into flammable neighbours one at a time, paced by a per-block `FIRE_IGNITION_DELAY_TICKS` (4), `FIRE_SPREAD_PER_TICK` (1) on `FIRE_SPREAD_PERIOD_TICKS` (2) and a per-cell `FIRE_SPREAD_INTERVAL_TICKS` cooldown (~2 blocks/s), and can chain-detonate adjacent TNT/nukes; when the timer expires the block crumbles to air and that edit goes through `_record_edit()`. `BLOCK_FIRE` (70) is only the standalone cross flame flint-and-steel leaves on a non-flammable face (`assets/placeholders/fire/fire.png` via `tools/gen_fire_texture.gd`; smoke puff likewise), decaying when starved/unsupported; `_seed_fire_edits()` re-arms persisted flames on commit. Flint and steel (`use_flint_and_steel()`) detonates an explosive, starts the clicked flammable block burning, or lights the clicked face. `tools/fire_verify.tscn` covers flammability, burn-in-place, spread, decay, chain detonation, flint and steel, persistence reseeding, and the shader wiring - [ ] Buckets and fluid sources — water/lava buckets and source rules on top of the flowing levels 28-34 and `_water_tick`, plus lava/water interaction - [ ] Growth and decay — grass spread, leaf decay, sapling growth, and crop farming @@ -201,16 +201,16 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor ## Accessibility and input - [x] Key rebinding — `GameConfig.settings["input_bindings"]` stores one physical key per action and persists it in `settings.cfg`; `apply_input_bindings()` rewrites the InputMap at load and `set_input_binding()` applies remaps live, with `ProjectSettings` keeping the project defaults for "Reset to Defaults". Settings gained a Controls category whose rows cover every non-`ui_*` `[input]` action (known actions get labels and groups, unknown ones are appended), capture a key on click (Esc or a mouse click cancels), summarize shared keys instead of rejecting the intentional Space/Shift pairs, and update the menu, pause, HUD status, and inventory hints from the live bindings. `tools/input_rebind_verify.gd` covers action coverage, apply/reset, conflict lookup, the settings.cfg round-trip, and the panel's capture flow - [ ] Gamepad support — joypad bindings, dead zones, and button prompts; there are no joypad events in `project.godot` -- [ ] Motion and camera options — invert Y, separate X/Y sensitivity, and a reduced-motion toggle that disables `ui/motion.gd` tweens +- [x] Motion and camera options — Gameplay settings now provide separate horizontal/vertical sensitivity, invert Y, and reduced motion. Legacy `mouse_sensitivity` migrates into both axes; Player and PhotoMode share the live axis/inversion settings, and `ui/motion.gd` plus the loading overlay resolve reduced motion without creating cosmetic tweens. `tools/motion_camera_verify.tscn` covers migration, persistence, look direction, rows, and motion suppression - [x] UI scale and text size — Display settings gained UI Scale (75/100/125/150/200%) and Text Size (Small/Default/Large/Larger), both persisted in `settings.cfg`. UI Scale drives `Window.content_scale_factor` in canvas-item mode through `GameConfig.apply_ui_scale()`, so the HUD and menus resize together (the engine oversamples fonts at the same factor, keeping text crisp) while the 3D render resolution and `scaling_3d_scale` stay untouched; Text Size is a `UITheme` font-size multiplier applied on top of the layout. `UITheme.apply()` registers each screen host and stages every font-size override, and the `interface_scale_changed` signal makes `UITheme.refresh_all()` rebuild the shared theme and re-scale tagged sizes live; the Display category rows scroll and re-fit the open modal when the scale changes, so the taller list stays reachable at large scales. `tools/ui_scale_verify.gd` pins defaults, option snapping, window application, the live refresh, panel bounds at 200%, and the Display rows - [ ] Accessibility aids — subtitles for audio cues, colorblind-safe HUD/map colors, and a damage/danger vignette toggle ## Tooling and CI - [x] GitHub Actions runners — `.github/workflows/opencode-pr.yml` runs the advisory opencode review on every non-draft PR, and `.github/workflows/tests.yml` runs the parse check plus the `tools/` verifiers as parallel `fast-checks` / `worldgen-core` / `worldgen-regression` / `streaming` shards on every PR and PR push. No Nix/devenv setup: the checksum-verified Redot 26.2 release is installed and cached by `.github/actions/setup-redot`, and `.github/scripts/run_checks.sh` gives each check its own process, timeout, log, and step-summary row -- [ ] Unit tests (blocking) — add a unit test runner for pure logic (worldgen fields/hashes, `BlockRegistry` packing, water level rules, mesher/AO math) alongside the headless verifiers, run it on every PR, and make it the only required status check in branch protection so it blocks merges +- [ ] Unit tests (branch protection pending) — the dependency-free `tools/unit_tests.gd` runner now covers pinned worldgen fields/hashes, `BlockRegistry` byte/layer packing, water-level inverses, and mesher/AO fixtures (10 tests / 676 assertions), and runs in the existing `fast-checks` shard under the aggregate `PR tests` gate. Repository work is complete; a GitHub administrator still needs to configure `PR tests` as the only required branch-protection status - [x] AI code review (advisory) — `.github/workflows/opencode-pr.yml` runs the pinned `anomalyco/opencode/github` action on every non-draft PR from this repository plus manual dispatch, using `.github/prompts/pr-review.md` (RedotCraft rubric: worker-thread safety, streaming invariants, determinism, water rules, GDScript/shaders, UI signals) with GLM 5.3 and prior bot reviews fed back as context. Helper steps are inlined (PR context, previous reviews, permissions, prompt loading, diagnostics artifact) and there is no Nix/devenv setup; advisory only, never a required status check - [x] Parse check and headless verifiers in CI — `.github/workflows/tests.yml` runs `redot --editor --headless --path . --quit` plus `worldgen_verify`, `worldgen_biome_verify`, `worldgen_river_verify`, `worldgen_lod_verify`, `worldgen_tree_verify`, `worldgen_cactus_verify`, `audio_verify`, `ui_flow_verify`, `frame_pacing_verify`, `stream_full_verify`, and `player_target_verify.tscn` behind the `PR tests` aggregate job, which is designed to be the single required status check. Advisory until branch protection requires it; remaining hardening (streaming wait budget, unreferenced-script parse scan, unit runner) is tracked in the workflow header - [ ] Benchmark guardrails — assert the pinned generation/mesh/streaming budgets in CI; `tools/worldgen_benchmark.gd` and friends record times but nothing enforces them -- [ ] Migration tests — load version-tagged worldgen/save fixtures across revisions and assert clean migration or rejection +- [x] Migration tests — `tools/world_storage_verify.gd` now loads literal sparse metadata fixtures for worldgen v1-v14, pins a SHA-256 chunk output per historical version, migrates handcrafted negative-coordinate region v1 bytes to deterministic v2 bytes, exercises session v0/v1/current migrations, and rejects malformed/future metadata, session, and region formats while retaining backup recovery coverage - [ ] Replay regression harness — extend the F9 `game/shadow_capture.gd` bundle into a general deterministic replay so reported bugs reproduce from a saved bundle -- [ ] Asset and license check — validate texture/audio naming, imports, and license files in CI; `tools/icon_check.gd` still needs a display +- [x] Asset and license check — `tools/asset_license_verify.gd` deterministically audits shipped PNG/OGG naming, source references, committed import metadata/cache availability, and required attribution/license documents in headless CI. Runtime-generated and intentionally unused placeholder assets are explicitly excluded; display-only `tools/icon_check.gd` remains a separate rendered check diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..6506198 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,26 @@ +# Asset verification + +Run the deterministic source-asset audit with: + +```sh +redot --headless --path . --script res://tools/asset_license_verify.gd +``` + +It checks the shipped placeholder textures, audio-bank names, font references, +asset references used by `BlockRegistry` and `FireOverlay`, committed `.import` +recipes, headless import-cache availability, and the required license and +attribution documents. + +The checker deliberately does **not** scan `.godot/imported/`, `user://`, or +images/textures created at runtime. It permits unreferenced ZigCraft placeholder +textures because that pack intentionally includes assets reserved for future +blocks. `tools/icon_check.gd` remains display-only and is not a CI/headless +check. + +After adding or regenerating a shipped PNG, OGG, or font, run: + +```sh +redot --headless --import --path . +``` + +Then commit the corresponding `.import` metadata along with the source asset. diff --git a/autoload/game_config.gd b/autoload/game_config.gd index b788db1..9170ad8 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -7,7 +7,13 @@ const DEFAULT_SETTINGS := { "lod_mode": 0, "extreme_render_distance": false, "fov": 76.0, + # Kept as the migration source for settings.cfg files written before camera + # sensitivity was split into independent axes. "mouse_sensitivity": 0.0022, + "mouse_sensitivity_x": 0.0022, + "mouse_sensitivity_y": 0.0022, + "invert_y": false, + "reduced_motion": false, "fullscreen": false, "graphics_preset": 1, "master_volume": 0.9, @@ -18,6 +24,7 @@ const DEFAULT_SETTINGS := { "dynamic_resolution": false, "dynamic_resolution_target": 60, "input_bindings": {}, + "first_run_hints": {}, "ui_scale": 1.0, "text_scale": 1.0, "autosave_interval": 30, @@ -51,6 +58,8 @@ const LOD_MODE_BALANCED := 1 const LOD_MODE_NAMES := ["Full Detail", "Balanced LOD"] const AUTOSAVE_INTERVAL_VALUES := [0, 30, 60, 120, 300, 600] const AUTOSAVE_INTERVAL_NAMES := ["Off", "30 Seconds", "1 Minute", "2 Minutes", "5 Minutes", "10 Minutes"] +const MOUSE_SENSITIVITY_MIN := 0.0005 +const MOUSE_SENSITIVITY_MAX := 0.005 # 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 @@ -461,6 +470,23 @@ func input_move_hint() -> String: return compact +## First-run control cues are account-level settings rather than world state: +## controls are learned once, while a later Creative world can still introduce +## its Creative-only flight cue independently. +func is_first_run_hint_completed(hint: String) -> bool: + var completed: Dictionary = settings.get("first_run_hints", {}) + return bool(completed.get(hint, false)) + + +func complete_first_run_hint(hint: String) -> void: + if hint.is_empty() or is_first_run_hint_completed(hint): + return + var completed: Dictionary = settings.get("first_run_hints", {}) + completed[hint] = true + settings["first_run_hints"] = completed + save_settings() + + func reset_graphics_to_preset() -> void: graphics = GRAPHICS_PRESETS[get_graphics_preset()].duplicate() @@ -501,7 +527,27 @@ func get_fov() -> float: func get_mouse_sensitivity() -> float: - return float(settings.get("mouse_sensitivity", DEFAULT_SETTINGS["mouse_sensitivity"])) + return get_mouse_sensitivity_x() + + +## Backwards-compatible name for callers outside the project. New camera input +## should select the appropriate axis explicitly. +func get_mouse_sensitivity_x() -> float: + return clampf(float(settings.get("mouse_sensitivity_x", DEFAULT_SETTINGS["mouse_sensitivity_x"])), + MOUSE_SENSITIVITY_MIN, MOUSE_SENSITIVITY_MAX) + + +func get_mouse_sensitivity_y() -> float: + return clampf(float(settings.get("mouse_sensitivity_y", DEFAULT_SETTINGS["mouse_sensitivity_y"])), + MOUSE_SENSITIVITY_MIN, MOUSE_SENSITIVITY_MAX) + + +func is_invert_y() -> bool: + return bool(settings.get("invert_y", DEFAULT_SETTINGS["invert_y"])) + + +func is_reduced_motion() -> bool: + return bool(settings.get("reduced_motion", DEFAULT_SETTINGS["reduced_motion"])) func get_autosave_interval() -> float: @@ -547,6 +593,7 @@ func load_settings() -> void: if config.load(SETTINGS_PATH) == OK: for key in DEFAULT_SETTINGS.keys(): settings[key] = config.get_value("settings", key, settings[key]) + _migrate_camera_settings(config) reset_graphics_to_preset() var saved: Dictionary = config.get_value("graphics", "values", {}) for key in saved.keys(): @@ -558,12 +605,26 @@ func load_settings() -> void: settings["text_scale"] = nearest_option(settings.get("text_scale", DEFAULT_SETTINGS["text_scale"]), TEXT_SCALE_VALUES) settings["autosave_interval"] = nearest_option(settings.get("autosave_interval", DEFAULT_SETTINGS["autosave_interval"]), AUTOSAVE_INTERVAL_VALUES) _sanitize_input_bindings() + if typeof(settings.get("first_run_hints")) != TYPE_DICTIONARY: + settings["first_run_hints"] = {} apply_window_mode() apply_frame_pacing() apply_ui_scale() apply_input_bindings() +## Settings before the split only contain `mouse_sensitivity`. Copy the saved +## value into each missing axis, so an existing player's look speed is kept +## exactly rather than silently returning to the default. If a partially +## written settings file has one new axis, retain it and migrate only the other. +func _migrate_camera_settings(config: ConfigFile) -> void: + var legacy := float(config.get_value("settings", "mouse_sensitivity", DEFAULT_SETTINGS["mouse_sensitivity"])) + if not config.has_section_key("settings", "mouse_sensitivity_x"): + settings["mouse_sensitivity_x"] = legacy + if not config.has_section_key("settings", "mouse_sensitivity_y"): + settings["mouse_sensitivity_y"] = legacy + + ## Drops bindings for actions that no longer exist or keys that are not real, ## so a hand-edited or stale settings.cfg cannot leave the InputMap broken. func _sanitize_input_bindings() -> void: diff --git a/game/first_run_hints.gd b/game/first_run_hints.gd new file mode 100644 index 0000000..ff9a444 --- /dev/null +++ b/game/first_run_hints.gd @@ -0,0 +1,175 @@ +class_name FirstRunHints +extends Node + +const UIThemeScript := preload("res://ui/ui_theme.gd") + +## A one-at-a-time onboarding queue. Hints only advance while the player can +## act on them, and each completed cue is persisted by the host so returning +## players never receive the same tutorial toast again. + +const HINT_MOVEMENT := "movement" +const HINT_TARGETING := "targeting" +const HINT_INVENTORY := "inventory" +const HINT_FLY := "fly" +const HINT_DURATION := 7.0 +const HINT_GAP := 1.5 + +var completion_provider: Callable +var completion_recorder: Callable +var move_hint_provider: Callable +var key_label_provider: Callable +var presentation_allowed: Callable +var text_scale_provider: Callable + +var _hint_label: Label +var _active_hint := "" +var _remaining := 0.0 +var _gap_remaining := HINT_GAP +var _creative := false +var _was_flying := false + + +## Creates a HUD-owned label rather than sharing the gameplay status toast: +## actions such as mining and saving remain visible while a tutorial cue is up. +func initialize(hud_root: Control, styled: bool = true) -> void: + _hint_label = Label.new() + _hint_label.name = "FirstRunHint" + _hint_label.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + _hint_label.offset_left = -420.0 + _hint_label.offset_right = 420.0 + _hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _hint_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + if styled: + _hint_label.add_theme_font_override("font", UIThemeScript.font_semi()) + UIThemeScript.apply_font_size(_hint_label, 14) + _hint_label.add_theme_color_override("font_color", UIThemeScript.CYAN) + _hint_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8)) + _hint_label.add_theme_constant_override("shadow_offset_x", 2) + _hint_label.add_theme_constant_override("shadow_offset_y", 2) + _hint_label.hide() + hud_root.add_child(_hint_label) + apply_text_layout() + + +func set_creative(creative: bool) -> void: + _creative = creative + + +## Called from Main's normal process loop with only the live player context the +## queue needs. The host owns pause/modal/photo decisions through the callback. +func tick(delta: float, moving: bool, has_target: bool, flying: bool) -> void: + if not _can_present(): + if _hint_label != null: + _hint_label.hide() + _was_flying = flying + return + if moving: + _complete(HINT_MOVEMENT) + if flying and not _was_flying: + _complete(HINT_FLY) + _was_flying = flying + if not _active_hint.is_empty(): + _refresh_active_text() + _remaining -= delta + if _remaining <= 0.0: + _complete(_active_hint) + return + _gap_remaining = maxf(_gap_remaining - delta, 0.0) + if _gap_remaining <= 0.0: + _show_next(has_target) + + +## Completing an action before its prompt appears is still useful knowledge; +## skip that prompt rather than showing it after the player has already used it. +func observe_target_action() -> void: + _complete(HINT_TARGETING) + + +func observe_inventory_opened() -> void: + _complete(HINT_INVENTORY) + + +func apply_text_layout() -> void: + if _hint_label == null: + return + var scale: float = float(text_scale_provider.call()) if text_scale_provider.is_valid() else UIThemeScript.text_scale() + _hint_label.offset_top = -158.0 - 22.0 * scale + _hint_label.offset_bottom = -158.0 + + +func active_hint() -> String: + return _active_hint + + +func hint_text() -> String: + return _hint_label.text if _hint_label != null else "" + + +func is_hint_visible() -> bool: + return _hint_label != null and _hint_label.visible + + +func _show_next(has_target: bool) -> void: + var hint := _next_hint(has_target) + if hint.is_empty(): + return + _active_hint = hint + _remaining = HINT_DURATION + _refresh_active_text() + _hint_label.show() + + +func _next_hint(has_target: bool) -> String: + if not _is_complete(HINT_MOVEMENT): + return HINT_MOVEMENT + if not _is_complete(HINT_TARGETING) and has_target: + return HINT_TARGETING + if not _is_complete(HINT_INVENTORY) and _is_complete(HINT_TARGETING): + return HINT_INVENTORY + if _creative and not _is_complete(HINT_FLY) and _is_complete(HINT_INVENTORY): + return HINT_FLY + return "" + + +func _refresh_active_text() -> void: + if _hint_label == null: + return + match _active_hint: + HINT_MOVEMENT: + _hint_label.text = "Move with %s" % _move_hint() + HINT_TARGETING: + _hint_label.text = "Target acquired · LMB mine · RMB place" + HINT_INVENTORY: + _hint_label.text = "%s opens your inventory" % _key_label("inventory") + HINT_FLY: + _hint_label.text = "Double-tap %s to toggle flight" % _key_label("jump") + + +func _complete(hint: String) -> void: + if hint.is_empty() or _is_complete(hint): + return + if completion_recorder.is_valid(): + completion_recorder.call(hint) + if _active_hint != hint: + return + _active_hint = "" + _remaining = 0.0 + _gap_remaining = HINT_GAP + if _hint_label != null: + _hint_label.hide() + + +func _is_complete(hint: String) -> bool: + return bool(completion_provider.call(hint)) if completion_provider.is_valid() else false + + +func _can_present() -> bool: + return bool(presentation_allowed.call()) if presentation_allowed.is_valid() else true + + +func _move_hint() -> String: + return String(move_hint_provider.call()) if move_hint_provider.is_valid() else "WASD" + + +func _key_label(action: String) -> String: + return String(key_label_provider.call(action)) if key_label_provider.is_valid() else action.capitalize() diff --git a/game/first_run_hints.gd.uid b/game/first_run_hints.gd.uid new file mode 100644 index 0000000..227a0d4 --- /dev/null +++ b/game/first_run_hints.gd.uid @@ -0,0 +1 @@ +uid://bp6ngxu8keif0 diff --git a/game/main.gd b/game/main.gd index 60fe8ad..bbb6bd3 100644 --- a/game/main.gd +++ b/game/main.gd @@ -6,6 +6,8 @@ const ShadowCaptureScript := preload("res://game/shadow_capture.gd") const WorldgenOverlayScene := preload("res://ui/worldgen_overlay.tscn") const MinimapScene := preload("res://ui/minimap.tscn") const MapOverlayScene := preload("res://ui/map_overlay.tscn") +const FirstRunHintsScript := preload("res://game/first_run_hints.gd") +const LoadingOverlayScript := preload("res://ui/loading_overlay.gd") const INITIAL_INVENTORY := {1: 64, 2: 64, 3: 64, 4: 64, 5: 32, 6: 32, 7: 32, 8: 32, 9: 16, 10: 32, 27: 32, BlockRegistry.BLOCK_TNT: 16, BlockRegistry.BLOCK_NUKE: 4, ItemRegistry.ITEM_FLINT_AND_STEEL: 1} const STATS_INTERVAL := 0.25 @@ -64,6 +66,7 @@ var _cave_biome := BiomeCatalog.CAVE_BIOME_NONE var _cave_tint := Color("#242936") var _worldgen_overlay: WorldgenOverlay var _photo_mode: PhotoMode +var _first_run_hints var _minimap: Minimap var _map_overlay: MapOverlay var _minimap_restore := false @@ -77,6 +80,11 @@ var _world_storage: WorldStorage var _autosave_time := 0.0 var _storage_warning_shown := false var _game_mode: int = GameMode.SURVIVAL +var _loading_overlay: LoadingOverlay +var _world_entry_waiting := false +var _world_entry_resumed := false +var _streaming_progress: Dictionary = {} +var _pending_death_cause := "" func _ready() -> void: @@ -102,24 +110,12 @@ func _ready() -> void: var player_state: Variant = saved_state.get("player", {}) var resumed := typeof(player_state) == TYPE_DICTIONARY \ and player.restore_persistent_state(player_state) - 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.velocity = Vector3.ZERO - else: + if not resumed: 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) + _begin_world_entry(resumed) _worldgen_overlay = WorldgenOverlayScene.instantiate() as WorldgenOverlay add_child(_worldgen_overlay) _worldgen_overlay.initialize(world, player) @@ -150,7 +146,6 @@ func _ready() -> void: _inventory_overlay.configure_inventory(inventory, world) _inventory_overlay.set_game_mode(_game_mode) _update_inventory_display() - _show_control_hint() var shadow_capture := ShadowCaptureScript.new() shadow_capture.name = "ShadowCapture" shadow_capture.state_provider = _get_shadow_capture_state @@ -159,12 +154,16 @@ func _ready() -> void: _photo_mode = PhotoMode.new() _photo_mode.name = "PhotoMode" _photo_mode.initialize(player.camera, _hud_root) - _photo_mode.mouse_sensitivity_provider = GameConfig.get_mouse_sensitivity + _photo_mode.mouse_sensitivity_x_provider = GameConfig.get_mouse_sensitivity_x + _photo_mode.mouse_sensitivity_y_provider = GameConfig.get_mouse_sensitivity_y + _photo_mode.invert_y_provider = GameConfig.is_invert_y _photo_mode.status_requested.connect(set_status) _photo_mode.camera_mode_changed.connect(_on_photo_camera_changed) + _photo_mode.set_process_unhandled_input(not _world_entry_waiting) add_child(_photo_mode) + _build_first_run_hints() if player.dead: - _on_player_died("Saved expedition") + _pending_death_cause = "Saved expedition" func _get_shadow_capture_state() -> Dictionary: @@ -221,10 +220,15 @@ func _get_shadow_capture_state() -> Dictionary: func _exit_tree() -> void: _flush_world_save() + GameConfig.save_settings() get_tree().paused = false func _unhandled_input(event: InputEvent) -> void: + if _loading_overlay != null and is_instance_valid(_loading_overlay) \ + and _loading_overlay.visible and _loading_overlay.mouse_filter != Control.MOUSE_FILTER_IGNORE: + get_viewport().set_input_as_handled() + return # Photo mode owns its keys and promises a clean view; leave the minimap, # debug overlay, and modal map for after the camera is dismissed. if _photo_mode != null and _photo_mode.is_camera_active(): @@ -275,6 +279,10 @@ func _process(delta: float) -> void: if _stats_time <= 0.0: _stats_time = STATS_INTERVAL _update_stats() + if _first_run_hints != null: + _first_run_hints.tick(delta, + Input.get_vector("move_left", "move_right", "move_forward", "move_backward").length_squared() > 0.01, + player.has_target, player.flying) ## Grades the overlay, fog, caustics, and audio from the submerged state. The @@ -534,7 +542,6 @@ func _build_pause_menu() -> void: _pause_menu = PauseMenuScene.instantiate() as PauseMenu add_child(_pause_menu) _pause_menu.setting_changed.connect(_on_setting_changed) - _pause_menu.settings_closed.connect(_show_control_hint) _pause_menu.new_world_requested.connect(_on_new_world) _pause_menu.quit_requested.connect(_on_quit_game) @@ -590,6 +597,8 @@ func _apply_hud_text_layout() -> void: if _status_label != null: _status_label.offset_top = -112.0 - 22.0 * scale _status_label.offset_bottom = -112.0 + if _first_run_hints != null: + _first_run_hints.apply_text_layout() func _build_crosshair() -> void: @@ -761,6 +770,8 @@ func _on_inventory_opened() -> void: _inventory_overlay.close_panel() return player.cancel_mining() + if _first_run_hints != null: + _first_run_hints.observe_inventory_opened() if _map_overlay != null and _map_overlay.visible: _map_overlay.close() _inventory_overlay.set_weather_state(_weather.is_raining()) @@ -872,6 +883,7 @@ func _on_setting_changed(key: String, value: Variant) -> void: func _on_new_world() -> void: _flush_world_save() + GameConfig.save_settings() GameConfig.clear_active_world() get_tree().paused = false get_tree().change_scene_to_file("res://ui/main_menu.tscn") @@ -888,6 +900,8 @@ func _on_slot_cycled(direction: int) -> void: func _on_mined_block(position: Vector3i, block_id: int, harvest: bool) -> void: + if _first_run_hints != null: + _first_run_hints.observe_target_action() if _game_mode == GameMode.SURVIVAL: inventory.wear_tool(selected_slot) _drop_container_contents(position) @@ -899,6 +913,8 @@ func _on_mined_block(position: Vector3i, block_id: int, harvest: bool) -> void: func _on_block_placed(block_id: int) -> void: + if _first_run_hints != null: + _first_run_hints.observe_target_action() consume_selected_block() set_status("Placed %s" % world.get_block_name(block_id)) @@ -1089,17 +1105,78 @@ func set_status(message: String) -> void: Motion.fade_in(_status_label) -## Startup/rebind toast for the core controls; called again when the pause -## menu's Settings close so a mid-game remap is reflected. -func _show_control_hint() -> void: - var jump_hint := "double-tap %s to fly" if _game_mode == GameMode.CREATIVE else "%s jump" - set_status("%s move %s %s inventory %s map %s minimap ESC pause" % [ - GameConfig.input_move_hint(), - jump_hint % GameConfig.input_key("jump"), - GameConfig.input_key("inventory"), - GameConfig.input_key("map_overlay"), - GameConfig.input_key("minimap"), - ]) +## Configures the small onboarding node without coupling its sequencing to the +## large world/session controller. The callback keeps hints dormant under every +## paused modal, a hidden HUD, death, and the detached photo camera. +func _build_first_run_hints() -> void: + _first_run_hints = FirstRunHintsScript.new() + _first_run_hints.completion_provider = GameConfig.is_first_run_hint_completed + _first_run_hints.completion_recorder = GameConfig.complete_first_run_hint + _first_run_hints.move_hint_provider = GameConfig.input_move_hint + _first_run_hints.key_label_provider = GameConfig.input_key + _first_run_hints.presentation_allowed = func() -> bool: + return not _world_entry_waiting and not get_tree().paused and _hud_root.visible and not player.dead \ + and (_photo_mode == null or not _photo_mode.is_camera_active()) + _first_run_hints.set_creative(_game_mode == GameMode.CREATIVE) + _first_run_hints.initialize(_hud_root) + add_child(_first_run_hints) + + +## Present streaming feedback before the player can move. VoxelWorld owns all +## worker scheduling and scene-node commits; Main only observes its main-thread +## snapshots and unlocks input after the authoritative spawn collision ring. +func _begin_world_entry(resumed: bool) -> void: + _world_entry_waiting = true + _world_entry_resumed = resumed + player.set_loading_locked(true) + _loading_overlay = LoadingOverlayScript.new() as LoadingOverlay + $HUD.add_child(_loading_overlay) + world.stream_progress_changed.connect(_on_stream_progress_changed) + world.initial_stream_ready.connect(_on_initial_stream_ready) + world.begin_initial_stream(player) + + +func _on_stream_progress_changed(progress: Dictionary) -> void: + _streaming_progress = progress + if _loading_overlay != null and is_instance_valid(_loading_overlay): + _loading_overlay.set_progress(progress) + + +func _on_initial_stream_ready() -> void: + if not _world_entry_waiting: + return + _world_entry_waiting = false + if _world_entry_resumed: + # Saves can capture a player after an interrupted stream. Validate only + # after async local terrain and collision are authoritative. + if not world.is_player_volume_clear(player.global_position): + player.global_position = world.find_safe_spawn(player.global_position) + player.velocity = Vector3.ZERO + else: + var spawn := world.find_safe_spawn(player.global_position) + player.global_position = spawn + player.spawn_position = spawn + world.setup_player(player) + player.set_loading_locked(false) + if _photo_mode != null: + _photo_mode.set_process_unhandled_input(true) + var progress := world.get_streaming_progress() + _on_stream_progress_changed(progress) + if not _pending_death_cause.is_empty(): + if _loading_overlay != null and is_instance_valid(_loading_overlay): + _loading_overlay.queue_free() + _loading_overlay = null + var cause := _pending_death_cause + _pending_death_cause = "" + _on_player_died(cause) + return + if _loading_overlay != null and is_instance_valid(_loading_overlay): + _loading_overlay.complete() + if bool(progress.get("streaming", false)): + set_status("World ready · streaming %d / %d chunks" % [ + int(progress.get("loaded", 0)), int(progress.get("total", 0))]) + else: + set_status("World ready") func _update_camera_far(render_distance: int) -> void: @@ -1120,8 +1197,18 @@ func _update_stats() -> void: coords.x, coords.y, coords.z, location_name, ] - _stats_label.text = "%d FPS\n%d chunks\n%s" % [ + var stream := _streaming_progress + if stream.is_empty(): + stream = world.get_streaming_progress() + var stream_line := "" + if bool(stream.get("streaming", false)): + var total := maxi(int(stream.get("total", 0)), 1) + var loaded := clampi(int(stream.get("loaded", 0)), 0, total) + stream_line = "\nStream %d/%d %d%%" % [ + loaded, total, roundi(float(loaded) / float(total) * 100.0)] + _stats_label.text = "%d FPS · %d chunks\n%s%s" % [ Engine.get_frames_per_second(), world.get_loaded_chunk_count(), _day_night.get_clock_text(), + stream_line, ] diff --git a/game/photo_mode.gd b/game/photo_mode.gd index 78504f2..3f0645c 100644 --- a/game/photo_mode.gd +++ b/game/photo_mode.gd @@ -8,7 +8,7 @@ extends Node ## ## Main listens to camera_mode_changed to move world streaming, weather, and ## player control with the active camera; this node only owns presentation and -## input. mouse_sensitivity_provider is injected so the node stays free of +## input. Camera setting providers are injected so the node stays free of ## autoloads and can be exercised headlessly. signal status_requested(message: String) @@ -24,7 +24,9 @@ const FOV_STEP := 2.0 const PITCH_LIMIT := 1.553343 const DEFAULT_SENSITIVITY := 0.0025 -var mouse_sensitivity_provider: Callable = Callable() +var mouse_sensitivity_x_provider: Callable = Callable() +var mouse_sensitivity_y_provider: Callable = Callable() +var invert_y_provider: Callable = Callable() var _player_camera: Camera3D var _hud_root: Control @@ -119,14 +121,24 @@ func _apply_free_movement(direction: Vector3, boost: float, delta: float) -> voi func _look(relative: Vector2) -> void: if _camera == null: return - var sensitivity := DEFAULT_SENSITIVITY - if mouse_sensitivity_provider.is_valid(): - sensitivity = float(mouse_sensitivity_provider.call()) - _yaw = wrapf(_yaw - relative.x * sensitivity, -PI, PI) - _pitch = clampf(_pitch - relative.y * sensitivity, -PITCH_LIMIT, PITCH_LIMIT) + var horizontal_sensitivity := _sensitivity_from(mouse_sensitivity_x_provider) + var vertical_sensitivity := _sensitivity_from(mouse_sensitivity_y_provider) + var vertical_direction := 1.0 if _is_invert_y() else -1.0 + _yaw = wrapf(_yaw - relative.x * horizontal_sensitivity, -PI, PI) + _pitch = clampf(_pitch + relative.y * vertical_sensitivity * vertical_direction, -PITCH_LIMIT, PITCH_LIMIT) _camera.rotation = Vector3(_pitch, _yaw, 0.0) +func _sensitivity_from(provider: Callable) -> float: + if provider.is_valid(): + return float(provider.call()) + return DEFAULT_SENSITIVITY + + +func _is_invert_y() -> bool: + return bool(invert_y_provider.call()) if invert_y_provider.is_valid() else false + + func _zoom(amount: float) -> void: if _camera == null: return diff --git a/player/player.gd b/player/player.gd index adf68db..e2d0c28 100644 --- a/player/player.gd +++ b/player/player.gd @@ -61,6 +61,10 @@ var target_normal := Vector3i.UP var has_target := false var spawn_position := Vector3.ZERO var flying := false +## World entry holds movement and gameplay shortcuts until nearby authoritative +## collision has committed. Camera setup remains active so the loading overlay +## can use the normal gameplay view without a second scene. +var loading_locked := false var base_fov := 76.0 var pitch_limit := deg_to_rad(85.0) var _last_jump_time := -10.0 @@ -100,6 +104,15 @@ func setup_world(world_node: VoxelWorld) -> void: _update_held_block() +func set_loading_locked(locked: bool) -> void: + loading_locked = locked + if locked: + _reset_survival_motion() + has_target = false + if _highlight != null: + _highlight.hide() + + func set_selected_block(block_id: int) -> void: if selected_block != block_id: cancel_mining() @@ -240,7 +253,7 @@ func set_photo_mode(enabled: bool) -> void: func _unhandled_input(event: InputEvent) -> void: - if dead: + if dead or loading_locked: return if event.is_action_pressed("pick_block") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _pick_target() @@ -250,9 +263,7 @@ func _unhandled_input(event: InputEvent) -> void: cancel_mining() return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: - var sensitivity := GameConfig.get_mouse_sensitivity() - rotation.y -= event.relative.x * sensitivity - head.rotation.x = clampf(head.rotation.x - event.relative.y * sensitivity, -pitch_limit, pitch_limit) + _apply_mouse_look(event.relative) return if event is InputEventMouseButton and event.pressed: @@ -291,8 +302,18 @@ func _unhandled_input(event: InputEvent) -> void: cancel_mining() +## Kept separate from the event route so camera response stays testable without +## requiring a captured desktop mouse in a headless harness. +func _apply_mouse_look(relative: Vector2) -> void: + var horizontal_sensitivity := GameConfig.get_mouse_sensitivity_x() + var vertical_sensitivity := GameConfig.get_mouse_sensitivity_y() + var vertical_direction := 1.0 if GameConfig.is_invert_y() else -1.0 + rotation.y -= relative.x * horizontal_sensitivity + head.rotation.x = clampf(head.rotation.x + relative.y * vertical_sensitivity * vertical_direction, -pitch_limit, pitch_limit) + + func _physics_process(delta: float) -> void: - if dead: + if dead or loading_locked: return if global_position.y < FALL_RESET_Y: if game_mode == GameMode.CREATIVE: diff --git a/tools/UNIT_TESTS.md b/tools/UNIT_TESTS.md new file mode 100644 index 0000000..db4f71a --- /dev/null +++ b/tools/UNIT_TESTS.md @@ -0,0 +1,31 @@ +# Pure GDScript unit tests + +The lightweight unit suite has no external framework dependency. It runs pure +worldgen, registry, water, mesher, and AO logic in one headless Redot process. + +After a fresh clone, or after adding a new `class_name` test script, refresh +Redot's generated script-class cache once (CI does this in `setup-redot`): + +```bash +redot --headless --import --path . +``` + +Run the suite: + +```bash +redot --headless --path . --script res://tools/unit_tests.gd +``` + +The runner reports every failed assertion before returning a non-zero exit code. +Keep fixtures deterministic and use the existing focused `tools/*_verify.gd` +checks for scene, streaming, storage, or rendering integration coverage. + +Current inventory: + +- `worldgen_unit_test.gd`: fixed 1D/2D/3D hash vectors, negative-coordinate + division/range behavior, deterministic padded terrain-field samples, and + pinned field height/biome/river fixtures. +- `block_registry_unit_test.gd`: contiguous persistent byte IDs, runtime + texture-array layer packing, and source/flowing-water level inverses. +- `mesher_unit_test.gd`: water-height curve, AO sample offsets, cube-face + culling, and three-neighbor AO shading. diff --git a/tools/asset_license_verify.gd b/tools/asset_license_verify.gd new file mode 100644 index 0000000..5fad59b --- /dev/null +++ b/tools/asset_license_verify.gd @@ -0,0 +1,321 @@ +## Deterministic, display-free audit of shipped binary assets and attribution. +## Run: redot --headless --path . --script res://tools/asset_license_verify.gd +## +## This reads source metadata rather than instantiating render resources or +## project classes, which keeps it safe for headless CI. See assets/README.md. +extends SceneTree + +const BLOCK_REGISTRY_SOURCE := "res://world/block_registry.gd" +const AUDIO_MANAGER_SOURCE := "res://autoload/audio_manager.gd" +const UI_THEME_SOURCE := "res://ui/ui_theme.gd" +const FIRE_OVERLAY_SOURCE := "res://world/fire_overlay.gd" +const TEXTURE_DIRECTORIES := [ + "res://assets/placeholders/zigcraft/default", + "res://assets/placeholders/underwater", + "res://assets/placeholders/explosives", + "res://assets/placeholders/caves", + "res://assets/placeholders/fire", + "res://assets/placeholders/functional", +] +const AUDIO_DIRECTORIES := ["res://assets/audio/sfx", "res://assets/audio/ambient"] +const REQUIRED_DOCUMENTS := [ + "res://assets/audio/README.md", + "res://assets/audio/LICENSE-voxelibre-mcl-sounds.txt", + "res://assets/audio/LICENSE-kenney-ui-audio.txt", + "res://assets/fonts/README.md", + "res://assets/fonts/OFL.txt", + "res://assets/placeholders/zigcraft/README.md", + "res://assets/placeholders/underwater/README.md", + "res://assets/placeholders/explosives/README.md", + "res://assets/placeholders/caves/README.md", + "res://assets/placeholders/fire/README.md", + "res://assets/placeholders/functional/README.md", +] + +var _failures := 0 +var _snake_case: RegEx + + +func _initialize() -> void: + _snake_case = RegEx.new() + _snake_case.compile("^[a-z0-9]+(?:_[a-z0-9]+)*\\.(png|ogg)$") + process_frame.connect(_verify, CONNECT_ONE_SHOT) + + +func _verify() -> void: + _verify_required_documents() + _verify_texture_sources() + _verify_block_texture_references() + _verify_fonts() + _verify_audio_bank() + _verify_fire_overlay_references() + if _failures == 0: + print("ASSET LICENSE VERIFY: PASS") + quit(0) + return + print("ASSET LICENSE VERIFY: FAIL (%d)" % _failures) + quit(1) + + +func _verify_required_documents() -> void: + for path: String in REQUIRED_DOCUMENTS: + if not FileAccess.file_exists(path): + _fail("missing required license or attribution file: %s" % path) + continue + if FileAccess.get_file_as_string(path).strip_edges().is_empty(): + _fail("required license or attribution file is empty: %s" % path) + + +func _verify_texture_sources() -> void: + for directory: String in TEXTURE_DIRECTORIES: + if not _directory_exists(directory): + _fail("missing texture directory: %s" % directory) + continue + for filename: String in DirAccess.get_files_at(directory): + var path := directory.path_join(filename) + if filename.ends_with(".png"): + if _snake_case.search(filename) == null: + _fail("texture filename must be lowercase snake_case: %s" % path) + _verify_import(path, "texture") + elif not (filename.ends_with(".png.import") or filename == "README.md"): + _fail("unexpected texture-directory file: %s (only .png sources, .import metadata, and README.md belong here)" % path) + + +func _verify_block_texture_references() -> void: + var source := _read_source(BLOCK_REGISTRY_SOURCE) + if source.is_empty(): + return + var texture_root := _string_constant(source, "TEXTURE_ROOT") + var extra_paths := _extra_texture_paths(source) + var runtime_textures := _runtime_texture_names(source) + var referenced := {} + for texture_name: String in _block_texture_names(source): + if referenced.has(texture_name): + continue + referenced[texture_name] = true + if runtime_textures.has(texture_name): + continue + var path: String = extra_paths.get(texture_name, texture_root + texture_name) + _verify_referenced_texture(path, "BlockRegistry texture %s" % texture_name) + + var water_shader := _string_constant(source, "WATER_SHADER_PATH") + if water_shader.is_empty() or not FileAccess.file_exists(water_shader): + _fail("BlockRegistry water shader reference is missing: %s" % water_shader) + + +func _verify_fonts() -> void: + var source := _read_source(UI_THEME_SOURCE) + if source.is_empty(): + return + var expected := {} + var font_pattern := _regex("const\\s+FONT_[A-Z_]+PATH\\s*:=\\s*\"([^\"]+\\.ttf)\"") + for match in font_pattern.search_all(source): + var path: String = match.get_string(1) + expected[path.get_file()] = true + _verify_import(path, "font_data_dynamic") + + if expected.is_empty(): + _fail("no UI font references found in %s" % UI_THEME_SOURCE) + return + var font_directory := "res://assets/fonts" + if not _directory_exists(font_directory): + _fail("missing UI font directory: %s" % font_directory) + return + for filename: String in DirAccess.get_files_at(font_directory): + if filename.ends_with(".ttf") and not expected.has(filename): + _fail("unreferenced font source: %s (add a UITheme reference or remove it)" % font_directory.path_join(filename)) + elif not (filename.ends_with(".ttf") or filename.ends_with(".ttf.import") or filename == "README.md" or filename == "OFL.txt"): + _fail("unexpected font-directory file: %s" % font_directory.path_join(filename)) + + +func _verify_audio_bank() -> void: + var source := _read_source(AUDIO_MANAGER_SOURCE) + if source.is_empty(): + return + var sfx_directory := _string_constant(source, "SFX_DIR") + var ambient_directory := _string_constant(source, "AMBIENT_DIR") + var expected := {} + for material: String in _string_array_constant(source, "MATERIALS"): + for variation in range(_int_constant(source, "FOOTSTEP_VARIATIONS")): + expected["%s/footstep_%s_%d.ogg" % [sfx_directory, material, variation + 1]] = true + for variation in range(_int_constant(source, "BREAK_VARIATIONS")): + expected["%s/block_break_%d.ogg" % [sfx_directory, variation + 1]] = true + for variation in range(_int_constant(source, "PLACE_VARIATIONS")): + expected["%s/block_place_%d.ogg" % [sfx_directory, variation + 1]] = true + for kind: String in _string_array_constant(source, "UI_SOUNDS"): + expected["%s/ui_%s.ogg" % [sfx_directory, kind]] = true + for filename: String in _audio_filenames(source): + expected[ambient_directory.path_join(filename)] = true + + if expected.is_empty(): + _fail("no expected audio cues could be read from %s" % AUDIO_MANAGER_SOURCE) + return + for path: String in expected: + if _snake_case.search(path.get_file()) == null: + _fail("audio filename must be lowercase snake_case: %s" % path) + _verify_import(path, "oggvorbisstr") + + for directory: String in AUDIO_DIRECTORIES: + if not _directory_exists(directory): + _fail("missing audio directory: %s" % directory) + continue + for filename: String in DirAccess.get_files_at(directory): + var path := directory.path_join(filename) + if filename.ends_with(".ogg"): + if not expected.has(path): + _fail("unexpected audio cue: %s (add it to AudioManager or remove it)" % path) + elif not filename.ends_with(".ogg.import"): + _fail("unsupported audio source or metadata: %s (only recorded .ogg cues are allowed)" % path) + + +func _verify_fire_overlay_references() -> void: + var source := _read_source(FIRE_OVERLAY_SOURCE) + if source.is_empty(): + return + var pattern := _regex("preload\\(\"(res://assets/[^\"]+)\"\\)") + for match in pattern.search_all(source): + _verify_referenced_texture(match.get_string(1), "FireOverlay") + + +func _block_texture_names(source: String) -> Array[String]: + var start := source.find("const BLOCK_DEFS := [") + var finish := source.find("]\n\nconst TEXTURE_ROOT", start) + if start == -1 or finish == -1: + _fail("could not read BLOCK_DEFS from %s" % BLOCK_REGISTRY_SOURCE) + return [] + return _quoted_pngs(source.substr(start, finish - start)) + + +func _extra_texture_paths(source: String) -> Dictionary: + var paths := {} + var start := source.find("const EXTRA_TEXTURE_PATHS := {") + var finish := source.find("}\nconst WATER_TEXTURE_PATH", start) + if start == -1 or finish == -1: + _fail("could not read EXTRA_TEXTURE_PATHS from %s" % BLOCK_REGISTRY_SOURCE) + return paths + var pattern := _regex("\"([^\"]+\\.png)\":\\s*([A-Z_]+)\\s*\\+\\s*\"([^\"]+\\.png)\"") + for match in pattern.search_all(source.substr(start, finish - start)): + var root := _string_constant(source, match.get_string(2)) + if root.is_empty(): + _fail("unknown texture root %s in EXTRA_TEXTURE_PATHS" % match.get_string(2)) + continue + if match.get_string(1) != match.get_string(3): + _fail("mismatched EXTRA_TEXTURE_PATHS key and filename: %s" % match.get_string(0)) + paths[match.get_string(1)] = root + match.get_string(3) + return paths + + +func _runtime_texture_names(source: String) -> Dictionary: + var names := {} + var start := source.find("func _load_image(") + if start == -1: + return names + var pattern := _regex("path\\s*==\\s*TEXTURE_ROOT\\s*\\+\\s*\"([^\"]+\\.png)\"") + for match in pattern.search_all(source.substr(start)): + names[match.get_string(1)] = true + return names + + +func _audio_filenames(source: String) -> Array[String]: + var names: Array[String] = [] + var section_start := source.find("const AMBIENT_BEDS :=") + var section_end := source.find("const SURFACE_CUTOFF_HZ", section_start) + if section_start == -1 or section_end == -1: + _fail("could not read ambient audio references from %s" % AUDIO_MANAGER_SOURCE) + return names + return _quoted_oggs(source.substr(section_start, section_end - section_start)) + + +func _string_array_constant(source: String, name: String) -> Array[String]: + var pattern := _regex("const\\s+%s[^\\[]*\\[([^\\]]*)\\]" % name) + var match := pattern.search(source) + if match == null: + _fail("could not read %s from %s" % [name, AUDIO_MANAGER_SOURCE]) + return [] + return _quoted_strings(match.get_string(1)) + + +func _string_constant(source: String, name: String) -> String: + var pattern := _regex("const\\s+%s\\s*:=\\s*\"([^\"]+)\"" % name) + var match := pattern.search(source) + if match == null: + _fail("could not read %s from source" % name) + return "" + return match.get_string(1) + + +func _int_constant(source: String, name: String) -> int: + var pattern := _regex("const\\s+%s\\s*:=\\s*(\\d+)" % name) + var match := pattern.search(source) + if match == null: + _fail("could not read %s from %s" % [name, AUDIO_MANAGER_SOURCE]) + return 0 + return match.get_string(1).to_int() + + +func _quoted_pngs(text: String) -> Array[String]: + var names: Array[String] = [] + for match in _regex("\"([^\"]+\\.png)\"").search_all(text): + names.append(match.get_string(1)) + return names + + +func _quoted_oggs(text: String) -> Array[String]: + var names: Array[String] = [] + for match in _regex("\"([^\"]+\\.ogg)\"").search_all(text): + names.append(match.get_string(1)) + return names + + +func _quoted_strings(text: String) -> Array[String]: + var names: Array[String] = [] + for match in _regex("\"([^\"]+)\"").search_all(text): + names.append(match.get_string(1)) + return names + + +func _verify_referenced_texture(path: String, owner: String) -> void: + if not FileAccess.file_exists(path): + _fail("%s references a missing texture: %s" % [owner, path]) + return + _verify_import(path, "texture") + + +func _verify_import(path: String, importer: String) -> void: + if not FileAccess.file_exists(path): + _fail("missing asset source: %s" % path) + return + var import_path := path + ".import" + if not FileAccess.file_exists(import_path): + _fail("missing import metadata: %s (run `redot --headless --import --path .` and commit the generated .import file)" % import_path) + return + var metadata := FileAccess.get_file_as_string(import_path) + if not metadata.contains("importer=\"%s\"" % importer): + _fail("wrong importer in %s: expected %s; reimport %s" % [import_path, importer, path]) + if not metadata.contains("source_file=\"%s\"" % path): + _fail("stale import metadata in %s: source_file must reference %s; reimport it" % [import_path, path]) + if not ResourceLoader.exists(path): + _fail("asset is not available through the headless import cache: %s (run `redot --headless --import --path .`)" % path) + + +func _read_source(path: String) -> String: + if not FileAccess.file_exists(path): + _fail("missing source file used for asset references: %s" % path) + return "" + return FileAccess.get_file_as_string(path) + + +func _directory_exists(path: String) -> bool: + return DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(path)) + + +func _regex(pattern: String) -> RegEx: + var regex := RegEx.new() + if regex.compile(pattern) != OK: + _fail("invalid checker regex: %s" % pattern) + return regex + + +func _fail(message: String) -> void: + _failures += 1 + push_error("asset_license_verify: %s" % message) diff --git a/tools/asset_license_verify.gd.uid b/tools/asset_license_verify.gd.uid new file mode 100644 index 0000000..06369ee --- /dev/null +++ b/tools/asset_license_verify.gd.uid @@ -0,0 +1 @@ +uid://c2hba4q0hh64d diff --git a/tools/block_physics_verify.gd b/tools/block_physics_verify.gd new file mode 100644 index 0000000..ea0d0dc --- /dev/null +++ b/tools/block_physics_verify.gd @@ -0,0 +1,182 @@ +## Headless regression check for persisted gravity reactions. +## Run: redot --headless --path . res://tools/block_physics_verify.tscn +extends Node + +var _failures := 0 +var _storage_root := "user://block_physics_verify_%d" % Time.get_ticks_usec() + + +func _ready() -> void: + _check_registry() + _check_fall_and_support() + _check_stacked_no_duplication_or_loss() + _check_chunk_edge_and_unloaded_cells() + _check_lod_safety() + _check_persistence() + if _failures == 0: + print("BLOCK PHYSICS VERIFY: PASS") + else: + print("BLOCK PHYSICS VERIFY: FAIL (%d)" % _failures) + get_tree().quit(1 if _failures > 0 else 0) + + +func _check_registry() -> void: + var registry := BlockRegistry.new() + _expect(registry.is_gravity_block(BlockRegistry.BLOCK_SAND), "sand is not a gravity block") + _expect(registry.is_gravity_block(BlockRegistry.BLOCK_RED_SAND), "red sand is not a gravity block") + _expect(registry.is_gravity_block(BlockRegistry.BLOCK_GRAVEL), "gravel is not a gravity block") + _expect(not registry.is_gravity_block(BlockRegistry.BLOCK_STONE), "stone became a gravity block") + + +func _check_fall_and_support() -> void: + var world := _make_world(4) + var sand := Vector3i(4, 8, 4) + _set_block(world._chunks[Vector2i.ZERO].data, sand, BlockRegistry.BLOCK_SAND) + world._queue_gravity(sand) + for step in 4: + world._gravity_tick() + _expect(world.get_block_world(Vector3i(4, 5, 4)) == BlockRegistry.BLOCK_SAND, + "sand did not settle on its stone support") + _expect(world.get_block_world(sand) == BlockRegistry.BLOCK_AIR, + "falling sand left a duplicate at its source") + _expect(world._edited_blocks.get(sand, -1) == BlockRegistry.BLOCK_AIR, + "sand source air was not persisted") + _expect(world._edited_blocks.get(Vector3i(4, 5, 4), -1) == BlockRegistry.BLOCK_SAND, + "sand destination was not persisted") + _expect(world._chunk_edit_version.get(Vector2i.ZERO, 0) == 3, + "one gravity move should batch source and destination into one invalidation") + world.free() + + world = _make_world(4) + var gravel := Vector3i(7, 5, 7) + _set_block(world._chunks[Vector2i.ZERO].data, gravel, BlockRegistry.BLOCK_GRAVEL) + world._queue_gravity(gravel) + world._gravity_tick() + _expect(world.get_block_world(gravel) == BlockRegistry.BLOCK_GRAVEL, + "gravel fell through a solid support") + _expect(world._edited_blocks.is_empty(), "supported gravel created a persistent edit") + world.free() + + +func _check_stacked_no_duplication_or_loss() -> void: + var world := _make_world(4) + var sand := Vector3i(5, 8, 5) + var gravel := sand + Vector3i.UP + _set_block(world._chunks[Vector2i.ZERO].data, sand, BlockRegistry.BLOCK_SAND) + _set_block(world._chunks[Vector2i.ZERO].data, gravel, BlockRegistry.BLOCK_GRAVEL) + # Only waking the lower block must eventually wake its upper neighbour. + world._queue_gravity(sand) + for step in 8: + world._gravity_tick() + _expect(world.get_block_world(Vector3i(5, 5, 5)) == BlockRegistry.BLOCK_SAND, + "lower stacked sand settled at the wrong height") + _expect(world.get_block_world(Vector3i(5, 6, 5)) == BlockRegistry.BLOCK_GRAVEL, + "upper stacked gravel was not woken after support changed") + _expect(_count_id_in_column(world, 5, 5, BlockRegistry.BLOCK_SAND) == 1, + "stacked fall duplicated or lost sand") + _expect(_count_id_in_column(world, 5, 5, BlockRegistry.BLOCK_GRAVEL) == 1, + "stacked fall duplicated or lost gravel") + world.free() + + +func _check_chunk_edge_and_unloaded_cells() -> void: + # Vertical motion at x=15 remains valid even when the adjacent x=16 chunk is + # not loaded. It must only edit its owner chunk. + var world := _make_world(4, false) + var edge_sand := Vector3i(VoxelDefs.CHUNK_SIZE - 1, 7, 3) + _set_block(world._chunks[Vector2i.ZERO].data, edge_sand, BlockRegistry.BLOCK_SAND) + world._queue_gravity(edge_sand) + for step in 3: + world._gravity_tick() + _expect(world.get_block_world(Vector3i(VoxelDefs.CHUNK_SIZE - 1, 5, 3)) == BlockRegistry.BLOCK_SAND, + "sand at a chunk edge did not fall inside its loaded owner") + _expect(world._edits_by_chunk.size() == 1 and world._edits_by_chunk.has(Vector2i.ZERO), + "edge fall wrote an edit into an unloaded neighbor") + world._queue_gravity(Vector3i(VoxelDefs.CHUNK_SIZE, 7, 3)) + world._gravity_tick() + _expect(world._gravity_queue.is_empty() and world._gravity_queued.is_empty(), + "unloaded gravity candidate was retained or generated a phantom block") + _expect(not world._edited_blocks.has(Vector3i(VoxelDefs.CHUNK_SIZE, 7, 3)), + "unloaded gravity candidate recorded an edit") + world.free() + + +func _check_lod_safety() -> void: + var world := _make_world(4, false) + var chunk: VoxelWorld.Chunk = world._chunks[Vector2i.ZERO] + var sand := Vector3i(4, 7, 4) + _set_block(chunk.data, sand, BlockRegistry.BLOCK_SAND) + chunk.lod = true + world._queue_gravity(sand) + world._gravity_tick() + _expect(chunk.data[_data_index(sand)] == BlockRegistry.BLOCK_SAND, + "gravity mutated compact LOD chunk data") + _expect(world._edited_blocks.is_empty(), "gravity persisted a mutation for LOD data") + world.free() + + +func _check_persistence() -> void: + var storage := WorldStorage.new(_storage_root) + _expect(not storage.create_world({}, {}, "gravity-world", false).is_empty(), + "could not create gravity persistence fixture") + var world := _make_world(4) + world.set_edit_store(storage) + var sand := Vector3i(3, 7, 3) + _set_block(world._chunks[Vector2i.ZERO].data, sand, BlockRegistry.BLOCK_SAND) + world._queue_gravity(sand) + for step in 3: + world._gravity_tick() + _expect(world.flush_edit_store() == OK, "could not flush gravity edits") + var reopened := WorldStorage.new(_storage_root) + _expect(not reopened.open_world("gravity-world").is_empty(), "could not reopen gravity fixture") + var edits := reopened.load_chunk_edits(Vector2i.ZERO) + _expect(edits.get(sand, -1) == BlockRegistry.BLOCK_AIR, + "saved gravity source did not replay as air") + _expect(edits.get(Vector3i(3, 5, 3), -1) == BlockRegistry.BLOCK_SAND, + "saved gravity destination did not replay as sand") + _expect(_count_id_in_column(world, 3, 3, BlockRegistry.BLOCK_SAND) == 1, + "persisted fall duplicated or lost sand before save") + world.free() + + +func _make_world(floor_y: int, add_neighbors := true) -> VoxelWorld: + var world := VoxelWorld.new() + world._blocks = BlockRegistry.new() + world._chunks[Vector2i.ZERO] = _make_chunk() + if add_neighbors: + for direction in VoxelDefs.DIRS_8: + world._chunks[direction] = _make_chunk() + var chunk: VoxelWorld.Chunk = world._chunks[Vector2i.ZERO] + for z in VoxelDefs.CHUNK_SIZE: + for x in VoxelDefs.CHUNK_SIZE: + _set_block(chunk.data, Vector3i(x, floor_y, z), BlockRegistry.BLOCK_STONE) + return world + + +func _make_chunk() -> VoxelWorld.Chunk: + var chunk := VoxelWorld.Chunk.new() + chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + return chunk + + +func _count_id_in_column(world: VoxelWorld, x: int, z: int, block_id: int) -> int: + var count := 0 + for y in VoxelDefs.WORLD_HEIGHT: + if world.get_block_world(Vector3i(x, y, z)) == block_id: + count += 1 + return count + + +func _data_index(position: Vector3i) -> int: + return position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y + + +func _set_block(data: PackedByteArray, position: Vector3i, block_id: int) -> void: + data[_data_index(position)] = block_id + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("block_physics_verify: %s" % message) diff --git a/tools/block_physics_verify.gd.uid b/tools/block_physics_verify.gd.uid new file mode 100644 index 0000000..ebc903c --- /dev/null +++ b/tools/block_physics_verify.gd.uid @@ -0,0 +1 @@ +uid://b873c8rqy1n5 diff --git a/tools/block_physics_verify.tscn b/tools/block_physics_verify.tscn new file mode 100644 index 0000000..980a38a --- /dev/null +++ b/tools/block_physics_verify.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/block_physics_verify.gd" id="1_physics"] + +[node name="BlockPhysicsVerify" type="Node"] +script = ExtResource("1_physics") diff --git a/tools/first_run_hints_verify.gd b/tools/first_run_hints_verify.gd new file mode 100644 index 0000000..c2ff204 --- /dev/null +++ b/tools/first_run_hints_verify.gd @@ -0,0 +1,100 @@ +## Focused headless regression check for the gradual first-run HUD hints. +## Run: redot --headless --path . --script res://tools/first_run_hints_verify.gd +extends SceneTree + +const FirstRunHintsScript := preload("res://game/first_run_hints.gd") + +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var completed := {} + var presentation := {"allowed": true} + var bindings := {"move": "Arrows", "inventory": "I", "jump": "J"} + var hud := Control.new() + root.add_child(hud) + var hints := _make_hints(hud, completed, bindings, func() -> bool: return bool(presentation["allowed"])) + + # Movement starts alone and uses the current rebound movement labels. + hints.tick(2.0, false, false, false) + _expect(hints.active_hint() == FirstRunHintsScript.HINT_MOVEMENT, "movement should be the first cue") + _expect(hints.hint_text() == "Move with Arrows", "movement cue must use the live move label") + hints.tick(0.1, true, false, false) + _expect(bool(completed.get(FirstRunHintsScript.HINT_MOVEMENT, false)), "movement action should complete its cue") + + # Targeting waits for a real target, freezes under a hidden HUD/modal, and + # then progresses to inventory once it has been shown or used. + hints.tick(2.0, false, false, false) + _expect(hints.active_hint().is_empty(), "targeting should wait for a target") + hints.tick(0.1, false, true, false) + _expect(hints.active_hint() == FirstRunHintsScript.HINT_TARGETING, "targeting should appear at a target") + presentation["allowed"] = false + hints.tick(FirstRunHintsScript.HINT_DURATION + 1.0, false, true, false) + _expect(not bool(completed.get(FirstRunHintsScript.HINT_TARGETING, false)), "hidden HUD must freeze hint completion") + _expect(not hints.is_hint_visible(), "hidden HUD must hide the active cue") + presentation["allowed"] = true + hints.observe_target_action() + _expect(bool(completed.get(FirstRunHintsScript.HINT_TARGETING, false)), "mining or placement should complete targeting") + hints.tick(2.0, false, false, false) + _expect(hints.active_hint() == FirstRunHintsScript.HINT_INVENTORY, "inventory should follow targeting") + _expect(hints.hint_text() == "I opens your inventory", "inventory cue must use the live binding") + hints.observe_inventory_opened() + _expect(bool(completed.get(FirstRunHintsScript.HINT_INVENTORY, false)), "opening inventory should complete its cue") + + # Flight is only queued in Creative and its rebound jump label updates while + # active, rather than keeping the label that was present at startup. + hints.set_creative(true) + hints.tick(2.0, false, false, false) + _expect(hints.active_hint() == FirstRunHintsScript.HINT_FLY, "Creative should receive the flight cue") + _expect(hints.hint_text() == "Double-tap J to toggle flight", "flight cue must use the live jump binding") + bindings["jump"] = "K" + hints.tick(0.1, false, false, false) + _expect(hints.hint_text() == "Double-tap K to toggle flight", "active flight cue must refresh after a rebind") + hints.tick(0.1, false, false, true) + _expect(bool(completed.get(FirstRunHintsScript.HINT_FLY, false)), "toggling flight should complete its cue") + + # Persisted completions prevent a second session from replaying the sequence. + var resumed := _make_hints(hud, completed, bindings, func() -> bool: return true) + resumed.set_creative(true) + resumed.tick(10.0, false, true, false) + _expect(resumed.active_hint().is_empty(), "completed hints must not replay") + + var survival_completed := { + FirstRunHintsScript.HINT_MOVEMENT: true, + FirstRunHintsScript.HINT_TARGETING: true, + FirstRunHintsScript.HINT_INVENTORY: true, + } + var survival := _make_hints(hud, survival_completed, bindings, func() -> bool: return true) + survival.tick(10.0, false, true, false) + _expect(survival.active_hint().is_empty(), "Survival must not show Creative flight guidance") + + if _failures == 0: + print("FIRST RUN HINTS VERIFY: PASS") + quit(0) + return + print("FIRST RUN HINTS VERIFY: FAIL (%d)" % _failures) + quit(1) + + +func _make_hints(hud: Control, completed: Dictionary, bindings: Dictionary, can_present: Callable) -> Node: + var hints := FirstRunHintsScript.new() + hints.completion_provider = func(hint: String) -> bool: return bool(completed.get(hint, false)) + hints.completion_recorder = func(hint: String) -> void: completed[hint] = true + hints.move_hint_provider = func() -> String: return String(bindings["move"]) + hints.key_label_provider = func(action: String) -> String: return String(bindings["jump"] if action == "jump" else bindings["inventory"]) + hints.presentation_allowed = can_present + hints.text_scale_provider = func() -> float: return 1.0 + hints.initialize(hud, false) + root.add_child(hints) + return hints + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("FIRST RUN HINTS VERIFY: %s" % message) diff --git a/tools/first_run_hints_verify.gd.uid b/tools/first_run_hints_verify.gd.uid new file mode 100644 index 0000000..13de4e1 --- /dev/null +++ b/tools/first_run_hints_verify.gd.uid @@ -0,0 +1 @@ +uid://wajcvhgkk5iy diff --git a/tools/loading_progress_verify.gd b/tools/loading_progress_verify.gd new file mode 100644 index 0000000..3e9a6f2 --- /dev/null +++ b/tools/loading_progress_verify.gd @@ -0,0 +1,54 @@ +## Focused headless check for the read-only world-entry progress presentation. +## Run: +## redot --headless --path . --script res://tools/loading_progress_verify.gd +extends SceneTree + +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_source := FileAccess.get_file_as_string("res://game/main.gd") + _expect(main_source.contains("_pending_death_cause = \"Saved expedition\"") + and main_source.contains("_photo_mode.set_process_unhandled_input(not _world_entry_waiting)") + and main_source.contains("_photo_mode.set_process_unhandled_input(true)"), + "world entry does not defer saved death or lock photo-mode input") + var overlay: Control = load("res://ui/loading_overlay.gd").new() + root.add_child(overlay) + await process_frame + overlay.set_progress({ + "loaded": 7, "total": 4225, "pending": 8, "queued": 4210, + "generated": 4, "spawn_collision": 3, "spawn_total": 9, + "initial_ready": false, "streaming": true, + }) + _expect(overlay.get_node("Center/Panel/Box/Phase").text == "Preparing a safe spawn", + "unsafe spawn phase was not shown") + _expect("3 / 9" in overlay.get_node("Center/Panel/Box/ProgressText").text, + "spawn collision progress missing") + overlay.set_progress({ + "loaded": 1234, "total": 4225, "pending": 8, "queued": 2000, + "generated": 6, "spawn_collision": 9, "spawn_total": 9, + "initial_ready": true, "streaming": true, + }) + _expect(overlay.get_node("Center/Panel/Box/Phase").text == "Spawn ready", + "ready spawn did not transition to background streaming") + _expect("1234 / 4225" in overlay.get_node("Center/Panel/Box/ProgressText").text, + "high-distance horizon progress missing") + overlay.complete() + _expect(overlay.mouse_filter == Control.MOUSE_FILTER_IGNORE, + "completed overlay still intercepts gameplay input") + if _failures > 0: + push_error("loading progress verifier failed: %d assertion(s)" % _failures) + quit(1) + else: + print("loading progress verifier passed") + quit(0) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures += 1 + push_error(message) diff --git a/tools/loading_progress_verify.gd.uid b/tools/loading_progress_verify.gd.uid new file mode 100644 index 0000000..1857c47 --- /dev/null +++ b/tools/loading_progress_verify.gd.uid @@ -0,0 +1 @@ +uid://dn5vvjqh6kc6t diff --git a/tools/motion_camera_verify.gd b/tools/motion_camera_verify.gd new file mode 100644 index 0000000..f364fbc --- /dev/null +++ b/tools/motion_camera_verify.gd @@ -0,0 +1,230 @@ +## Headless regression check for split camera controls and reduced UI motion. +## Run: +## redot --headless --path . res://tools/motion_camera_verify.tscn +## +## The check snapshots user://settings.cfg before exercising both the legacy +## sensitivity migration and current persistence, then restores it exactly. +extends Node + +const SETTINGS_PATH := "user://settings.cfg" + +var _failures := 0 +var _settings_existed := false +var _settings_backup := PackedByteArray() +var root: Window + + +func _ready() -> void: + call_deferred("_run") + + +func _run() -> void: + root = get_tree().root + var config: Node = root.get_node_or_null("GameConfig") + if config == null: + push_error("motion_camera_verify: GameConfig autoload is missing") + get_tree().quit(1) + return + _backup_settings_file() + _check_defaults_and_migration(config) + _check_current_persistence(config) + _check_player_camera_axes(config) + _check_photo_camera_axes() + await _check_settings_panel(config) + await _check_reduced_motion(config) + _restore_settings_file() + config.load_settings() + + if _failures == 0: + print("MOTION CAMERA VERIFY: PASS") + get_tree().quit(0) + return + print("MOTION CAMERA VERIFY: FAIL (%d)" % _failures) + get_tree().quit(1) + + +func _backup_settings_file() -> void: + _settings_existed = FileAccess.file_exists(SETTINGS_PATH) + if _settings_existed: + _settings_backup = FileAccess.get_file_as_bytes(SETTINGS_PATH) + + +func _restore_settings_file() -> void: + if _settings_existed: + var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) + if file != null: + file.store_buffer(_settings_backup) + else: + DirAccess.remove_absolute(SETTINGS_PATH) + + +func _check_defaults_and_migration(config: Node) -> void: + var defaults: Dictionary = config.DEFAULT_SETTINGS + for key in ["mouse_sensitivity", "mouse_sensitivity_x", "mouse_sensitivity_y", "invert_y", "reduced_motion"]: + _expect(defaults.has(key), "DEFAULT_SETTINGS is missing %s" % key) + _expect(is_equal_approx(float(defaults["mouse_sensitivity_x"]), float(defaults["mouse_sensitivity"])), + "horizontal sensitivity must retain the legacy default") + _expect(is_equal_approx(float(defaults["mouse_sensitivity_y"]), float(defaults["mouse_sensitivity"])), + "vertical sensitivity must retain the legacy default") + + # Simulate a pre-split settings.cfg containing only the old shared key. + var legacy := ConfigFile.new() + legacy.set_value("settings", "mouse_sensitivity", 0.0037) + _expect(legacy.save(SETTINGS_PATH) == OK, "could not write legacy settings fixture") + config.load_settings() + _expect(is_equal_approx(float(config.get_mouse_sensitivity_x()), 0.0037), + "legacy sensitivity did not migrate to the horizontal axis") + _expect(is_equal_approx(float(config.get_mouse_sensitivity_y()), 0.0037), + "legacy sensitivity did not migrate to the vertical axis") + _expect(not config.is_invert_y(), "invert Y should default off for legacy users") + _expect(not config.is_reduced_motion(), "reduced motion should default off for legacy users") + + +func _check_current_persistence(config: Node) -> void: + config.set_setting("mouse_sensitivity_x", 0.0013) + config.set_setting("mouse_sensitivity_y", 0.0041) + config.set_setting("invert_y", true) + config.set_setting("reduced_motion", true) + config.save_settings() + config.settings["mouse_sensitivity_x"] = 0.0022 + config.settings["mouse_sensitivity_y"] = 0.0022 + config.settings["invert_y"] = false + config.settings["reduced_motion"] = false + config.load_settings() + _expect(is_equal_approx(float(config.get_mouse_sensitivity_x()), 0.0013), "horizontal sensitivity did not persist") + _expect(is_equal_approx(float(config.get_mouse_sensitivity_y()), 0.0041), "vertical sensitivity did not persist") + _expect(config.is_invert_y(), "invert Y did not persist") + _expect(config.is_reduced_motion(), "reduced motion did not persist") + + +func _check_player_camera_axes(config: Node) -> void: + config.set_setting("mouse_sensitivity_x", 0.001) + config.set_setting("mouse_sensitivity_y", 0.004) + config.set_setting("invert_y", false) + var player_script: GDScript = load("res://player/player.gd") + var player: Node3D = player_script.new() + var head := Node3D.new() + player.add_child(head) + player.set("head", head) + var motion := InputEventMouseMotion.new() + motion.relative = Vector2(10.0, 10.0) + player.call("_apply_mouse_look", motion.relative) + _expect(is_equal_approx(player.rotation.y, -0.01), "player did not use horizontal sensitivity") + _expect(is_equal_approx(head.rotation.x, -0.04), "player did not use vertical sensitivity") + config.set_setting("invert_y", true) + motion.relative = Vector2(0.0, 10.0) + player.call("_apply_mouse_look", motion.relative) + _expect(is_equal_approx(head.rotation.x, 0.0), "player invert Y did not reverse vertical look") + player.free() + + +func _check_photo_camera_axes() -> void: + var root_node := Node3D.new() + root.add_child(root_node) + var player_camera := Camera3D.new() + root_node.add_child(player_camera) + var hud := Control.new() + root_node.add_child(hud) + var photo_script: GDScript = load("res://game/photo_mode.gd") + var photo: Node = photo_script.new() + photo.initialize(player_camera, hud) + photo.mouse_sensitivity_x_provider = func() -> float: return 0.001 + photo.mouse_sensitivity_y_provider = func() -> float: return 0.004 + photo.invert_y_provider = func() -> bool: return false + root_node.add_child(photo) + photo.set_free_camera(true) + photo._look(Vector2(10.0, 10.0)) + _expect(is_equal_approx(photo.get_camera().rotation.y, -0.01), "photo camera did not use horizontal sensitivity") + _expect(is_equal_approx(photo.get_camera().rotation.x, -0.04), "photo camera did not use vertical sensitivity") + photo.invert_y_provider = func() -> bool: return true + photo._look(Vector2(0.0, 10.0)) + _expect(is_equal_approx(photo.get_camera().rotation.x, 0.0), "photo camera invert Y did not reverse vertical look") + root_node.queue_free() + + +func _check_settings_panel(config: Node) -> void: + var menu: Node = load("res://ui/main_menu.tscn").instantiate() + root.add_child(menu) + await get_tree().process_frame + menu._on_settings() + var gameplay: Node = menu.get_node("SettingsMenu/GameplayCategory") + gameplay.open_panel() + await get_tree().process_frame + _expect(_find_label(gameplay, "Horizontal Sensitivity") != null, "Gameplay is missing Horizontal Sensitivity") + _expect(_find_label(gameplay, "Vertical Sensitivity") != null, "Gameplay is missing Vertical Sensitivity") + var invert := _find_check(gameplay, "Invert Y Axis") + var reduced := _find_check(gameplay, "Reduced Motion") + _expect(invert != null, "Gameplay is missing Invert Y Axis") + _expect(reduced != null, "Gameplay is missing Reduced Motion") + if invert != null: + invert.toggled.emit(false) + _expect(not config.is_invert_y(), "Invert Y checkbox did not update GameConfig") + if reduced != null: + reduced.toggled.emit(true) + _expect(config.is_reduced_motion(), "Reduced Motion checkbox did not update GameConfig") + menu.queue_free() + await get_tree().process_frame + + +func _check_reduced_motion(config: Node) -> void: + config.set_setting("reduced_motion", true) + var motion: GDScript = load("res://ui/motion.gd") + var control := Control.new() + control.visible = true + control.modulate.a = 0.25 + control.scale = Vector2(0.5, 0.5) + root.add_child(control) + motion.call("pop_in", control) + _expect(control.scale.is_equal_approx(Vector2.ONE) and is_equal_approx(control.modulate.a, 1.0), + "reduced-motion pop should finish immediately") + control.modulate.a = 0.25 + motion.call("fade_in", control) + _expect(is_equal_approx(control.modulate.a, 1.0), "reduced-motion fade should finish immediately") + control.scale = Vector2(0.5, 0.5) + motion.call("pulse", control) + _expect(control.scale.is_equal_approx(Vector2.ONE), "reduced-motion pulse should not tween") + var dim := ColorRect.new() + dim.modulate.a = 0.25 + root.add_child(dim) + motion.call("dim_in", dim) + _expect(is_equal_approx(dim.modulate.a, 1.0), "reduced-motion dim should finish immediately") + var second := Control.new() + second.modulate.a = 0.25 + second.scale = Vector2(0.5, 0.5) + root.add_child(second) + motion.call("stagger_in", [control, second]) + _expect(is_equal_approx(second.modulate.a, 1.0) and second.scale.is_equal_approx(Vector2.ONE), + "reduced-motion stagger should finish immediately") + config.set_setting("reduced_motion", false) + control.queue_free() + dim.queue_free() + second.queue_free() + await get_tree().process_frame + + +func _find_label(panel: Node, text: String) -> Label: + var rows := panel.find_child("RowsBox", true, false) + if rows == null: + return null + for row in rows.get_children(): + for child in row.get_children(): + if child is Label and (child as Label).text == text: + return child + return null + + +func _find_check(panel: Node, text: String) -> CheckBox: + var rows := panel.find_child("RowsBox", true, false) + if rows == null: + return null + for child in rows.get_children(): + if child is CheckBox and (child as CheckBox).text == text: + return child + return null + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("motion_camera_verify: %s" % message) diff --git a/tools/motion_camera_verify.gd.uid b/tools/motion_camera_verify.gd.uid new file mode 100644 index 0000000..7f69f25 --- /dev/null +++ b/tools/motion_camera_verify.gd.uid @@ -0,0 +1 @@ +uid://qxi8d43eo112 diff --git a/tools/motion_camera_verify.tscn b/tools/motion_camera_verify.tscn new file mode 100644 index 0000000..d7f9528 --- /dev/null +++ b/tools/motion_camera_verify.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/motion_camera_verify.gd" id="1_motion_camera"] + +[node name="MotionCameraVerify" type="Node"] +script = ExtResource("1_motion_camera") diff --git a/tools/photo_mode_verify.gd b/tools/photo_mode_verify.gd index 012985b..ef7b9ca 100644 --- a/tools/photo_mode_verify.gd +++ b/tools/photo_mode_verify.gd @@ -33,7 +33,9 @@ func _run() -> void: var photo := PhotoMode.new() photo.initialize(player_camera, hud) - photo.mouse_sensitivity_provider = func() -> float: return 0.0025 + photo.mouse_sensitivity_x_provider = func() -> float: return 0.0025 + photo.mouse_sensitivity_y_provider = func() -> float: return 0.0025 + photo.invert_y_provider = func() -> bool: return false root_node.add_child(photo) # HUD toggle keeps the root visibility and the tracked state in sync. diff --git a/tools/unit/block_registry_unit_test.gd b/tools/unit/block_registry_unit_test.gd new file mode 100644 index 0000000..c0b0275 --- /dev/null +++ b/tools/unit/block_registry_unit_test.gd @@ -0,0 +1,53 @@ +## Tests the persistent block table and its runtime texture-layer packing. +class_name BlockRegistryUnitTest +extends RefCounted + + +func run(suite) -> void: + suite.test("block definitions have contiguous byte IDs", func() -> void: _test_definition_ids(suite)) + suite.test("block texture layers follow packed definitions", func() -> void: _test_texture_packing(suite)) + suite.test("water IDs round trip through levels", func() -> void: _test_water_levels(suite)) + + +static func _test_definition_ids(suite: UnitSuite) -> void: + suite.expect(BlockRegistry.BLOCK_DEFS.size() <= 256, "persistent IDs fit PackedByteArray") + for index in BlockRegistry.BLOCK_DEFS.size(): + var definition: Array = BlockRegistry.BLOCK_DEFS[index] + suite.expect_equal(int(definition[0]), index, "definition ID is its table index") + suite.expect(int(definition[0]) >= 0 and int(definition[0]) < 256, "definition ID is byte-packable") + + +static func _test_texture_packing(suite: UnitSuite) -> void: + var expected_layers := {} + for definition in BlockRegistry.BLOCK_DEFS: + for texture_index in range(2, 5): + var texture_name: String = definition[texture_index] + if not texture_name.is_empty() and not expected_layers.has(texture_name): + expected_layers[texture_name] = expected_layers.size() + var registry := BlockRegistry.new() + suite.expect(registry.texture_array != null, "texture array was built") + suite.expect_equal(registry.texture_array.get_layers(), expected_layers.size(), "one texture-array layer per unique texture") + const FACE_TEXTURE_COLUMNS := [2, 4, 3] + for definition in BlockRegistry.BLOCK_DEFS: + var id: int = definition[0] + for face in 3: + var texture_name: String = definition[FACE_TEXTURE_COLUMNS[face]] + var expected_layer: int = int(expected_layers.get(texture_name, 0)) + suite.expect_equal(registry.layer_for(id, face), expected_layer, + "packed layer for block %d face %d" % [id, face]) + suite.expect_equal(registry.get_block_name(BlockRegistry.BLOCK_WOOD_BED_LAST), "WOOD BED WEST", "last persistent ID name") + + +static func _test_water_levels(suite: UnitSuite) -> void: + var registry := BlockRegistry.new() + for level in range(0, 9): + var id := registry.water_id_for_level(level) + var expected_level := level + suite.expect_equal(registry.water_level(id), expected_level, "water level round trip %d" % level) + suite.expect_equal(registry.is_water_id(id), level > 0, "water identity at level %d" % level) + for id in range(BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_1 + 1): + var level := registry.water_level(id) + suite.expect_equal(registry.water_id_for_level(level), id, "flowing water inverse for ID %d" % id) + suite.expect_equal(registry.water_level(BlockRegistry.BLOCK_STONE), 0, "solid is not water") + suite.expect_equal(registry.water_id_for_level(-3), BlockRegistry.BLOCK_AIR, "negative level is air") + suite.expect_equal(registry.water_id_for_level(42), BlockRegistry.BLOCK_WATER, "large level is a source") diff --git a/tools/unit/block_registry_unit_test.gd.uid b/tools/unit/block_registry_unit_test.gd.uid new file mode 100644 index 0000000..fc67353 --- /dev/null +++ b/tools/unit/block_registry_unit_test.gd.uid @@ -0,0 +1 @@ +uid://bm6dp32wse56f diff --git a/tools/unit/mesher_unit_test.gd b/tools/unit/mesher_unit_test.gd new file mode 100644 index 0000000..dd1f9f2 --- /dev/null +++ b/tools/unit/mesher_unit_test.gd @@ -0,0 +1,67 @@ +## Unit-level geometry and ambient-occlusion fixtures for ChunkMesher. +class_name MesherUnitTest +extends RefCounted + + +func run(suite) -> void: + suite.test("mesher water-top curve is bounded and ordered", func() -> void: _test_water_curve(suite)) + suite.test("mesher AO offsets cover each face corner", func() -> void: _test_ao_offsets(suite)) + suite.test("mesher culls shared cube faces", func() -> void: _test_face_culling(suite)) + suite.test("mesher AO uses all three occluders", func() -> void: _test_ao_shading(suite)) + + +static func _test_water_curve(suite: UnitSuite) -> void: + var mesher := ChunkMesher.new(BlockRegistry.new()) + var previous := 0.0 + for level in range(1, 9): + var top := mesher._water_top(level) + suite.expect(top > previous and top <= 0.9, "water top increases at level %d" % level) + previous = top + suite.expect_approx(mesher._water_top(1), 0.388125, "level-one water top") + suite.expect_approx(mesher._water_top(8), 0.9, "source water top") + + +static func _test_ao_offsets(suite: UnitSuite) -> void: + var mesher := ChunkMesher.new(BlockRegistry.new()) + suite.expect_equal(mesher._face_ao_offsets.size(), 6, "AO table has one row per face") + for face in mesher._face_ao_offsets.size(): + var corners: Array = mesher._face_ao_offsets[face] + suite.expect_equal(corners.size(), 4, "AO table has four corners for face %d" % face) + for samples in corners: + suite.expect_equal(samples.size(), 3, "AO corner has side A, side B, and diagonal samples") + var top_corner: Array = mesher._face_ao_offsets[0][0] + suite.expect_equal(top_corner[0], Vector3i(-1, 1, 0), "top AO side A offset") + suite.expect_equal(top_corner[1], Vector3i(0, 1, 1), "top AO side B offset") + suite.expect_equal(top_corner[2], Vector3i(-1, 1, 1), "top AO diagonal offset") + + +static func _test_face_culling(suite: UnitSuite) -> void: + var mesher := ChunkMesher.new(BlockRegistry.new()) + var result := _build_fixture(mesher, [Vector3i(8, 8, 8), Vector3i(9, 8, 8)]) + suite.expect_equal(result.verts.size(), 40, "two adjacent cubes emit ten exposed faces") + suite.expect_equal(result.indices.size(), 60, "two adjacent cubes emit ten quad index sets") + + +static func _test_ao_shading(suite: UnitSuite) -> void: + var mesher := ChunkMesher.new(BlockRegistry.new()) + var result := _build_fixture(mesher, [ + Vector3i(8, 8, 8), + Vector3i(7, 9, 8), Vector3i(8, 9, 9), Vector3i(7, 9, 9), + ]) + # The first emitted face is the source block's top face. Its first corner is + # surrounded by the three offsets checked above, yielding AO_LEVELS[0]. + suite.expect_approx(result.colors[0].r, VoxelDefs.AO_LEVELS[0], "three occluders use darkest AO level") + + +static func _build_fixture(mesher: ChunkMesher, blocks: Array[Vector3i]) -> ChunkMesher.MeshResult: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for position in blocks: + data[position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y] = BlockRegistry.BLOCK_STONE + var heights := PackedInt32Array() + heights.resize(VoxelDefs.CHUNK_AREA) + heights.fill(10) + var tints := PackedColorArray() + tints.resize(VoxelDefs.CHUNK_AREA) + tints.fill(Color.WHITE) + return mesher.build(data, 10, heights, tints, tints, ChunkMesher.NeighborSet.new(), false) diff --git a/tools/unit/mesher_unit_test.gd.uid b/tools/unit/mesher_unit_test.gd.uid new file mode 100644 index 0000000..67b5e3b --- /dev/null +++ b/tools/unit/mesher_unit_test.gd.uid @@ -0,0 +1 @@ +uid://dkrsivvemy4lu diff --git a/tools/unit/unit_suite.gd b/tools/unit/unit_suite.gd new file mode 100644 index 0000000..eab7a75 --- /dev/null +++ b/tools/unit/unit_suite.gd @@ -0,0 +1,32 @@ +## Small dependency-free assertion harness for pure GDScript unit tests. +class_name UnitSuite +extends RefCounted + +var test_count: int = 0 +var assertion_count: int = 0 +var failures := PackedStringArray() +var _current_test := "" + + +func test(name: String, body: Callable) -> void: + test_count += 1 + _current_test = name + var failures_before := failures.size() + body.call() + if failures.size() == failures_before: + print(" PASS ", name) + + +func expect(condition: bool, message: String) -> void: + assertion_count += 1 + if not condition: + failures.append("%s: %s" % [_current_test, message]) + + +func expect_equal(actual: Variant, expected: Variant, message: String) -> void: + expect(actual == expected, "%s (expected %s, got %s)" % [message, expected, actual]) + + +func expect_approx(actual: float, expected: float, message: String, tolerance: float = 0.00001) -> void: + expect(is_equal_approx(actual, expected) or absf(actual - expected) <= tolerance, + "%s (expected %.6f, got %.6f)" % [message, expected, actual]) diff --git a/tools/unit/unit_suite.gd.uid b/tools/unit/unit_suite.gd.uid new file mode 100644 index 0000000..00ad77f --- /dev/null +++ b/tools/unit/unit_suite.gd.uid @@ -0,0 +1 @@ +uid://pg63yr6wj1cx diff --git a/tools/unit/worldgen_unit_test.gd b/tools/unit/worldgen_unit_test.gd new file mode 100644 index 0000000..6f565fe --- /dev/null +++ b/tools/unit/worldgen_unit_test.gd @@ -0,0 +1,66 @@ +## Deterministic world-generation primitive and field fixtures. +class_name WorldgenUnitTest +extends RefCounted + +const TEST_CONFIG := { + "seed": 123456789, + "world_type": WorldGenConfig.WORLD_TYPE_NORMAL, + "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": 0.0, + "decoration_density": 0.0, +} + +const FIELD_FIXTURES := [ + [Vector2i(0, 0), 65.557, 0, 0.0], + [Vector2i(7, 11), 68.470, 0, 0.0], + [Vector2i(15, 15), 66.610, 0, 0.0], +] + + +func run(suite) -> void: + suite.test("worldgen hash fixed vectors", func() -> void: _test_hash_vectors(suite)) + suite.test("worldgen floor division and ranges", func() -> void: _test_hash_bounds(suite)) + suite.test("worldgen field fixtures are deterministic", func() -> void: _test_field_fixture(suite)) + + +static func _test_hash_vectors(suite: UnitSuite) -> void: + suite.expect_equal(WorldGenHash.hash_1d(123456789, -17), 746319822, "1D hash fixture") + suite.expect_equal(WorldGenHash.hash_2d(123456789, 0, 0), 991826124, "origin hash fixture") + suite.expect_equal(WorldGenHash.hash_2d(123456789, -17, 31), 554134520, "negative-coordinate hash fixture") + suite.expect_equal(WorldGenHash.hash_2d(-987654321, 999, -444), 466599662, "negative-seed hash fixture") + suite.expect_equal(WorldGenHash.hash_3d(123456789, -17, 9, 31), 778180355, "3D hash fixture") + + +static func _test_hash_bounds(suite: UnitSuite) -> void: + for value in [-33, -17, -1, 0, 1, 17, 33]: + suite.expect_equal(WorldGenHash.floor_mod(value, 16), posmod(value, 16), "floor modulus at %d" % value) + suite.expect_equal(WorldGenHash.floor_div(value, 16) * 16 + WorldGenHash.floor_mod(value, 16), value, + "division identity at %d" % value) + var unit := WorldGenHash.float_01_2d(918273, value, -value) + suite.expect(unit >= 0.0 and unit < 1.0, "unit hash range at %d" % value) + for minimum in [-4, 0, 5]: + for maximum in [minimum, minimum + 1, minimum + 7]: + var sample := WorldGenHash.int_range_2d(42, minimum, maximum, minimum, maximum) + suite.expect(sample >= minimum and sample <= maximum, "integer hash range %d..%d" % [minimum, maximum]) + + +static func _test_field_fixture(suite: UnitSuite) -> void: + var sampler := TerrainSampler.new(WorldGenConfig.new(TEST_CONFIG), TerrainProfileCatalog.new(), BiomeCatalog.new()) + var first: ChunkTerrainData = sampler.build_field(Vector2i(3, -2)) + var second: ChunkTerrainData = sampler.build_field(Vector2i(3, -2)) + suite.expect_equal(first.final_height, second.final_height, "field heights repeat") + suite.expect_equal(first.dominant_biome, second.dominant_biome, "field biomes repeat") + for fixture in FIELD_FIXTURES: + var cell: Vector2i = fixture[0] + var index := ChunkTerrainData.cell_index(cell.x, cell.y) + var point: Dictionary = sampler.sample_point(3 * VoxelDefs.CHUNK_SIZE + cell.x, -2 * VoxelDefs.CHUNK_SIZE + cell.y) + suite.expect_approx(float(first.final_height[index]), float(point["final_height"]), "field/point height parity at %s" % cell) + suite.expect_equal(int(first.dominant_biome[index]), int(point["dominant_biome_id"]), "field/point biome parity at %s" % cell) + suite.expect_approx(float(first.final_height[index]), float(fixture[1]), "field height fixture at %s" % cell, 0.001) + suite.expect_equal(int(first.dominant_biome[index]), int(fixture[2]), "field biome fixture at %s" % cell) + suite.expect_approx(float(first.river[index]), float(fixture[3]), "field river fixture at %s" % cell, 0.000001) diff --git a/tools/unit/worldgen_unit_test.gd.uid b/tools/unit/worldgen_unit_test.gd.uid new file mode 100644 index 0000000..bde701c --- /dev/null +++ b/tools/unit/worldgen_unit_test.gd.uid @@ -0,0 +1 @@ +uid://cqe1a6e6nrl1w diff --git a/tools/unit_tests.gd b/tools/unit_tests.gd new file mode 100644 index 0000000..a3eb327 --- /dev/null +++ b/tools/unit_tests.gd @@ -0,0 +1,23 @@ +## Entry point for dependency-free pure-logic unit tests. +extends SceneTree + +const UnitSuiteScript = preload("res://tools/unit/unit_suite.gd") +const WorldgenUnitTestScript = preload("res://tools/unit/worldgen_unit_test.gd") +const BlockRegistryUnitTestScript = preload("res://tools/unit/block_registry_unit_test.gd") +const MesherUnitTestScript = preload("res://tools/unit/mesher_unit_test.gd") + + +func _initialize() -> void: + var suite = UnitSuiteScript.new() + print("UNIT TESTS: START") + WorldgenUnitTestScript.new().run(suite) + BlockRegistryUnitTestScript.new().run(suite) + MesherUnitTestScript.new().run(suite) + if suite.failures.is_empty(): + print("UNIT TESTS: PASS (%d tests, %d assertions)" % [suite.test_count, suite.assertion_count]) + quit(0) + return + for failure in suite.failures: + push_error("UNIT TEST: %s" % failure) + print("UNIT TESTS: FAIL (%d/%d tests, %d assertions)" % [suite.failures.size(), suite.test_count, suite.assertion_count]) + quit(1) diff --git a/tools/unit_tests.gd.uid b/tools/unit_tests.gd.uid new file mode 100644 index 0000000..7c0a959 --- /dev/null +++ b/tools/unit_tests.gd.uid @@ -0,0 +1 @@ +uid://4cm6a2rewgry diff --git a/tools/world_storage_verify.gd b/tools/world_storage_verify.gd index a174442..bee0192 100644 --- a/tools/world_storage_verify.gd +++ b/tools/world_storage_verify.gd @@ -2,11 +2,29 @@ extends SceneTree var _failures := PackedStringArray() var _root := "user://world_storage_verify_%d" % Time.get_ticks_usec() +const WORLDGEN_MIGRATION_HASHES := { + 1: "f6aa67c78f19b7f8f9baee9133eac78f27a21f0d719a926311fdc19c75ab1f93", + 2: "c743ab6db11f64ce777aeb4d3ea06a6ec175cd0b876043ed56b1a7ef84c7d760", + 3: "be1c9c3712afd597e637463a5bebf25a7813ccd68bddf7efa5080ad9958486f5", + 4: "1e72a0207cfca8da8acea5efadd508cdea38d4acdd7d4347b38a2acb20ec5920", + 5: "4ddb5f1c3a9bb198a882069e56c20417d41b24d84f49de8aaa86b9d77cf684ae", + 6: "da705fd0f749518a73f4e41f7c9b93a964783c828f28e81428d6772891ff0cf7", + 7: "1d88708dd2dd9c8eb0f1fa7e6510b2815f2cbf5572cc80a59e67b97cd51b04d5", + 8: "6a27c9f03e665f6c40f4500eeb6fbb2ca6d7681b6791461bfe829968cbf27f85", + 9: "64bc2a9d1c27b5a99e93f9eaf9525ef580ecf26fa4eb3987f767d0f5b98dc65a", + 10: "5db38b3f49509f3eb5cea281017a9c4e86161ebf92ffcab889329c5d665eb91a", + 11: "bea6af0ec518980f6a38c26826584a719c0e1aef5f21bf83584bb9652e2b30ab", + 12: "987cb6224bd2772c0125acc58050692b0df668e92402443040ccef3d45fc57ee", + 13: "8553843f78fd5bcdedbc14cdb7f6e8d6226ea745b377843556509f15dd951ed3", + 14: "bf288934dc42421719ad8c04b95be0d051fe1634a4a40fedfd588adad1139498", +} func _initialize() -> void: _verify_game_modes() + _verify_metadata_and_worldgen_migration_fixtures() _verify_v1_compatibility_fixture() + _verify_session_state_migration_fixtures() _verify_round_trip_and_regions() _verify_future_metadata_rejection() _verify_library_listing_and_delete() @@ -66,49 +84,200 @@ func _verify_game_modes() -> void: _expect(summary["game_mode"] == int(String(summary["id"]).trim_prefix("mode-")), "summary lost mode") +## These fixtures deliberately use the sparse dictionaries written by older +## releases. Do not build them with WorldGenConfig: that would recreate today's +## format and stop exercising absent historical keys. +func _verify_metadata_and_worldgen_migration_fixtures() -> void: + for version in range(1, WorldGenConfig.CURRENT_VERSION + 1): + var id := "metadata-worldgen-v%d" % version + var source_worldgen := _historical_worldgen_fixture(version) + _expect(_write_metadata_fixture(id, source_worldgen, {"fixture": version}), + "could not write metadata fixture for worldgen v%d" % version) + var loaded := WorldStorage.new(_root).open_world(id) + _expect(not loaded.is_empty(), "metadata fixture for worldgen v%d was rejected" % version) + if loaded.is_empty(): + continue + _expect(_equivalent(loaded.get("worldgen", {}), source_worldgen), + "metadata fixture for worldgen v%d changed before generation" % version) + var first_config := WorldGenConfig.new(loaded["worldgen"]).to_dictionary() + var second_config := WorldGenConfig.new(loaded["worldgen"]).to_dictionary() + _expect(first_config == second_config and int(first_config.get("worldgen_version", -1)) == version, + "worldgen v%d did not migrate to a stable canonical config" % version) + var first_generator := TerrainGenerator.new() + var second_generator := TerrainGenerator.new() + first_generator.configure(loaded["worldgen"]) + second_generator.configure(loaded["worldgen"]) + var chunk := Vector2i(-version, version - WorldGenConfig.CURRENT_VERSION) + var first_data: PackedByteArray = first_generator.generate_data(chunk, {}).data + var second_data: PackedByteArray = second_generator.generate_data(chunk, {}).data + _expect(not first_data.is_empty() and first_data == second_data, + "worldgen v%d fixture generation was not deterministic" % version) + _expect(_sha256(first_data) == WORLDGEN_MIGRATION_HASHES.get(version, ""), + "worldgen v%d historical output changed" % version) + + var malformed_id := "metadata-malformed" + var malformed := _metadata_fixture(malformed_id, _historical_worldgen_fixture(8), {}) + malformed.erase("state") + _expect(_write_raw_metadata(malformed_id, malformed), "could not write malformed metadata fixture") + _expect(WorldStorage.new(_root).open_world(malformed_id).is_empty(), + "metadata fixture missing state was accepted") + var obsolete_format_id := "metadata-format-v0" + var obsolete_format := _metadata_fixture(obsolete_format_id, _historical_worldgen_fixture(8), {}) + obsolete_format["format_version"] = WorldStorage.METADATA_VERSION - 1 + _expect(_write_raw_metadata(obsolete_format_id, obsolete_format), + "could not write obsolete metadata format fixture") + _expect(WorldStorage.new(_root).open_world(obsolete_format_id).is_empty(), + "unsupported older metadata format was accepted") + var invalid_json_id := "metadata-invalid-json" + _expect(_write_raw_metadata_text(invalid_json_id, "{not valid json"), + "could not write invalid JSON metadata fixture") + _expect(WorldStorage.new(_root).open_world(invalid_json_id).is_empty(), + "invalid JSON metadata fixture was accepted without a backup") + print("WORLD STORAGE METADATA MIGRATION: worldgen_versions=1-%d malformed_rejected=true obsolete_rejected=true" % WorldGenConfig.CURRENT_VERSION) + + 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)) == { + var config := WorldGenConfig.new({"seed": -10101, "worldgen_version": 8}).to_dictionary() + for id: String in ["legacy-v1-world-a", "legacy-v1-world-b"]: + var storage := WorldStorage.new(_root) + _expect(not storage.create_world(config, {}, id).is_empty(), + "could not create v1 compatibility world %s" % id) + _expect(_write_v1_region_fixture(id), "could not write handcrafted v1 fixture %s" % id) + var expected_a := { 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)) == { + } + var expected_b := { Vector3i(-17, 33, -2): BlockRegistry.BLOCK_WATER, - }, "handcrafted v1 second chunk did not load") - print("WORLD STORAGE V1: handcrafted_fixture=true negative_coordinates=true") + } + var migrated_bytes := PackedByteArray() + for id: String in ["legacy-v1-world-a", "legacy-v1-world-b"]: + var reopened := WorldStorage.new(_root) + _expect(not reopened.open_world(id).is_empty(), "could not reopen v1 fixture world %s" % id) + _expect(reopened.load_chunk_edits(Vector2i(-1, -1)) == expected_a, + "handcrafted v1 negative-coordinate edits did not load for %s" % id) + _expect(reopened.load_chunk_edits(Vector2i(-2, -1)) == expected_b, + "handcrafted v1 second chunk did not load for %s" % id) + # Staging a loaded legacy chunk forces its next write through the v2 codec. + reopened.stage_chunk_edits(Vector2i(-2, -1), expected_b) + reopened.stage_chunk_edits(Vector2i(-1, -1), expected_a) + _expect(reopened.flush_dirty_regions() == OK, "v1 to v2 fixture migration failed for %s" % id) + var path: String = _root + "/" + id + "/regions/r.-1.-1.rcregion" + _expect(_read_region_version(path) == WorldStorage.REGION_VERSION, + "v1 fixture was not rewritten as v2 for %s" % id) + var converted := FileAccess.get_file_as_bytes(path) + if migrated_bytes.is_empty(): + migrated_bytes = converted + else: + _expect(converted == migrated_bytes, "v1 to v2 migration bytes were not deterministic") + var verified := WorldStorage.new(_root) + verified.open_world(id) + _expect(verified.load_chunk_edits(Vector2i(-1, -1)) == expected_a \ + and verified.load_chunk_edits(Vector2i(-2, -1)) == expected_b, + "v2 rewrite changed v1 fixture edits for %s" % id) + print("WORLD STORAGE V1: handcrafted_fixture=true negative_coordinates=true migrated_to_v2=true") + + +func _verify_session_state_migration_fixtures() -> void: + var version_main: Variant = _new_session_main() + if version_main == null: + _expect(false, "could not instantiate Main to read the session state version") + return + var session_version := int(version_main.SESSION_STATE_VERSION) + _free_session_main(version_main) + # v0 and v1 used count rows rather than positioned inventory stacks. Keep the + # rows literal so this verifies the wire format, not ItemInventory's current + # serializer. + var v0 := { + "inventory": [[BlockRegistry.BLOCK_STONE, 7], [BlockRegistry.BLOCK_STONE, 5], + [BlockRegistry.BLOCK_TORCH, 2]], + "selected_slot": 4, + "day_night": null, + "weather": null, + } + var v0_main: Variant = _restore_session_fixture(v0) + _expect(v0_main.inventory.count_item(BlockRegistry.BLOCK_STONE) == 12 + and v0_main.inventory.count_item(BlockRegistry.BLOCK_TORCH) == 2 + and v0_main.selected_slot == 4, + "v0 session fixture did not migrate legacy count rows") + var v1 := { + "state_version": 1, + "inventory": [[BlockRegistry.BLOCK_DIRT, 9], [BlockRegistry.BLOCK_TORCH, 3]], + "selected_slot": 99, + "day_night": null, + "weather": null, + } + var v1_main: Variant = _restore_session_fixture(v1) + _expect(v1_main.inventory.count_item(BlockRegistry.BLOCK_DIRT) == 9 + and v1_main.inventory.count_item(BlockRegistry.BLOCK_TORCH) == 3 + and v1_main.selected_slot == ItemInventory.HOTBAR_SIZE - 1, + "v1 session fixture did not migrate legacy count rows") + + var current := { + "state_version": session_version, + "inventory": [ + {"id": BlockRegistry.BLOCK_STONE, "count": 6, "durability": 0}, + {}, + {"id": ItemRegistry.ITEM_WOOD_PICKAXE, "count": 1, "durability": 42}, + ], + "selected_slot": 2, + "day_night": null, + "weather": null, + } + var current_main: Variant = _restore_session_fixture(current) + _expect(current_main.inventory.persistent_state().slice(0, 3) == current["inventory"] + and current_main.selected_slot == 2, + "current session fixture did not retain positioned stacks") + # This is the current tagged wire representation a subsequent save uses for + # a restored legacy inventory; it must load without changing stack positions. + var migrated_current := { + "state_version": session_version, + "inventory": v0_main.inventory.persistent_state(), + "selected_slot": v0_main.selected_slot, + "day_night": null, + "weather": null, + } + var round_tripped: Variant = _restore_session_fixture(migrated_current) + _expect(round_tripped.inventory.persistent_state() == migrated_current["inventory"], + "v0 session did not produce a loadable current inventory wire format") + + var future_main: Variant = _new_session_main() + _expect(future_main._migrate_session_state({"state_version": session_version + 1, + "inventory": current["inventory"]}).is_empty(), "future session version was accepted") + _expect(future_main._migrate_session_state([]).is_empty(), "non-dictionary session fixture was accepted") + var malformed_main: Variant = _restore_session_fixture({ + "state_version": session_version, + "inventory": "not an inventory array", + "selected_slot": "not a slot", + "day_night": null, + "weather": null, + }) + _expect(_all_inventory_slots_empty(malformed_main.inventory.persistent_state()) + and malformed_main.selected_slot == 0, + "malformed current session did not fall back to an empty survival state") + for main in [v0_main, v1_main, current_main, round_tripped, future_main, malformed_main]: + _free_session_main(main) + print("WORLD STORAGE SESSION MIGRATION: v0=true v1=true current=true future_rejected=true malformed_safe=true") + + +func _new_session_main() -> Variant: + var script: Variant = load("res://game/main.gd") + return script.new() if script != null and script.can_instantiate() else null + + +func _restore_session_fixture(fixture: Variant) -> Variant: + var main: Variant = _new_session_main() + if main == null: + _expect(false, "could not instantiate Main for session migration fixture") + return null + main._game_mode = GameMode.SURVIVAL + main._restore_session_state(main._migrate_session_state(fixture)) + return main + + +func _free_session_main(value: Variant) -> void: + if value is Node: + value.free() func _verify_round_trip_and_regions() -> void: @@ -401,10 +570,129 @@ func _verify_generation_edit_priority() -> void: _expect(_block(result.data, removed) == BlockRegistry.BLOCK_AIR, "loaded AIR edit did not override generated terrain") +func _historical_worldgen_fixture(version: int) -> Dictionary: + var fixture := { + "seed": -400_000 - version, + "world_type": version % 3, + "terrain_scale": 1.25, + "tree_density": 0.75, + "worldgen_version": version, + } + # v10 introduced the two opt-in terrain experiments and v11 introduced + # climate variants/region structures. Earlier fixtures intentionally omit + # those keys to exercise their historical defaults. + if version >= WorldGenConfig.SPLINE_TERRAIN_VERSION: + fixture["spline_terrain"] = true + fixture["elevated_hydrology"] = true + if version >= WorldGenConfig.VARIANT_WORLDGEN_VERSION: + fixture["climate_variants"] = false + fixture["region_structures"] = false + return fixture + + +func _metadata_fixture(id: String, worldgen: Dictionary, state: Dictionary) -> Dictionary: + return { + "magic": WorldStorage.METADATA_MAGIC, + "format_version": WorldStorage.METADATA_VERSION, + "id": id, + "name": "Migration fixture %s" % id, + "created_unix": 1_700_000_000, + "updated_unix": 1_700_000_000, + "worldgen": worldgen.duplicate(true), + "game_mode": GameMode.SURVIVAL, + "layout": { + "chunk_size": VoxelDefs.CHUNK_SIZE, + "world_height": VoxelDefs.WORLD_HEIGHT, + "region_chunks": WorldStorage.REGION_CHUNKS, + }, + "block_registry_revision": 1, + "state": state.duplicate(true), + } + + +func _write_metadata_fixture(id: String, worldgen: Dictionary, state: Dictionary) -> bool: + return _write_raw_metadata(id, _metadata_fixture(id, worldgen, state)) + + +func _write_raw_metadata(id: String, metadata: Dictionary) -> bool: + return _write_raw_metadata_text(id, JSON.stringify(metadata)) + + +func _write_raw_metadata_text(id: String, contents: String) -> bool: + var world_path := _root + "/" + id + if DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(world_path + "/regions")) != OK: + return false + var file := FileAccess.open(world_path + "/metadata.json", FileAccess.WRITE) + if file == null: + return false + file.store_string(contents) + file.flush() + var error := file.get_error() + file = null + return error == OK + + +## Exact pre-palette region records. Keep this separate from _write_region(): +## it verifies the old on-disk layout rather than merely testing today's writer. +func _write_v1_region_fixture(id: String) -> bool: + var path := _root + "/" + id + "/regions/r.-1.-1.rcregion" + var fixture := FileAccess.open_compressed(path, FileAccess.WRITE, FileAccess.COMPRESSION_ZSTD) + if fixture == null: + return false + 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() + var error := fixture.get_error() + fixture = null + return error == OK + + +func _read_region_version(path: String) -> int: + var file := FileAccess.open_compressed(path, FileAccess.READ, FileAccess.COMPRESSION_ZSTD) + if file == null or file.get_buffer(4).get_string_from_utf8() != WorldStorage.REGION_MAGIC: + return -1 + var version := int(file.get_16()) + file = null + return version + + +func _all_inventory_slots_empty(slots: Array) -> bool: + for slot in slots: + if slot is Dictionary and not slot.is_empty(): + return false + return true + + func _block(data: PackedByteArray, position: Vector3i) -> int: return data[position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.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/ui/README.md b/ui/README.md index 03cd481..c71d2e6 100644 --- a/ui/README.md +++ b/ui/README.md @@ -7,6 +7,10 @@ The interface uses a shared "Deepslate & Ember" design system built from native - `ui_theme.gd` owns palette, typography, spacing, style boxes, generated control textures, and small component factories. Screens apply it with `UITheme.apply(root)` (which registers the host for live scale refresh) and size fonts through `UITheme.apply_font_size()` so the Display text-size setting can rescale them. - `motion.gd` owns the restrained entrance, selection, and status tween vocabulary. - `block_icon.gd` renders cached isometric item icons from `BlockRegistry.texture_array`; UI icons therefore stay synchronized with world textures. +- `loading_overlay.gd` is the read-only transition shown after selecting a + world. It reports secure spawn-ring and full-horizon stream counts from + `VoxelWorld`, consumes input until local collision commits, then fades without + delaying gameplay while the HUD continues to report background streaming. - `minimap.gd` (corner HUD map, `]`) and `map_overlay.gd` (full-screen `M` atlas with wheel/`+`/`-` zoom and drag/arrow pan) both sample `VoxelWorld.request_debug_map()` on worker threads, so refreshes reuse the F3 overlay's modes and never block the frame; `map_view.gd` holds their shared view projection and color packing, and `N` cycles modes. - Static hierarchy remains in `.tscn` scenes. Scripts compose dynamic rows, inventory slots, HUD slots, and decorative title-screen layers. - Iosevka font subsets and their license live in `assets/fonts/`. diff --git a/ui/loading_overlay.gd b/ui/loading_overlay.gd new file mode 100644 index 0000000..44a29ba --- /dev/null +++ b/ui/loading_overlay.gd @@ -0,0 +1,161 @@ +class_name LoadingOverlay +extends Control + +## Transitional world-entry presentation. It is deliberately read-only: the +## player remains locked until VoxelWorld has committed collision for the local +## spawn ring, while the rest of the requested horizon keeps streaming. + +var _panel: PanelContainer +var _phase: Label +var _detail: Label +var _progress_text: Label +var _activity: Label +var _bar: Panel +var _fill: ColorRect +var _progress := 0.0 +var _pending_progress: Dictionary = {} + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + focus_mode = Control.FOCUS_ALL + UITheme.apply(self) + _build() + set_progress(_pending_progress) + call_deferred("grab_focus") + + +func _build() -> void: + var dim := ColorRect.new() + dim.name = "Dim" + dim.color = Color(UITheme.VOID.r, UITheme.VOID.g, UITheme.VOID.b, 0.86) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(dim) + + var center := CenterContainer.new() + center.name = "Center" + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + + _panel = PanelContainer.new() + _panel.name = "Panel" + _panel.custom_minimum_size = Vector2(460.0, 0.0) + _panel.add_theme_stylebox_override("panel", UITheme.modal_style()) + center.add_child(_panel) + + var box := VBoxContainer.new() + box.name = "Box" + box.add_theme_constant_override("separation", 10) + _panel.add_child(box) + + var eyebrow := Label.new() + eyebrow.name = "Eyebrow" + eyebrow.text = "WORLD PREPARATION" + eyebrow.add_theme_font_override("font", UITheme.font_eyebrow()) + UITheme.apply_font_size(eyebrow, UITheme.SIZE_EYEBROW) + eyebrow.add_theme_color_override("font_color", UITheme.CYAN) + box.add_child(eyebrow) + + _phase = Label.new() + _phase.name = "Phase" + _phase.add_theme_font_override("font", UITheme.font_display()) + UITheme.apply_font_size(_phase, 24) + _phase.add_theme_color_override("font_color", UITheme.INK) + box.add_child(_phase) + + _detail = Label.new() + _detail.name = "Detail" + _detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UITheme.apply_font_size(_detail, UITheme.SIZE_BODY) + _detail.add_theme_color_override("font_color", UITheme.INK_DIM) + box.add_child(_detail) + + _bar = Panel.new() + _bar.name = "ProgressTrack" + _bar.custom_minimum_size = Vector2(0.0, 10.0) + _bar.add_theme_stylebox_override("panel", UITheme.panel_style(UITheme.SURFACE_LOW, UITheme.LINE, 1, 5)) + box.add_child(_bar) + _fill = ColorRect.new() + _fill.name = "ProgressFill" + _fill.color = UITheme.EMBER + _fill.mouse_filter = Control.MOUSE_FILTER_IGNORE + _bar.add_child(_fill) + _bar.resized.connect(_layout_progress) + + _progress_text = Label.new() + _progress_text.name = "ProgressText" + UITheme.apply_font_size(_progress_text, UITheme.SIZE_VALUE) + _progress_text.add_theme_color_override("font_color", UITheme.CYAN) + box.add_child(_progress_text) + + _activity = Label.new() + _activity.name = "Activity" + UITheme.apply_font_size(_activity, UITheme.SIZE_EYEBROW) + _activity.add_theme_color_override("font_color", UITheme.MUTED) + box.add_child(_activity) + + +## Accepts VoxelWorld.get_streaming_progress() snapshots before or after the +## control enters the tree, so scene startup cannot miss the first update. +func set_progress(progress: Dictionary) -> void: + _pending_progress = progress.duplicate() + if _phase == null: + return + var total := maxi(int(progress.get("total", 0)), 1) + var loaded := clampi(int(progress.get("loaded", 0)), 0, total) + var percent := clampi(roundi(float(loaded) / float(total) * 100.0), 0, 100) + _progress = float(loaded) / float(total) + var spawn_total := maxi(int(progress.get("spawn_total", 0)), 1) + var spawn_collision := clampi(int(progress.get("spawn_collision", 0)), 0, spawn_total) + var initial_ready := bool(progress.get("initial_ready", false)) + var streaming := bool(progress.get("streaming", false)) + if not initial_ready: + _phase.text = "Preparing a safe spawn" + _detail.text = "Generating nearby terrain and its collision before you enter the world." + _progress_text.text = "Spawn area %d / %d chunks secure" % [spawn_collision, spawn_total] + elif streaming: + _phase.text = "Spawn ready" + _detail.text = "The horizon is still streaming in the background." + _progress_text.text = "Horizon %d / %d chunks · %d%%" % [loaded, total, percent] + else: + _phase.text = "World ready" + _detail.text = "Terrain and collision are ready." + _progress_text.text = "Horizon %d / %d chunks · 100%%" % [loaded, total] + var active := int(progress.get("pending", 0)) + var queued := int(progress.get("queued", 0)) + var staged := int(progress.get("generated", 0)) + _activity.text = "%d active · %d queued · %d awaiting mesh" % [active, queued, staged] + _layout_progress() + + +## Unlocking gameplay never waits for this cosmetic fade. It also drops focus +## and mouse interception immediately, preserving normal HUD input behavior. +func complete() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + set_process_unhandled_input(false) + if GameConfig.is_reduced_motion(): + queue_free() + return + var tween := create_tween() + tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD) + tween.tween_property(self, "modulate:a", 0.0, 0.18) + tween.tween_callback(queue_free) + + +func _layout_progress() -> void: + if _bar == null or _fill == null: + return + _fill.position = Vector2.ZERO + _fill.size = Vector2(_bar.size.x * _progress, _bar.size.y) + + +## A loading overlay is not cancellable because abandoning the scene while +## worker jobs are in flight would turn Escape into a long synchronous wait. +## Consume input until Main releases the player after the safe spawn boundary. +func _unhandled_input(_event: InputEvent) -> void: + if visible: + get_viewport().set_input_as_handled() diff --git a/ui/loading_overlay.gd.uid b/ui/loading_overlay.gd.uid new file mode 100644 index 0000000..f1150de --- /dev/null +++ b/ui/loading_overlay.gd.uid @@ -0,0 +1 @@ +uid://btsgpav70x3lq diff --git a/ui/motion.gd b/ui/motion.gd index 64450af..124c85f 100644 --- a/ui/motion.gd +++ b/ui/motion.gd @@ -6,11 +6,32 @@ extends RefCounted ## tree-pause and scene changes, so nothing here delays closing. +## Read dynamically from the autoload so this utility remains usable by simple +## headless scene harnesses that do not resolve autoload identifiers at parse +## time. Reduced motion always puts the control directly in its final state; +## no tween or frame wait is created. +static func _reduced_motion(control: Control) -> bool: + if control == null or not is_instance_valid(control): + return false + var tree := control.get_tree() + var config := tree.root.get_node_or_null("GameConfig") if tree != null else null + return config != null and config.has_method("is_reduced_motion") and bool(config.is_reduced_motion()) + + +static func _show_immediately(control: Control, reset_scale: bool = true) -> void: + if reset_scale: + control.scale = Vector2.ONE + control.modulate.a = 1.0 + + ## Scale-and-rise pop used when a modal panel appears. Awaits one frame so ## the freshly-shown control has a real size to pivot around. static func pop_in(control: Control, duration: float = 0.24, rise: float = 16.0) -> void: if control == null or not is_instance_valid(control): return + if _reduced_motion(control): + _show_immediately(control) + return await control.get_tree().process_frame if not is_instance_valid(control) or not control.visible: return @@ -29,6 +50,9 @@ static func pop_in(control: Control, duration: float = 0.24, rise: float = 16.0) static func dim_in(dim: ColorRect, duration: float = 0.16) -> void: if dim == null or not is_instance_valid(dim): return + if _reduced_motion(dim): + _show_immediately(dim, false) + return dim.modulate.a = 0.0 var tween := dim.create_tween() tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD) @@ -37,6 +61,16 @@ 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: + var reduced := false + for candidate in controls: + if candidate is Control and _reduced_motion(candidate as Control): + reduced = true + break + if reduced: + for candidate in controls: + if candidate is Control and is_instance_valid(candidate): + _show_immediately(candidate as Control) + return for control in controls: if is_instance_valid(control) and control is Control: (control as Control).modulate.a = 0.0 @@ -69,6 +103,9 @@ static func stagger_in(controls: Array, step: float = 0.055, duration: float = 0 static func pulse(control: Control, peak: float = 1.08, duration: float = 0.16) -> void: if control == null or not is_instance_valid(control): return + if _reduced_motion(control): + _show_immediately(control) + return control.pivot_offset = control.size * 0.5 var tween := control.create_tween() tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD) @@ -81,6 +118,9 @@ static func pulse(control: Control, peak: float = 1.08, duration: float = 0.16) static func fade_in(control: Control, duration: float = 0.18) -> void: if control == null or not is_instance_valid(control): return + if _reduced_motion(control): + _show_immediately(control, false) + return control.modulate.a = 0.0 var tween := control.create_tween() tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD) diff --git a/ui/settings_category_panel.gd b/ui/settings_category_panel.gd index b924a4a..64902ae 100644 --- a/ui/settings_category_panel.gd +++ b/ui/settings_category_panel.gd @@ -103,7 +103,10 @@ func _category_definition() -> Dictionary: "title": "Gameplay", "advanced": false, "rows": [ - {"type": "slider", "label": "Mouse Sensitivity", "key": "mouse_sensitivity", "min": 0.0005, "max": 0.005, "step": 0.0001, "format": "%.2fx", "scale": 1000.0}, + {"type": "slider", "label": "Horizontal Sensitivity", "key": "mouse_sensitivity_x", "min": GameConfig.MOUSE_SENSITIVITY_MIN, "max": GameConfig.MOUSE_SENSITIVITY_MAX, "step": 0.0001, "format": "%.2fx", "scale": 1000.0, "tooltip": "Mouse look speed when turning left or right."}, + {"type": "slider", "label": "Vertical Sensitivity", "key": "mouse_sensitivity_y", "min": GameConfig.MOUSE_SENSITIVITY_MIN, "max": GameConfig.MOUSE_SENSITIVITY_MAX, "step": 0.0001, "format": "%.2fx", "scale": 1000.0, "tooltip": "Mouse look speed when looking up or down."}, + {"type": "check", "label": "Invert Y Axis", "key": "invert_y", "tooltip": "Reverse vertical mouse look for gameplay and the photo camera."}, + {"type": "check", "label": "Reduced Motion", "key": "reduced_motion", "tooltip": "Show menu, HUD, and modal changes instantly instead of playing motion effects."}, {"type": "option", "label": "Autosave", "key": "autosave_interval", "options": GameConfig.AUTOSAVE_INTERVAL_NAMES, "values": GameConfig.AUTOSAVE_INTERVAL_VALUES, "tooltip": "How often active gameplay is saved. Worlds are always saved when leaving or quitting."}, ], } diff --git a/world/block_registry.gd b/world/block_registry.gd index ce82cc4..69a5cc9 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -508,6 +508,13 @@ func is_flammable(block_id: int) -> bool: return has_flag(block_id, FLAG_FLAMMABLE) +## Blocks whose settled position is resolved by VoxelWorld's persisted gravity +## simulation. Red sand shares sand's material behavior without consuming a +## format flag bit (block IDs and flags are persisted in compact bytes). +func is_gravity_block(block_id: int) -> bool: + return block_id == BLOCK_SAND or block_id == BLOCK_RED_SAND or block_id == BLOCK_GRAVEL + + func is_water_id(block_id: int) -> bool: return block_id == BLOCK_WATER or (block_id >= BLOCK_WATER_FLOW_7 and block_id <= BLOCK_WATER_FLOW_1) diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 468eb06..6e0388d 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -1,6 +1,11 @@ class_name VoxelWorld extends Node3D +## Main-thread streaming telemetry. Worker jobs only write their private slots; +## these snapshots are assembled after completion/commit on the scene thread. +signal stream_progress_changed(progress: Dictionary) +signal initial_stream_ready + const SPAWN_RADIUS := 1 const SPAWN_SEARCH_RADIUS := 12 ## Half the logical cores, capped at 8. Measured on a 16-thread desktop: 8 @@ -29,6 +34,8 @@ const STREAM_BOUNDARY_MARGIN := 0.05 ## putting compression in the active meshing path. const ENABLE_COLD_CHUNK_COMPRESSION := false const COMMIT_BUDGET_MS := 2 +const STREAM_PROGRESS_INTERVAL := 0.15 +const STREAM_PROGRESS_EXTREME_INTERVAL := 1.0 ## 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 ## fewer pixels for those modes instead of freezing their refresh for seconds. @@ -38,6 +45,10 @@ const DEBUG_MAP_HEAVY_MODES: Array[String] = ["height", "raw_height", "slope"] const REBUILD_OPPOSITE_BITS := [2, 1, 8, 4, 128, 64, 32, 16] const WATER_TICK_INTERVAL := 0.25 const WATER_CELLS_PER_TICK := 1024 +## Falling blocks use the same bounded main-thread cadence as water. A block +## advances one cell per tick so a large collapse cannot stall one frame. +const GRAVITY_TICK_INTERVAL := 0.25 +const GRAVITY_CELLS_PER_TICK := 512 const TNT_BLAST_RADIUS := 5 const NUKE_BLAST_RADIUS := 18 ## Fire advances in discrete steps like water. A flammable block is never @@ -101,6 +112,10 @@ var _water_queue: Array[Vector3i] = [] var _water_queued: Dictionary = {} var _water_head := 0 var _water_accum := 0.0 +var _gravity_queue: Array[Vector3i] = [] +var _gravity_queued: Dictionary = {} +var _gravity_head := 0 +var _gravity_accum := 0.0 var _fire_queue: Array[Vector3i] = [] var _fire_queued: Dictionary = {} var _fire_accum := 0.0 @@ -113,6 +128,10 @@ var _burning_queue: Array[Vector3i] = [] var _burning_queued: Dictionary = {} var _fire_overlay: FireOverlay var _worldgen_revision := 0 +var _stream_progress_time := 0.0 +var _initial_stream_center := Vector2i(999999, 999999) +var _initial_stream_active := false +var _initial_stream_complete := false var _max_active_jobs := MIN_ACTIVE_JOBS var _full_jobs_measured := 0 var _lod_jobs_measured := 0 @@ -256,10 +275,20 @@ func _process(delta: float) -> void: if _player == null: return _stream_tick() + _stream_progress_time += delta + var progress_interval := STREAM_PROGRESS_INTERVAL \ + if _desired.size() <= 4096 else STREAM_PROGRESS_EXTREME_INTERVAL + if _stream_progress_time >= progress_interval: + _stream_progress_time = 0.0 + _emit_stream_progress() _water_accum += delta if _water_accum >= WATER_TICK_INTERVAL: _water_accum = 0.0 _water_tick() + _gravity_accum += delta + if _gravity_accum >= GRAVITY_TICK_INTERVAL: + _gravity_accum = 0.0 + _gravity_tick() _fire_accum += delta if _fire_accum >= FIRE_TICK_INTERVAL: _fire_accum = 0.0 @@ -415,6 +444,21 @@ func setup_player(player_node: Node3D, sync_spawn_area := false) -> void: _schedule_jobs() +## Starts the first world entry without synchronously generating a spawn ring. +## Main keeps player input locked until `initial_stream_ready`; all chunk work +## continues through the ordinary nearest-first worker queues. +func begin_initial_stream(player_node: Node3D) -> void: + _player = player_node + _stream_center = _chunk_for_position(player_node.global_position) + _initial_stream_center = _stream_center + _initial_stream_active = true + _initial_stream_complete = false + _stream_progress_time = 0.0 + _rebuild_desired() + _schedule_jobs() + _emit_stream_progress() + + func _stream_tick() -> void: var center := _chunk_for_position(_player.global_position) if center != _stream_center: @@ -429,6 +473,21 @@ func _stream_tick() -> void: # flight waits on a floor remesh that was only queued afterward. _ensure_near_collision() _schedule_jobs() + _update_initial_stream_state() + + +func _update_initial_stream_state() -> void: + if not _initial_stream_active or _initial_stream_complete: + return + for dz in range(-SPAWN_RADIUS, SPAWN_RADIUS + 1): + for dx in range(-SPAWN_RADIUS, SPAWN_RADIUS + 1): + var pos := _initial_stream_center + Vector2i(dx, dz) + var chunk: Chunk = _chunks.get(pos) + if chunk == null or chunk.lod or not _is_chunk_collision_ready(pos): + return + _initial_stream_complete = true + _emit_stream_progress() + initial_stream_ready.emit() ## Distant full chunks are committed without a collision shape. Rebuilds are @@ -1011,6 +1070,7 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi chunk.mask = res.mask if not lod: _seed_fire_edits(pos) + _seed_gravity_edits(pos) 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(): @@ -1336,6 +1396,7 @@ func break_block(block_position: Vector3i) -> int: _record_edit(block_position, BlockRegistry.BLOCK_AIR) _touch_chunk(chunk_position, block_position, block_id, BlockRegistry.BLOCK_AIR) _seed_water(block_position) + _seed_gravity(block_position) return BlockRegistry.canonical_id(block_id) @@ -1369,6 +1430,7 @@ func place_block(block_position: Vector3i, block_id: int, facing: int = BlockReg _record_edit(block_position, placed_id) _touch_chunk(chunk_position, block_position, existing, placed_id) _seed_water(block_position) + _seed_gravity(block_position) return true @@ -1398,6 +1460,7 @@ func _set_placed_block(position: Vector3i, block_id: int) -> void: _record_edit(position, block_id) _touch_chunk(chunk_position, position, existing, block_id) _seed_water(position) + _seed_gravity(position) func _remove_door_part(position: Vector3i) -> void: @@ -1413,6 +1476,7 @@ func _remove_door_part(position: Vector3i) -> void: _record_edit(position, BlockRegistry.BLOCK_AIR) _touch_chunk(_chunk_for_block(position), position, existing, BlockRegistry.BLOCK_AIR) _seed_water(position) + _seed_gravity(position) ## Opens or closes both persisted halves together. Interaction is valid from @@ -1515,6 +1579,7 @@ func carve_sphere(center: Vector3i, radius: int) -> int: door_counterparts[counterpart] = true chunk.data[index] = BlockRegistry.BLOCK_AIR _record_edit_in_chunk(position, BlockRegistry.BLOCK_AIR, chunk_position) + _seed_gravity(position) removed += 1 column_changed = true var dy := y - center.y @@ -1814,6 +1879,7 @@ func _extinguish_fire(position: Vector3i, changed_chunks: Dictionary) -> void: chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) _seed_water(position) + _seed_gravity(position) changed_chunks[_chunk_for_block(position)] = true @@ -1833,6 +1899,7 @@ func _destroy_burnt_block(position: Vector3i, changed_chunks: Dictionary) -> voi chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) _seed_water(position) + _seed_gravity(position) changed_chunks[_chunk_for_block(position)] = true @@ -1847,6 +1914,7 @@ func _remove_door_for_batch(position: Vector3i, changed_chunks: Dictionary) -> i chunk.data[index] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) _seed_water(position) + _seed_gravity(position) changed_chunks[_chunk_for_block(position)] = true return 1 @@ -1928,6 +1996,48 @@ func _seed_water(block_position: Vector3i) -> void: _queue_water(block_position + offset) +## A block update can either place a gravity block or remove/change its support. +## Queue both the changed cell and the cell directly above it; that upper cell is +## the only one in the column whose support can have changed. +func _seed_gravity(block_position: Vector3i) -> void: + _queue_gravity(block_position) + _queue_gravity(block_position + Vector3i.UP) + + +## Rehydrated edits store final voxel states rather than transient queue state. +## Rechecking every edited cell and its upper neighbour resumes interrupted +## falls after reload without depending on dictionary or chunk load order. +func _seed_gravity_edits(pos: Vector2i) -> void: + var bucket: Dictionary = _edits_by_chunk.get(pos, {}) + var positions: Array[Vector3i] = [] + for key in bucket: + positions.append(key) + positions.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: + if a.y != b.y: + return a.y < b.y + if a.z != b.z: + return a.z < b.z + return a.x < b.x + ) + for position in positions: + _seed_gravity(position) + + +func _queue_gravity(position: Vector3i) -> void: + if position.y <= 0 or position.y >= VoxelDefs.WORLD_HEIGHT: + return + if _gravity_queued.has(position): + return + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + _restore_chunk_data(chunk) + if not _blocks.is_gravity_block(chunk.data[_data_index(position)]): + return + _gravity_queued[position] = true + _gravity_queue.append(position) + + func _queue_water(position: Vector3i) -> void: if position.y < 1 or position.y >= VoxelDefs.WORLD_HEIGHT: return @@ -2013,11 +2123,90 @@ func _water_place(position: Vector3i, block_id: int, changed_chunks: Dictionary) return chunk.data[index] = block_id _record_edit(position, block_id) + _seed_gravity(position) changed_chunks[_chunk_for_block(position)] = true for offset in WATER_NEIGHBOR_OFFSETS: _queue_water(position + offset) +## Settles sand, red sand, and gravel after nearby persistent edits. A block +## advances one cell per tick; every moved source and destination is recorded +## before persistence/versioning is batched for the affected chunk ring. +func _gravity_tick() -> void: + var budget := GRAVITY_CELLS_PER_TICK + var changed_chunks: Dictionary = {} + # Snapshot the tail: cells woken by a move wait for the next simulation step. + # Besides making the fall rate stable, this prevents one tall column from + # consuming the whole per-frame budget by repeatedly re-queuing itself. + var tick_end := _gravity_queue.size() + while budget > 0 and _gravity_head < tick_end: + var position: Vector3i = _gravity_queue[_gravity_head] + _gravity_head += 1 + budget -= 1 + _gravity_queued.erase(position) + _update_gravity_cell(position, changed_chunks) + _flush_gravity_changes(changed_chunks) + if _gravity_head == _gravity_queue.size(): + _gravity_queue.clear() + _gravity_head = 0 + elif _gravity_head > 4096: + _gravity_queue = _gravity_queue.slice(_gravity_head) + _gravity_head = 0 + + +func _update_gravity_cell(position: Vector3i, changed_chunks: Dictionary) -> void: + if position.y <= 1: + return + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + _restore_chunk_data(chunk) + var source_index := _data_index(position) + var block_id: int = chunk.data[source_index] + if not _blocks.is_gravity_block(block_id): + return + var destination := position + Vector3i.DOWN + # Falling is vertical, so this is currently the same x/z chunk. Keep the + # resident check explicit so a future lateral reaction cannot treat unknown + # unloaded or compact LOD data as empty space. + var destination_chunk := _loaded_chunk_for(destination) + if destination_chunk == null or destination_chunk.lod: + return + _restore_chunk_data(destination_chunk) + var destination_index := _data_index(destination) + var destination_id: int = destination_chunk.data[destination_index] + if not _is_gravity_passable(destination_id): + return + chunk.data[source_index] = BlockRegistry.BLOCK_AIR + destination_chunk.data[destination_index] = block_id + _record_edit(position, BlockRegistry.BLOCK_AIR) + _record_edit(destination, block_id) + changed_chunks[_chunk_for_block(position)] = true + changed_chunks[_chunk_for_block(destination)] = true + _seed_water(position) + _seed_water(destination) + _queue_gravity(destination) + _queue_gravity(position + Vector3i.UP) + + +func _is_gravity_passable(block_id: int) -> bool: + return block_id == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(block_id) \ + or _blocks.has_flag(block_id, BlockRegistry.FLAG_CROSS) + + +func _flush_gravity_changes(changed_chunks: Dictionary) -> void: + if changed_chunks.is_empty(): + return + 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: + _queue_rebuild(chunk_position, true) + + ## 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. @@ -2280,8 +2469,11 @@ func get_worldgen_stats() -> Dictionary: lod_chunks += 1 else: full_chunks += 1 + var stream := get_streaming_progress() return { "chunks": "%d full / %d lod" % [full_chunks, lod_chunks], + "streamed": int(stream["loaded"]), + "stream total": int(stream["total"]), "pending": _pending.size(), "queued": _gen_queue.size() + _mesh_queue.size(), "generated waiting": _generated.size(), @@ -2294,6 +2486,48 @@ func get_worldgen_stats() -> Dictionary: } +## A cheap scene-thread snapshot used by the entry overlay and compact HUD +## feedback. `loaded` counts only chunks that match the current detail mode, so +## a Full/LOD transition never reports stale terrain as finished streaming. +func get_streaming_progress() -> Dictionary: + var loaded := 0 + for pos in _desired: + var chunk: Chunk = _chunks.get(pos) + if chunk != null and chunk.lod == _chunk_uses_lod(pos): + loaded += 1 + var total := _desired.size() + var spawn_loaded := 0 + var spawn_collision := 0 + var spawn_center := _initial_stream_center if _initial_stream_active else _stream_center + if spawn_center.x == 999999: + spawn_center = Vector2i.ZERO + for dz in range(-SPAWN_RADIUS, SPAWN_RADIUS + 1): + for dx in range(-SPAWN_RADIUS, SPAWN_RADIUS + 1): + var pos := spawn_center + Vector2i(dx, dz) + var chunk: Chunk = _chunks.get(pos) + if chunk != null and not chunk.lod: + spawn_loaded += 1 + if _is_chunk_collision_ready(pos): + spawn_collision += 1 + return { + "loaded": loaded, + "total": total, + "pending": _pending.size(), + "queued": _gen_queue.size() + _mesh_queue.size(), + "generated": _generated.size(), + "commits": _commit_queue.size(), + "spawn_loaded": spawn_loaded, + "spawn_collision": spawn_collision, + "spawn_total": (SPAWN_RADIUS * 2 + 1) * (SPAWN_RADIUS * 2 + 1), + "initial_ready": _initial_stream_complete, + "streaming": total > 0 and loaded < total, + } + + +func _emit_stream_progress() -> void: + stream_progress_changed.emit(get_streaming_progress()) + + ## Starts or polls an asynchronous diagnostic-map request. The expensive noise ## sampling never blocks the main thread; callers receive `pending = true` ## until the immutable worker result is available. From 5e088adde94f031f7d64068bcf83fe8d3441179d Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 19 Sep 2026 17:41:30 +0100 Subject: [PATCH 2/3] Optimize chunk streaming and profile rendering --- .github/workflows/tests.yml | 8 +- ROADMAP.md | 13 +- autoload/game_config.gd | 7 + game/main.gd | 11 +- tools/CHUNK_PERFORMANCE.md | 400 +++++++++++++++++ tools/OPTIMIZATION_CHANGESETS.md | 68 +++ tools/block_physics_verify.gd | 18 + tools/chunk_data_compression_verify.gd | 3 + tools/chunk_priority_verify.gd | 73 +++ tools/chunk_priority_verify.gd.uid | 1 + tools/frame_pacing_verify.gd | 8 + tools/gameplay_render_profile.gd | 335 ++++++++++++++ tools/gameplay_render_profile.gd.uid | 1 + tools/gameplay_render_profile.tscn | 6 + tools/lod_batch_render_profile.gd | 179 ++++++++ tools/lod_batch_render_profile.gd.uid | 1 + tools/lod_batch_render_profile.tscn | 6 + tools/lod_batch_verify.gd | 160 +++++++ tools/lod_batch_verify.gd.uid | 1 + tools/mesh_parity_verify.gd | 124 +++++ tools/mesh_parity_verify.gd.uid | 1 + tools/player_target_verify.gd | 1 + tools/population_benchmark.gd | 127 ++++++ tools/population_benchmark.gd.uid | 1 + tools/population_pruning_verify.gd | 341 ++++++++++++++ tools/population_pruning_verify.gd.uid | 1 + tools/stream_full_verify.gd | 18 +- tools/stream_transition_verify.gd | 101 +++++ tools/stream_transition_verify.gd.uid | 1 + tools/ui_flow_verify.gd | 15 + tools/world_storage_verify.gd | 4 + ui/settings_category_panel.gd | 14 +- world/chunk_mesher.gd | 92 +++- world/voxel_world.gd | 596 +++++++++++++++++++++---- world/worldgen/README.md | 23 + world/worldgen/voxel_populator.gd | 46 +- 36 files changed, 2693 insertions(+), 112 deletions(-) create mode 100644 tools/CHUNK_PERFORMANCE.md create mode 100644 tools/OPTIMIZATION_CHANGESETS.md create mode 100644 tools/chunk_priority_verify.gd create mode 100644 tools/chunk_priority_verify.gd.uid create mode 100644 tools/gameplay_render_profile.gd create mode 100644 tools/gameplay_render_profile.gd.uid create mode 100644 tools/gameplay_render_profile.tscn create mode 100644 tools/lod_batch_render_profile.gd create mode 100644 tools/lod_batch_render_profile.gd.uid create mode 100644 tools/lod_batch_render_profile.tscn create mode 100644 tools/lod_batch_verify.gd create mode 100644 tools/lod_batch_verify.gd.uid create mode 100644 tools/mesh_parity_verify.gd create mode 100644 tools/mesh_parity_verify.gd.uid create mode 100644 tools/population_benchmark.gd create mode 100644 tools/population_benchmark.gd.uid create mode 100644 tools/population_pruning_verify.gd create mode 100644 tools/population_pruning_verify.gd.uid create mode 100644 tools/stream_transition_verify.gd create mode 100644 tools/stream_transition_verify.gd.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c8921f..251c4a4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,8 +10,6 @@ name: PR tests # only becomes available once the file is on the default branch. # # Known follow-ups before making this required: -# - tools/stream_full_verify.gd caps its wait at 240 * 0.25s (60s); slow -# (4 vCPU) runners can exceed that and fail spuriously. Raise MAX_WAIT_TICKS. # - The parse check only walks scripts the editor loads; an unreferenced # broken script needs a dedicated scanner (load() + can_instantiate()). on: @@ -61,6 +59,9 @@ jobs: run: | bash .github/scripts/run_checks.sh \ tools/unit_tests.gd \ + tools/chunk_priority_verify.gd \ + tools/lod_batch_verify.gd \ + tools/mesh_parity_verify.gd \ tools/asset_license_verify.gd \ tools/audio_verify.gd \ tools/weather_verify.gd \ @@ -112,6 +113,8 @@ jobs: run: | bash .github/scripts/run_checks.sh \ tools/worldgen_verify.gd \ + tools/population_benchmark.gd \ + tools/population_pruning_verify.gd \ tools/worldgen_water_verify.gd \ tools/worldgen_ore_verify.gd \ tools/worldgen_structure_verify.gd \ @@ -179,6 +182,7 @@ jobs: run: | bash .github/scripts/run_checks.sh \ tools/stream_full_verify.gd \ + tools/stream_transition_verify.gd \ tools/stream_soak_verify.gd \ tools/lod_mode_verify.gd \ tools/chunk_data_compression_verify.gd \ diff --git a/ROADMAP.md b/ROADMAP.md index 3ce4f51..e35c3d5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ 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. 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] 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 pinned RD10 Full Detail verification loads 441/441 chunks in 7.66-7.89 s; Balanced loads 289 full + 152 compact chunks. The older RD32 ~155 s / RD16 ~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 @@ -23,7 +23,7 @@ Property names and file references are included so each item is easy to find. - [x] Play and Settings information architecture — Play opens a world setup screen (world type, seed, clipboard import, Create Game) whose Advanced button opens the tunables-only world gen screen; Settings opens a category hub (Display, Graphics, Sound, Gameplay) where Advanced Graphics is itself a hub split into Lighting, Shadows, Sky & Atmosphere, Post-Processing, and Performance section panels. Cancel unwinds one layer at a time with focus restoration, and the pause menu reuses the hub. `tools/ui_flow_verify.gd` covers the flows - [x] Compact HUD — hotbar slots 76 -> 48 px with smaller icons, key hints, and counts, coords/stats chips reduced to ~150 px through the shared compact chip style, and status/selection labels moved above the smaller hotbar - [x] E opens the inventory — `inventory` action rebound from I; main-menu footer and docs updated -- [x] Loading and world-creation progress — world entry now streams asynchronously behind a themed `LoadingOverlay`, keeps player/photo input locked until the authoritative 3x3 collision ring is ready, recovers invalid saved positions only after that ring commits, and leaves compact loaded/total progress in the HUD while the remaining horizon streams. Extreme-distance telemetry is throttled to avoid repeatedly scanning the full desired square. `tools/loading_progress_verify.gd` covers the presentation and entry-lock wiring +- [x] Loading and world-creation progress — world entry now streams asynchronously behind a themed `LoadingOverlay`, keeps player/photo input locked until the authoritative 3x3 collision ring is ready, recovers invalid saved positions only after that ring commits, and leaves compact loaded/total progress in the HUD while the remaining horizon streams. Loaded progress is maintained incrementally rather than scanning the full desired square. `tools/loading_progress_verify.gd` covers the presentation and entry-lock wiring - [ ] Chat / command console — `/time`, `/weather`, `/tp`, `/give`, `/seed`, `/gamemode`, and `/help` for testing and moderation; reuses `DayNightCycle.set_time()`, `WeatherSystem.toggle()`, and `GameConfig.world`. Future mode changes must be command-only; there is currently no way to switch an existing world's mode in-game. - [x] Death and respawn flow — `SurvivalUI` presents a focused respawn/menu modal; respawn streams and validates `Player.spawn_position` before resuming. Saved deaths reopen the prompt. - [x] Photo mode and HUD hiding — F1 hides the HUD, F2 saves a HUD-free PNG to `user://screenshots/` (every CanvasLayer, so modals cannot leak into the frame), and P swaps in a detached free camera (`game/photo_mode.gd`, built on the `game/shadow_capture.gd` precedent: opt-in node added by `Main`, own key handling, `status_requested` feedback, `user://` output). The free camera inherits the player camera pose/FOV, flies with WASD/SPACE/SHIFT/CTRL, zooms with the wheel, and P/Esc return to the player. While active, `Main` follows it with chunk streaming (entry streams around the camera asynchronously; exit sync-commits the player's 3x3 ring before unfreezing so a long flight cannot drop the player), rain/cover, underwater grading, and the coords readout, and `Player.set_photo_mode()` freezes input and hides the targeting highlight and held block; entering photo mode hides the HUD, minimap, and F3 worldgen overlay, and exiting restores the pre-entry HUD state (an F1 press inside photo mode persists). `tools/photo_mode_verify.gd` covers HUD state, camera handoff, look/movement, player artifact and UI-layer hiding, and screenshot naming; `_get_shadow_capture_state()` records HUD/camera mode @@ -55,8 +55,11 @@ Property names and file references are included so each item is easy to find. - [x] Weather depth — thunder/lightning, snow in cold biomes, wind sway on leaves — `WeatherSystem` replaces rain with snow in cold biomes and schedules distant lightning for uncovered non-cold rain, surfaced through `lightning(strength)`; `Main` flashes `DayNightCycle.trigger_lightning()` (additive sun/ambient/fog lift that decays over a few frames) and plays thunder. Wind sway writes a per-block weight into vertex color alpha (`ChunkMesher._wind`: leaves 1.0, non-emissive cross foliage 0.6, solid 0) and `block.gdshader` displaces those vertices with a height anchor driven by the `wind_strength` global (rain raises it); `tools/weather_verify.gd` covers biome precipitation, the snow/mist fields, the lightning schedule, the flash decay, and the shader/global wiring ## Geometry / materials +- [x] Closest-first dispatch and emitter scanning — generation/mesh min-heaps share current-chunk, immediate-ring, collision-ring, then nearest-distance priority; ready far meshes no longer block nearer generation, and queued priorities rebuild on recenter. Native byte searches replace interpreted emitter discovery with a bounded dense fallback, preserving light hashes. RD10 settled samples are 7.66-7.89 s; async entry's safe ring measured 346 ms on the pinned fixture. `tools/chunk_priority_verify.gd` pins ordering and promotion; details remain in `tools/CHUNK_PERFORMANCE.md` +- [x] Population CPU follow-up — conservative footprint rejection removes out-of-chunk decoration/POI terrain queries, and exact cave-noise short-circuiting avoids irrelevant samples. Paired old/new population samples measure 2.22-2.26x faster with identical data and height bounds; a final RD10 Full Detail run reached 8.17 s (timings vary with load). `tools/population_benchmark.gd` pins twelve baseline hashes; `tools/population_pruning_verify.gd` guards stamp bounds and sampler pruning. Details and limitations are in `tools/CHUNK_PERFORMANCE.md` +- [x] Chunk streaming stall/performance investigation — settings-menu pause no longer stops terrain generation/commit scheduling; simulation remains paused. Distance changes are debounced, desired-ring enumeration is O(radius^2), distant urgent-queue scans are bounded, and face/AO strides are precomputed without changing mesh output. Ryzen 7 7700 samples: full mesh 77.18 -> 61.63 ms; actual RD10 Full Detail 12.00 -> 10.96-11.48 s at eight workers. `tools/stream_transition_verify.gd` and `tools/mesh_parity_verify.gd` cover the fixes; measurements, worker sweep, rejected experiments, and remaining GPU/large-distance work are in `tools/CHUNK_PERFORMANCE.md` - [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] Experimental compact LOD render batching — Display settings expose a default-off toggle that aggregates eligible opaque Balanced-LOD chunks into render-only 2x2 `ArrayMesh` groups. Full Detail, terrain ownership, edits, neighbor snapshots, collision, and per-chunk transparent water remain untouched. Stale/missing/unloaded/mode-transition members restore source meshes before their aggregate hides, and oversized 16,384-vertex/24,576-index groups safely remain individual. Aggregates defer behind commits/collision-ring work, use one main-thread upload per frame within a soft remaining commit budget, and refill from a bounded desired-grid cursor after the 64-group queue drains. Normal Forward+ RX 5700 XT fixture draws fell 8.0% (RD10), 30.3% (RD16), 40.0% (RD24), but RD24 p50/p95/p99 regressed ~8–12%, so it stays experimental/off by default. `tools/lod_batch_verify.gd` and `tools/lod_batch_render_profile.tscn`; exact method/results in `tools/CHUNK_PERFORMANCE.md` - [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 @@ -209,8 +212,8 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [x] GitHub Actions runners — `.github/workflows/opencode-pr.yml` runs the advisory opencode review on every non-draft PR, and `.github/workflows/tests.yml` runs the parse check plus the `tools/` verifiers as parallel `fast-checks` / `worldgen-core` / `worldgen-regression` / `streaming` shards on every PR and PR push. No Nix/devenv setup: the checksum-verified Redot 26.2 release is installed and cached by `.github/actions/setup-redot`, and `.github/scripts/run_checks.sh` gives each check its own process, timeout, log, and step-summary row - [ ] Unit tests (branch protection pending) — the dependency-free `tools/unit_tests.gd` runner now covers pinned worldgen fields/hashes, `BlockRegistry` byte/layer packing, water-level inverses, and mesher/AO fixtures (10 tests / 676 assertions), and runs in the existing `fast-checks` shard under the aggregate `PR tests` gate. Repository work is complete; a GitHub administrator still needs to configure `PR tests` as the only required branch-protection status - [x] AI code review (advisory) — `.github/workflows/opencode-pr.yml` runs the pinned `anomalyco/opencode/github` action on every non-draft PR from this repository plus manual dispatch, using `.github/prompts/pr-review.md` (RedotCraft rubric: worker-thread safety, streaming invariants, determinism, water rules, GDScript/shaders, UI signals) with GLM 5.3 and prior bot reviews fed back as context. Helper steps are inlined (PR context, previous reviews, permissions, prompt loading, diagnostics artifact) and there is no Nix/devenv setup; advisory only, never a required status check -- [x] Parse check and headless verifiers in CI — `.github/workflows/tests.yml` runs `redot --editor --headless --path . --quit` plus `worldgen_verify`, `worldgen_biome_verify`, `worldgen_river_verify`, `worldgen_lod_verify`, `worldgen_tree_verify`, `worldgen_cactus_verify`, `audio_verify`, `ui_flow_verify`, `frame_pacing_verify`, `stream_full_verify`, and `player_target_verify.tscn` behind the `PR tests` aggregate job, which is designed to be the single required status check. Advisory until branch protection requires it; remaining hardening (streaming wait budget, unreferenced-script parse scan, unit runner) is tracked in the workflow header +- [x] Parse check and headless verifiers in CI — `.github/workflows/tests.yml` runs `redot --editor --headless --path . --quit` plus the dependency-free unit runner and sharded gameplay, worldgen, persistence, streaming, performance-parity, and UI verifiers behind the `PR tests` aggregate job, which is designed to be the single required status check. Advisory until branch protection requires it; the remaining unreferenced-script parse-scan hardening is tracked in the workflow header - [ ] Benchmark guardrails — assert the pinned generation/mesh/streaming budgets in CI; `tools/worldgen_benchmark.gd` and friends record times but nothing enforces them -- [x] Migration tests — `tools/world_storage_verify.gd` now loads literal sparse metadata fixtures for worldgen v1-v14, pins a SHA-256 chunk output per historical version, migrates handcrafted negative-coordinate region v1 bytes to deterministic v2 bytes, exercises session v0/v1/current migrations, and rejects malformed/future metadata, session, and region formats while retaining backup recovery coverage +- [x] Migration tests — `tools/world_storage_verify.gd` now loads literal sparse metadata fixtures for worldgen v1-v14, pins a SHA-256 chunk output per historical version, migrates handcrafted negative-coordinate region v1 bytes to deterministic v2 bytes, exercises session v0/v1/current migrations, and rejects malformed/future metadata, session, and region formats while retaining backup recovery coverage. Unsupported future session state is treated as read-only so autosave cannot replace newer player/inventory metadata with defaults - [ ] Replay regression harness — extend the F9 `game/shadow_capture.gd` bundle into a general deterministic replay so reported bugs reproduce from a saved bundle - [x] Asset and license check — `tools/asset_license_verify.gd` deterministically audits shipped PNG/OGG naming, source references, committed import metadata/cache availability, and required attribution/license documents in headless CI. Runtime-generated and intentionally unused placeholder assets are explicitly excluded; display-only `tools/icon_check.gd` remains a separate rendered check diff --git a/autoload/game_config.gd b/autoload/game_config.gd index 9170ad8..2b1ee38 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -5,6 +5,9 @@ const SETTINGS_PATH := "user://settings.cfg" const DEFAULT_SETTINGS := { "render_distance": 10, "lod_mode": 0, + # Experimental render-only aggregation for compact Balanced LOD chunks. Keep + # this opt-in until representative target-GPU profiling clears it as a win. + "lod_batching": false, "extreme_render_distance": false, "fov": 76.0, # Kept as the migration source for settings.cfg files written before camera @@ -512,6 +515,10 @@ func get_lod_mode() -> int: return clampi(int(settings.get("lod_mode", LOD_MODE_FULL)), LOD_MODE_FULL, LOD_MODE_BALANCED) +func get_lod_batching_enabled() -> bool: + return bool(settings.get("lod_batching", DEFAULT_SETTINGS["lod_batching"])) + + 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 bbb6bd3..e00a0a4 100644 --- a/game/main.gd +++ b/game/main.gd @@ -79,6 +79,7 @@ var _dynamic_resolution_active := false var _world_storage: WorldStorage var _autosave_time := 0.0 var _storage_warning_shown := false +var _session_state_read_only := false var _game_mode: int = GameMode.SURVIVAL var _loading_overlay: LoadingOverlay var _world_entry_waiting := false @@ -364,6 +365,7 @@ func _update_frame_pacing(delta: float) -> void: func _apply_config() -> void: var render_distance := GameConfig.get_render_distance() world.configure(GameConfig.world, render_distance, GameConfig.get_lod_mode()) + world.set_lod_batching_enabled(GameConfig.get_lod_batching_enabled()) _apply_graphics() _update_camera_far(render_distance) @@ -387,12 +389,14 @@ func _prepare_world_storage() -> Dictionary: func _migrate_session_state(value: Variant) -> Dictionary: + _session_state_read_only = false if typeof(value) != TYPE_DICTIONARY: return {} var state: Dictionary = value.duplicate(true) var version := int(state.get("state_version", 0)) if version > SESSION_STATE_VERSION: push_warning("Save uses unsupported session state version %d" % version) + _session_state_read_only = true return {} # Keep the source version until inventory restoration has migrated its rows. return state @@ -447,10 +451,11 @@ func _flush_world_save() -> bool: if _world_storage == null or player == null: return false var save_error := world.flush_edit_store() - if save_error == OK: + if save_error == OK and not _session_state_read_only: save_error = _world_storage.flush(_build_persistent_state()) if save_error == OK: - GameConfig.active_world_metadata = _world_storage.metadata.duplicate(true) + if not _session_state_read_only: + GameConfig.active_world_metadata = _world_storage.metadata.duplicate(true) if not _storage_warning_shown and _world_storage.unreadable_region_count() > 0: _storage_warning_shown = true set_status("A damaged world region is read-only; edits there cannot be saved") @@ -871,6 +876,8 @@ func _on_setting_changed(key: String, value: Variant) -> void: _update_camera_far(render_distance) "lod_mode": world.set_lod_mode(int(value)) + "lod_batching": + world.set_lod_batching_enabled(bool(value)) "fov": player.set_fov(float(value)) "graphics_preset": diff --git a/tools/CHUNK_PERFORMANCE.md b/tools/CHUNK_PERFORMANCE.md new file mode 100644 index 0000000..eb9ccf1 --- /dev/null +++ b/tools/CHUNK_PERFORMANCE.md @@ -0,0 +1,400 @@ +# Chunk streaming investigation + +Measured on the Ryzen 7 7700 (8 cores / 16 logical CPUs) with the installed +Redot 26.2 engine. These are headless CPU/streaming measurements, not GPU FPS +claims. Benchmark processes ran serially. Timing varies with machine load. + +## Confirmed stall + +Changing distance from the pause menu rebuilt the desired queues while the +paused scene tree prevented `VoxelWorld._process()` from collecting, committing, +or scheduling work. The flat-world regression remained at 25/49 chunks with +24 queued jobs for the entire 20-second timeout. Resuming allowed it to recover; +this investigation did not reproduce a permanent post-resume queue deadlock. + +Streaming now runs while paused; water, gravity, and fire clocks remain frozen. +The regression's paused expansion completes in about 0.2 seconds for flat terrain +and 0.7 seconds for normal terrain. Distance-slider changes are coalesced over +200 ms and flushed when the settings panel closes. + +## Retained optimizations + +- Enumerate each desired ring's perimeter directly. The old nested square scan + visited every interior cell on every ring, taking O(radius^3) work despite its + nearest-first ordering. Queue construction now takes O(radius^2). +- Check bounded near-queue membership before searching large distant queues for + urgent work. This avoids scanning up to 40,401 entries every scheduling tick + when no urgent job exists, without changing urgent queue ordering. +- Maintain the current-mode loaded count during desired rebuilds, commits, and + unloads. Progress updates no longer scan the entire desired square. +- Exclude non-desired retained chunks from collision-repair scheduling, and skip + obsolete queue entries before submission. +- Precompute immutable face and AO linear strides; avoid repeated per-corner + coordinate arithmetic and redundant horizontal bounds checks. +- Resolve cube tint only when a visible face exists, avoid temporary cube-face + index/collision arrays, and skip collision vertex reads for non-collision jobs. + +Worker snapshots remain independent copies. Full Detail still generates full +chunks through the requested distance; Balanced LOD remains explicitly opt-in. +No terrain, lighting, collision, or draw-distance quality was reduced. + +## Measurements + +The four-fixture `worldgen_mesh_benchmark.gd` uses empty neighbor sets; its mesh +timings must not be substituted for real streaming timings. + +| CPU mesh measurement | Before | After | +| --- | ---: | ---: | +| Full mesh average | 77.18 ms | 61.63 ms | +| Face emission average | 61.38 ms | 46.23 ms | +| Compact LOD mesh average | 2.58 ms | 2.45 ms | + +Full meshing is about 20% cheaper on these fixtures. Nine exact SHA-256 fixtures +pin normal, missing, emissive, and compact neighbors, collision on/off, LOD, +all mesh attributes, and full light arrays in `mesh_parity_verify.gd`. + +Actual `stream_full_verify.gd`, seed 918273, RD10 Full Detail, 441 chunks with +169 near collision shapes: + +| Normal worker limit (+1 urgent permitted) | Settled load | +| --- | ---: | +| 8, before changes | 12.00 s | +| 4, after changes | 13.28 s | +| 8, after changes, repeated final runs | 10.96-11.48 s | +| 12, after changes | 10.97 s | +| 16, after changes | 10.70 s | + +Keep the production limit at 8: extra workers offered only a small gain in this +sample and increased measured main-thread work. This is roughly a 4-9% reduction +in settled load time at the normal limit, not a 10x improvement. The timer polls +settlement every 250 ms, so differences of that size need repeated runs. + +The final 8-worker traces submitted 432 generation jobs (the verifier's first +nine spawn chunks are synchronous), 448 mesh jobs, and discarded zero results. +Aggregate worker generation elapsed time was 38.5-41.7 s, versus 19.3-19.5 s +meshing. These totals overlap across workers and are not wall time or measured +CPU utilization. Main-thread streaming work totalled about 1.41 s, including +about 0.99 s in commits. Generation/population is therefore the larger remaining +worker cost on this stream, not just meshing or the number of threads. + +## Rejected experiments and remaining work + +- Per-face resize/indexed writes were slower than direct packed-array pushes. +- An enclosed-cube fast path was within timing noise and was not retained. +- Reusing terrain warp/profile inputs measured 74.42 ms full generation versus + 74.06 ms before; the small compact gain did not justify retaining the change. +- Do not remove neighbor snapshot copies merely to reduce allocations: generated + data can become live editable chunk data while dependent mesh jobs still run. +- The population follow-up below measures caves, vegetation, and structure + subphases and removes queries for candidates that cannot affect the chunk. +- GPU draw calls, rendered frame-time percentiles, and RD16/RD32/Extreme resource + budgets still require separate measurements. Chunk batching and horizon work + are separate roadmap features, not part of this correctness fix. +- Running obsolete workers still finish before their results are rejected. + Cooperative cancellation is a future option, but needs a thread-safe contract; + unsafe shared mutable cancellation state is not an acceptable shortcut. + +## Reproduction + +```sh +redot --editor --headless --path . --quit +redot --headless --path . --script res://tools/stream_transition_verify.gd +redot --headless --path . --script res://tools/stream_transition_verify.gd -- --normal +redot --headless --path . --script res://tools/worldgen_mesh_benchmark.gd +redot --headless --path . --script res://tools/stream_full_verify.gd +redot --headless --path . --script res://tools/stream_full_verify.gd -- --workers=16 +redot --headless --path . --script res://tools/mesh_parity_verify.gd +``` + +`get_worldgen_stats()` now exposes submitted generation/mesh counts, discarded +results, active-worker limits, aggregate worker generation/mesh time, and total +main-thread streaming/commit time. Counters are cumulative for the world instance. + +## Population follow-up + +`population_benchmark.gd` measures the full population pipeline in production +order over twelve pinned chunks (two seeds, three repetitions). It checks each +profiled result against `populate()` and twelve preoptimization golden hashes. +The fixtures cover plains, river, highlands, and deep ocean; forest/taiga/jungle/ +swamp density and seams are covered separately by the biome/tree verifiers. + +The largest cost was terrain sampling for decoration candidates whose eventual +stamps could not reach the current chunk. Sparse POI candidates can be almost +an entire 160-block owner cell away. Checking the conservative write/clear +footprint before terrain/site queries removes that work without changing any +accepted overlapping candidate, stamp order, density, or worldgen version. +Underwater tufts get a second point-in-chunk check before querying their ground. + +Noise caves additionally short-circuit the second ridge when the first already +rejects a tunnel, and skip cheese noise for cells already carved by spaghetti. +Height-dependent thresholds are computed once per call using float64 arrays; +all scratch remains local to the worker job and the noise sources are immutable. + +| Population phase average | Before | Final sample | +| --- | ---: | ---: | +| Surface decoration | 20.18 ms | 4.59 ms | +| Underwater decoration | 5.60 ms | 0.55 ms | +| Structures | 5.22 ms | 0.02 ms | +| Noise caves | 5.78 ms | 4.47 ms | +| All population phases | 49.23 ms | 22.12 ms | + +To reduce the effect of changing machine load, the benchmark can alternate the +old and new production `populate()` in the same process against identical fields: + +```sh +redot --headless --path . --script res://tools/population_benchmark.gd -- --compare-ref=f7404af +``` + +This optional local-only mode reads the old GDScript from that git revision and +checks old/new data and max-height equality on every invocation. CI does not need +git history: it runs the normal golden-fixture mode and footprint verifier. +Paired results: **54.75 -> 24.18 ms (2.26x)** and +**49.50 -> 22.27 ms (2.22x)**. This is population speedup, not whole-game speedup. + +RD10 Full Detail reached **8.17 s** on two final normal-load runs, compared with +10.96-11.48 s after the first optimization pass and the original 12.00 s. +Earlier follow-up runs were 9.93/9.96 s, with a slower 12.49 s run during machine +load variation (unmodified phases also slowed). These are not fixed performance +guarantees. The final traces retained 441 chunks, 169 collision shapes, and 0-2 +discarded results; worker generation total dropped to 23.23-23.28 s versus the first +pass's 38.5-41.7 s, with comparable mesh-worker time (18.69 s). + +`population_pruning_verify.gd` exercises inclusive bounds, negative coordinates, +all land/underwater stamp extents, structure clear bounds, sampler-call pruning, +legacy catalogs, and full/compact max-height invariants. Historical v1-v14 hashes, +tree/biome density, POI, LOD, water, and the fourteen-corner worldgen config sweep +also passed. No terrain version bump or reduction in world content is needed. + +## Closest-first scheduling follow-up + +Replaced dispatch arrays with min-heaps, retaining separate generation and mesh +membership maps. Both stages now compare the same priorities before submission: + +1. The player's current stream-center chunk. +2. The immediate 3x3 ring, with local edited-owner feedback ahead of neighbor work. +3. The remaining collision-radius ring, nearest first; far edits cannot bypass it. +4. Distant chunks, nearest first. + +Squared distance orders each tier and coordinates break ties deterministically. +Ready meshes win equal priorities, but a farther mesh no longer bypasses nearby +generation just because it completed its terrain stage first. Camera/player +re-centering rebuilds priorities. Photo mode continues to follow its active +camera, and the single urgent overflow worker limit is unchanged. This prioritizes +dispatch, not strict completion order: existing worker jobs cannot be preempted, +and a chunk's mesh still needs its terrain-neighbor snapshots. + +Heap construction is O(N); push/pop are O(log N), except rare edit promotions +which locate an existing entry before moving it upward. This replaces repeated +front-of-array shifts and queue scans, particularly useful at Extreme distances. + +Also replaced the interpreted full light-volume emitter scan with native packed +byte searches. Source indices are sorted to preserve exact flood seeding order. +At 4096 hits the code switches back to the original spatial scan, bounding index +scratch to 4096 integers and avoiding a large dense-emitter sort. Sparse and +dense mixed-emitter fixtures retain exact light/output hashes. + +Measured full-mesh average: **61.63 -> 58.00 ms**; the sampled block-light phase +fell from **4.13 -> 0.36 ms**. Settled RD10 Full Detail now measured **7.66-7.89 s** +on the original synchronous-spawn verifier, versus 8.17 s after population work. +The additional asynchronous entry run measured **7.97 s** for all 441 chunks, +with its 3x3 collision-safe ring ready at **345.7 ms**, recorded directly from +`initial_stream_ready`. This excludes application/asset startup and is one pinned +world fixture, not a promise for every world or a GPU frame-rate measurement. + +```sh +redot --headless --path . --script res://tools/chunk_priority_verify.gd +redot --headless --path . --script res://tools/stream_full_verify.gd -- --async-start +``` + +Priority ordering, duplicate suppression, edit promotion, recentering, Extreme +heap invariants, live LOD/distance changes, fast-flight soak, collision targeting, +explosions, fire, gravity, and mesh/light parity passed regression checks. + +## Compact LOD render batching + +Balanced LOD (already an explicit terrain-detail opt-in) can aggregate eligible +**opaque** compact chunks into aligned 2x2 `ArrayMesh` render groups. It never +creates terrain outside `render_distance`, never changes Full Detail, and never +turns full chunks into compact data. Each original chunk retains its data, edits, +neighbour snapshots, water mesh, and collision ownership; the aggregate only +hides its four opaque source `MeshInstance3D`s. Water stays per chunk because +combining transparent water would change transparent ordering/culling behaviour. + +An aggregate is invalidated before a member commits, unloads, or crosses the +LOD/full boundary. Missing or stale members render their source meshes instead, +so a batch can never make a hole. It hides the old aggregate before restoring +source meshes, avoiding one-frame overlap during deferred `queue_free()`. +Building is scene-thread-only (no worker task, no collision body), capped at one +upload per frame, and only starts after all commits and collision-ring work are +settled. A soft remaining-commit-budget guard avoids beginning its nonpreemptible +upload late in a streaming frame. Groups over 16,384 vertices or 24,576 indices +remain individual meshes. Rejected groups are not repeatedly retried until a +member changes or the stream configuration is rebuilt. + +The pending queue is capped at 64 nearest groups. That cap is not a permanent +coverage limit: a cursor scans the existing desired grid in 12 bounded candidates +per frame and revisits skipped eligible groups after foreground work/nearer uploads +drain, without retaining an unbounded candidate list. Once a full scan finds no +remaining candidates and the queue drains, batching sleeps until chunk changes +wake it, avoiding steady-state scanning overhead. Experimental LOD batching +defaults **off**, is exposed in Display settings, and applies only when the player +also selects Balanced LOD; Full Detail is unaffected. `lod_batch_verify.gd` checks +source exclusivity, custom attributes, foreground commit deferral, invalidation, +no batch collision node, default state, refill, and bounds. + +### Rendered Forward+ A/B (2026-09-19) + +`lod_batch_render_profile.tscn -- --normal --rd24` ran with installed Redot 26.2, +Vulkan Forward+, X11, **AMD Radeon RX 5700 XT (RADV NAVI10)**, normal seed +**918273**, a fixed 1280×720 viewport/camera/render parameters, Balanced LOD, +V-Sync/FPS cap disabled, 30 warmup + 120 sampled rendered frames per phase. It +runs **off → on → off control** at every distance. `RenderingServer` +draw-call/primitives counters are collected after `frame_post_draw`; frame time +is wall-clock rendered-frame interval, not a GPU timestamp query. Runs were +serial. + +| Render distance | Batching | Draw calls/frame | Primitives/frame | p50 / p95 / p99 ms | +| --- | --- | ---: | ---: | ---: | +| 10 | Off | 648 | 1,200,924 | 1.92 / 2.34 / 2.35 | +| 10 | On (19 groups) | 596 | 1,205,752 | 1.93 / 2.21 / 2.37 | +| 10 | Off control | — | — | 1.93 / 2.32 / 2.37 | +| 16 | Off | 1,453 | 1,769,020 | 2.48 / 2.83 / 2.96 | +| 16 | On (175 groups) | 1,013 | 1,791,940 | 2.34 / 2.75 / 2.88 | +| 16 | Off control | — | — | 2.33 / 2.78 / 2.82 | +| 24 | Off | 3,067 | 2,870,816 | 3.25 / 3.48 / 3.64 | +| 24 | On (495 groups) | 1,840 | 2,901,186 | 3.52 / 3.91 / 3.94 | +| 24 | Off control | — | — | 3.26 / 3.53 / 3.66 | + +The feature reduced draws by 8.0%, 30.3%, and 40.0%. The 2x2 culling bound +submitted 0.4–1.3% more primitives. RD16 improved across this one sample's +percentiles, but RD10 was mixed and RD24 regressed about 8–12% despite fewer +draws; the off control tracks the original baseline. It is therefore explicitly +experimental/off by default, with an in-game Display setting and +`set_lod_batching_enabled(false)` fallback. These numbers are one driver/GPU/ +scene fixture, not portable GPU timing claims. Re-run target hardware rather +than extrapolating headless streaming timings. + +Repeat after removing idle scanning (same configuration and counters): + +| RD | Off p50/p95/p99 ms | On p50/p95/p99 ms | Off-control p50/p95/p99 ms | +| --- | --- | --- | --- | +| 10 | 1.93 / 2.33 / 2.35 | 1.93 / 2.34 / 2.40 | 1.92 / 2.33 / 2.34 | +| 16 | 2.33 / 2.73 / 2.73 | 2.58 / 2.98 / 3.13 | 2.32 / 2.71 / 2.73 | +| 24 | 3.46 / 4.02 / 4.82 | 3.28 / 3.49 / 3.56 | 3.30 / 3.57 / 3.69 | + +The draw reductions reproduce, but frame-time gains do not: RD16 regressed in +this repeat, while RD24's on phase was close to its warmed off control. This +reinforces the default-off decision; fewer draws alone are not proof of faster +rendering. Full Detail remained at 441/441 chunks with 169 collision shapes in +the follow-up headless verifier (7.89 s on that run). + +```sh +redot --headless --path . --script res://tools/lod_batch_verify.gd +redot --path . res://tools/lod_batch_render_profile.tscn -- --normal +redot --path . res://tools/lod_batch_render_profile.tscn -- --normal --rd24 +``` + +## Rendered gameplay bottleneck profile (separate from loading and batching) + +The CPU/loading measurements above remain headless worker/streaming evidence. +They do **not** imply an in-game FPS improvement. Likewise, the compact-LOD +batching experiment above remains opt-in/default-off and is not part of this +profile. The profiler below forces Full Detail and unbatched rendering at both +distances, so it cannot be used to claim a batching win. + +`gameplay_render_profile.tscn` instantiates the actual `game/main.tscn`, then +sets an isolated `Main` subclass as its root script *before it enters the tree*. +The subclass overrides the inspected `_prepare_world_storage() -> Dictionary`, +`_flush_world_save() -> bool`, `_exit_tree() -> void`, and +`_build_first_run_hints() -> void` hooks. It therefore performs ordinary Main +startup (world, player, HUD, sky, sun, weather, post-processing, and streaming) +without creating/opening/flushing a world, saving `settings.cfg`, or recording +onboarding completion. Fixture values are in-memory only and restored before +exit. + +### Method + +- Installed Redot 26.2, Forward+, Vulkan, AMD Radeon RX 5700 XT (RADV NAVI10). +- Normal world, seed `918273`; fixed noon (`12.0`), parked camera, 1280×720 + window, default **Medium** preset, Full Detail, and batching off. +- Player physics/input and photo-camera input are disabled during profiling. + Camera placement uses the generator's deterministic spawn, not the adjusted + safe spawn chosen from resident chunks; its transform is printed in the log. +- Medium's 3D scale is 0.77. The engine warned that FSR2 is incompatible with + TAA and disabled TAA internally; the profile reports the configured viewport + property as `TAA requested=true`, not proof that separate TAA ran. +- Each phase has 60 rendered-frame warmup frames and 120 sampled frames. The + profile asks `RenderingServer` to measure this viewport, reads its CPU/GPU + render-time values after `frame_post_draw`, and separately reports wall-frame + intervals and rendered draw/primitives counters. +- One production effect is toggled at a time in **off → on → off-control** + order (SSAO/SSIL are one production setting). A numeric GPU result was + returned by this driver; a backend that supplies `0.0` is reported as + `reported-zero`, not silently treated as an unavailable timer. Missing engine + timing APIs are reported as unavailable. Wall time is not a GPU timestamp. + +### Results + +`p50/p95`, milliseconds. GPU measurements were stable through each repeated +off control; viewport CPU values were below GPU values in the steady reference +frames. This fixture is GPU-bound, not evidence of a general default-FPS gain. + +| RD / phase | Draws | Primitives | viewport CPU p50/p95 | viewport GPU p50/p95 | wall p50/p95 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 10 reference, all Medium effects on | 1,080 | 2,287,270 | 0.561/0.847 | 7.016/7.030 | 7.522/7.657 | +| 16 reference, all Medium effects on | 2,092 | 4,640,437 | 0.884/1.290 | 8.614/8.629 | 8.886/9.171 | + +| RD / effect | GPU off p50 | GPU on p50 | GPU off-control p50 | +| --- | ---: | ---: | ---: | +| 10 sun shadows | 5.569 | 7.019 | 5.570 | +| 10 SSAO+SSIL | 6.300 | 7.018 | 6.300 | +| 10 SSR | 6.644 | 7.017 | 6.646 | +| 10 volumetric fog | 6.835 | 7.013 | 6.837 | +| 16 sun shadows | 6.637 | 8.613 | 6.638 | +| 16 SSAO+SSIL | 7.876 | 8.616 | 7.869 | +| 16 SSR | 8.225 | 8.614 | 8.227 | +| 16 volumetric fog | 8.434 | 8.612 | 8.435 | + +These replace the exploratory measurements whose camera was anchored to the +adjusted player spawn. The changed shadow cost reflects a different pinned +view, not a production optimization. Both final distances use camera origin +`(-15.5, 90.1684, 58.5)` and the same orientation. + +### Interpretation and candidates + +1. **Profile shadows first.** They are the largest measured GPU cost here: + about 1.45 ms at RD10 and 1.98 ms at RD16, while adding 341/627 draws and + roughly 1.27M/2.40M primitives. Investigate shadow-caster coverage, cascade + distance/resolution, and leaf caster cost with a visual-regression capture + before changing a default. +2. **SSAO+SSIL is the next stable GPU candidate** at about 0.72-0.74 ms at both + distances. It deserves a quality/half-resolution investigation; it should + not be disabled speculatively because the current test is one camera/GPU. +3. **SSR (~0.37-0.39 ms) and volumetric fog (~0.18 ms)** are materially smaller + on this fixture. Do not prioritize them ahead of shadows or SSAO+SSIL. +4. RD16 grows GPU p50 from 7.02 to 8.61 ms and draws from 1,080 to 2,092, but + viewport CPU p50 remains below 1 ms in the reference. This points to GPU + raster/shadow/post work rather than a renderer-CPU draw-submission bottleneck + on this driver. CPU p95 varied across controls, so it is not sufficient + evidence for a CPU-side optimization by itself. + +These are bounded, stationary views on one GPU/driver. They exclude loading +and moving-player streaming churn, do not measure every camera/weather/biome, +and must be rerun on target hardware. They do not reduce production quality or +change defaults. + +A shadow-only leaf texture-fetch bypass was tested and removed: GPU p50 +off/on/off-control was 7.022/7.025/7.024 ms at RD10 and +8.630/8.636/8.633 ms at RD16. It offered no measurable win. No shader change, +atlas reduction, or disabled-effect default was retained. The proven loading +work and experimental batching remain distinct from this measurement-only pass; +see `tools/OPTIMIZATION_CHANGESETS.md` for the review boundaries. + +### Reproduction + +Run serially (the render profile refuses headless mode): + +```sh +redot --editor --headless --path . --quit +redot --path . res://tools/gameplay_render_profile.tscn +``` diff --git a/tools/OPTIMIZATION_CHANGESETS.md b/tools/OPTIMIZATION_CHANGESETS.md new file mode 100644 index 0000000..ee07bff --- /dev/null +++ b/tools/OPTIMIZATION_CHANGESETS.md @@ -0,0 +1,68 @@ +# Optimization change sets + +All changes below are currently uncommitted relative to `f7404af`. They should +be reviewed as separate groups, not described as one proven FPS improvement. +Some files contain changes from multiple groups; whole-file staging would mix +them. This document does not create commits or modify the index. + +## A. Proven loading and CPU work + +Production changes: + +- `world/worldgen/voxel_populator.gd`: footprint rejection before terrain queries, + exact cave-noise short-circuiting, call-local height thresholds. +- `world/chunk_mesher.gd`: immutable face/AO strides, lazy tint calculation, + reduced temporary allocations, bounded native emitter discovery. +- `world/voxel_world.gd`: paused streaming with simulation gated, desired-ring + enumeration, incremental progress, distance-priority generation/mesh heaps, + collision repair guards, and cumulative profiling counters. Excludes the + `_lod_batch_*` renderer subsystem and its call sites. +- `ui/settings_category_panel.gd`: render-distance debounce and close-time flush. + Excludes the experimental batching checkbox. + +Coverage: `chunk_priority_verify.gd`, `mesh_parity_verify.gd`, +`population_benchmark.gd`, `population_pruning_verify.gd`, and the non-batching +portions of `stream_transition_verify.gd`, `stream_full_verify.gd`, +`player_target_verify.gd`, and `ui_flow_verify.gd`. + +Evidence: approximately 12 s to 7.7-7.9 s for the pinned RD10 loading fixture; +roughly 2.2x population speedup in paired comparisons. These are loading/CPU +measurements, not GPU FPS promises. No quality reduction or terrain version bump. + +## B. Experimental render batching + +Production changes: + +- `world/voxel_world.gd`: render-only 2x2 opaque compact-LOD aggregates and their + lifecycle, caps, foreground-pressure checks, refill/sleep, and rejection state. +- `autoload/game_config.gd`: default-false `lod_batching` setting and getter. +- `game/main.gd`: initial/live application of that setting. +- `ui/settings_category_panel.gd`: Experimental LOD Batching checkbox. + +Coverage and measurement: `lod_batch_verify.gd`, +`lod_batch_render_profile.gd/.tscn`, batching assertions in +`stream_transition_verify.gd`, and the setting assertions in +`frame_pacing_verify.gd`. + +Evidence: fewer draw calls, but inconsistent frame-time changes including +regressions. Default off. Balanced LOD only; no Full Detail batching, no water +batching, and no terrain beyond the chosen render distance. Keep experimental +until representative gameplay GPU measurements justify enabling it. + +## C. Measurement only + +`gameplay_render_profile.gd/.tscn` loads the actual gameplay scene with isolated +persistence hooks. It measures viewport CPU/GPU render times and wall intervals +using a pinned camera, seed, time, preset, and viewport at RD10/RD16. Controlled +effect toggles are temporary and are not production graphics changes. + +This work identifies rendering costs; it does not claim to fix them. Disabling +shadows, shrinking the atlas, or lowering effects would be quality tradeoffs, +not output-preserving optimizations. + +## Shared documentation and CI + +Split `.github/workflows/tests.yml`, `ROADMAP.md`, `world/worldgen/README.md`, +and `CHUNK_PERFORMANCE.md` by the corresponding tests/features when preparing +separate commits. Preserve the default-off batching boundary. No horizon +impostor implementation is included in any group. diff --git a/tools/block_physics_verify.gd b/tools/block_physics_verify.gd index ea0d0dc..9b2c77b 100644 --- a/tools/block_physics_verify.gd +++ b/tools/block_physics_verify.gd @@ -9,6 +9,7 @@ var _storage_root := "user://block_physics_verify_%d" % Time.get_ticks_usec() func _ready() -> void: _check_registry() _check_fall_and_support() + _check_cross_block_support() _check_stacked_no_duplication_or_loss() _check_chunk_edge_and_unloaded_cells() _check_lod_safety() @@ -79,6 +80,23 @@ func _check_stacked_no_duplication_or_loss() -> void: world.free() +func _check_cross_block_support() -> void: + var world := _make_world(4) + var torch := Vector3i(6, 5, 6) + var sand := torch + Vector3i.UP + _set_block(world._chunks[Vector2i.ZERO].data, torch, BlockRegistry.BLOCK_TORCH) + _set_block(world._chunks[Vector2i.ZERO].data, sand, BlockRegistry.BLOCK_SAND) + world._queue_gravity(sand) + world._gravity_tick() + _expect(world.get_block_world(torch) == BlockRegistry.BLOCK_TORCH, + "falling sand silently replaced a cross block") + _expect(world.get_block_world(sand) == BlockRegistry.BLOCK_SAND, + "falling sand moved through a cross block without a drop path") + _expect(world._edited_blocks.is_empty(), + "cross-block support created a persistent gravity edit") + world.free() + + func _check_chunk_edge_and_unloaded_cells() -> void: # Vertical motion at x=15 remains valid even when the adjacent x=16 chunk is # not loaded. It must only edit its owner chunk. diff --git a/tools/chunk_data_compression_verify.gd b/tools/chunk_data_compression_verify.gd index df9a902..897cb15 100644 --- a/tools/chunk_data_compression_verify.gd +++ b/tools/chunk_data_compression_verify.gd @@ -100,6 +100,9 @@ func _verify_neighbor_snapshot(world: VoxelWorld, remote_pos: Vector2i, data: Pa func _verify_approach_and_edit(world: VoxelWorld, remote_pos: Vector2i) -> void: var remote: VoxelWorld.Chunk = world._chunks[remote_pos] world._stream_center = remote_pos + # Collision restoration only services the authoritative desired set. This + # focused fixture builds chunks directly instead of calling setup_player(). + world._desired[remote_pos] = true 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") diff --git a/tools/chunk_priority_verify.gd b/tools/chunk_priority_verify.gd new file mode 100644 index 0000000..7fb652e --- /dev/null +++ b/tools/chunk_priority_verify.gd @@ -0,0 +1,73 @@ +## Deterministic scheduler tests: no worker timing assumptions or rendered assets. +extends SceneTree + +var _failures: Array[String] = [] + + +func _initialize() -> void: + var world := VoxelWorld.new() + world._stream_center = Vector2i.ZERO + world._work_push(world._gen_queue, world._gen_queued, Vector2i.ZERO) + world._work_push(world._mesh_queue, world._mesh_queued, Vector2i(5, 0)) + _expect(not world._next_work_is_mesh(), "distant mesh overtook generation under the player") + world._work_push(world._mesh_queue, world._mesh_queued, Vector2i.ZERO) + _expect(world._next_work_is_mesh(), "ready mesh did not win equal-distance generation") + world._work_pop(world._mesh_queue, world._mesh_queued) + world._work_pop(world._gen_queue, world._gen_queued) + world._work_push(world._gen_queue, world._gen_queued, Vector2i(8, 0)) + _expect(world._next_work_is_mesh(), "closer collision mesh lost to distant generation") + world._work_pop(world._mesh_queue, world._mesh_queued) + world._work_push(world._mesh_queue, world._mesh_queued, Vector2i(12, 0)) + _expect(not world._next_work_is_mesh(), "far mesh bypassed closer far generation") + world._gen_queue.clear() + world._gen_queued.clear() + world._mesh_queue.clear() + world._mesh_queued.clear() + + # Completion/invalidation order must not determine dispatch order. Squared + # distance also orders a farther ring's axis before a nearer ring's corner. + var positions: Array[Vector2i] = [Vector2i(8, 0), Vector2i(3, 3), Vector2i(4, 0), + Vector2i(-1, 0), Vector2i(0, 1), Vector2i.ZERO, Vector2i(-8, 0)] + for pos in positions: + world._work_push(world._mesh_queue, world._mesh_queued, pos) + world._work_push(world._mesh_queue, world._mesh_queued, pos) + _expect(world._mesh_queue.size() == positions.size(), "heap duplicated queued positions") + var expected: Array[Vector2i] = [Vector2i.ZERO, Vector2i(-1, 0), Vector2i(0, 1), + Vector2i(4, 0), Vector2i(3, 3), Vector2i(-8, 0), Vector2i(8, 0)] + for pos in expected: + _expect(world._work_pop(world._mesh_queue, world._mesh_queued) == pos, + "mesh queue lost closest-first ordering at %s" % pos) + _expect(world._mesh_queued.is_empty(), "heap membership remained after drain") + + # Local edited-owner feedback never outranks the player's own chunk. + # Far edits cannot bypass closer collision repairs. + for pos in [Vector2i.ZERO, Vector2i(0, 1), Vector2i(1, 1), Vector2i(2, 0), Vector2i(5, 0)]: + world._work_push(world._mesh_queue, world._mesh_queued, pos) + world._work_push(world._mesh_queue, world._mesh_queued, Vector2i(1, 1), true) + world._work_push(world._mesh_queue, world._mesh_queued, Vector2i(5, 0), true) + _expect(world._mesh_queue.size() == 5, "edit promotion duplicated a mesh") + for pos in [Vector2i.ZERO, Vector2i(1, 1), Vector2i(0, 1), Vector2i(2, 0), Vector2i(5, 0)]: + _expect(world._work_pop(world._mesh_queue, world._mesh_queued) == pos, + "edit/safety priority mismatch at %s" % pos) + + # Changing centers rebuilds priorities, including in negative coordinates. + world.render_distance = 4 + world.lod_distance = 4 + world._stream_center = Vector2i(-17, 9) + world._rebuild_desired() + var last_priority := -1 + while not world._gen_queue.is_empty(): + var pos := world._work_pop(world._gen_queue, world._gen_queued) + var priority := world._work_priority(pos, false) + _expect(priority >= last_priority, "recentered queue is out of order") + last_priority = priority + world.free() + for failure in _failures: + push_error(failure) + print("CHUNK PRIORITY VERIFY: %s" % ("PASS" if _failures.is_empty() else "FAIL")) + quit(0 if _failures.is_empty() else 1) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/chunk_priority_verify.gd.uid b/tools/chunk_priority_verify.gd.uid new file mode 100644 index 0000000..dbc05dd --- /dev/null +++ b/tools/chunk_priority_verify.gd.uid @@ -0,0 +1 @@ +uid://wq5ntcx5twn2 diff --git a/tools/frame_pacing_verify.gd b/tools/frame_pacing_verify.gd index 5598239..58415e8 100644 --- a/tools/frame_pacing_verify.gd +++ b/tools/frame_pacing_verify.gd @@ -40,6 +40,8 @@ func _check_defaults(config: Node) -> void: var defaults: Dictionary = config.DEFAULT_SETTINGS for key in ["vsync", "fps_cap", "dynamic_resolution", "dynamic_resolution_target"]: _expect(defaults.has(key), "DEFAULT_SETTINGS is missing %s" % key) + _expect(defaults.has("lod_batching"), "DEFAULT_SETTINGS is missing experimental LOD batching") + _expect(not bool(defaults["lod_batching"]), "experimental LOD batching should default off") _expect(int(defaults["vsync"]) == 1, "vsync should default to On") _expect(int(defaults["fps_cap"]) == 0, "fps_cap should default to Unlimited") _expect(bool(defaults["dynamic_resolution"]) == false, "dynamic resolution should default off") @@ -96,10 +98,16 @@ func _check_display_panel(config: Node) -> void: var cap_option := _find_option(display, "FPS Cap") var target_option := _find_option(display, "Dynamic Target") var dynamic_check := _find_check(display, "Dynamic Resolution") + var lod_batching_check := _find_check(display, "Experimental LOD Batching") _expect(vsync_option != null and vsync_option.item_count == 4, "Display is missing the four-mode V-Sync row") _expect(cap_option != null and cap_option.item_count == (config.FPS_CAP_VALUES as Array).size(), "Display is missing the FPS Cap row") _expect(target_option != null, "Display is missing the Dynamic Target row") _expect(dynamic_check != null, "Display is missing the Dynamic Resolution row") + _expect(lod_batching_check != null, "Display is missing the experimental LOD batching row") + if lod_batching_check != null: + lod_batching_check.button_pressed = true + _expect(bool(config.get_setting("lod_batching")), "toggling LOD batching should store the setting") + lod_batching_check.button_pressed = false if target_option != null: _expect(not target_option.is_visible_in_tree(), "the dynamic target row should hide while dynamic resolution is off") if cap_option != null: diff --git a/tools/gameplay_render_profile.gd b/tools/gameplay_render_profile.gd new file mode 100644 index 0000000..c782992 --- /dev/null +++ b/tools/gameplay_render_profile.gd @@ -0,0 +1,335 @@ +## Rendered gameplay profiler for the production Main scene. +## +## Requires a real Forward+ display and deliberately refuses headless mode: +## redot --path . res://tools/gameplay_render_profile.tscn +## +## It instantiates game/main.tscn and replaces its root script *before* adding +## it to the tree. The replacement preserves normal startup while overriding +## persistence and onboarding hooks, so it never creates/saves a world, writes +## settings.cfg, or records first-run hints. It profiles unbatched Full Detail +## at RD10 and RD16 using a fixed seed, noon camera, and 1280x720 window. +## +## RenderingServer's viewport CPU/GPU render-time queries are collected after +## frame_post_draw. Wall frame intervals are reported separately and must not +## be interpreted as GPU times. +extends Node + +const MainScene := preload("res://game/main.tscn") + +const SEED := 918273 +const VIEWPORT_SIZE := Vector2i(1280, 720) +const RENDER_DISTANCES := [10, 16] +const WARMUP_FRAMES := 60 +const SAMPLE_FRAMES := 120 +const SETTLE_TIMEOUT_SECONDS := 180.0 +const NOON_HOUR := 12.0 + + +## This only changes Main's persistence-facing seams. It intentionally lets +## Main._ready(), _apply_config(), world streaming, weather, HUD, environment, +## lights, and normal scene setup run unchanged. +class IsolatedProfileMain extends Main: + func _prepare_world_storage() -> Dictionary: + _game_mode = GameMode.SURVIVAL + _world_storage = null + return {} + + + func _flush_world_save() -> bool: + return true + + + func _exit_tree() -> void: + get_tree().paused = false + + + func _build_first_run_hints() -> void: + # Never attach the recorder that can update GameConfig.first_run_hints. + _first_run_hints = null + + +var _main: Main +var _viewport: Viewport +var _viewport_rid: RID +var _timing_api_available := false +var _saved_game_config := {} +var _saved_window := {} + + +func _ready() -> void: + _run.call_deferred() + + +func _run() -> void: + if DisplayServer.get_name() == "headless": + push_error("GAMEPLAY RENDER PROFILE requires a real display; headless frames have no rendered viewport timing") + get_tree().quit(2) + return + _save_process_state() + _configure_isolated_fixture() + var failed := false + var failure_reason := "" + for render_distance in RENDER_DISTANCES: + var result := await _profile_distance(render_distance) + if not bool(result.get("ok", false)): + failed = true + failure_reason = String(result.get("error", "unknown failure")) + break + _restore_process_state() + if failed: + push_error("GAMEPLAY RENDER PROFILE: FAIL (%s)" % failure_reason) + get_tree().quit(1) + else: + print("GAMEPLAY RENDER PROFILE: COMPLETE") + get_tree().quit(0) + + +func _save_process_state() -> void: + var window := get_window() + _saved_window = { + "size": window.size, + "mode": DisplayServer.window_get_mode(), + "vsync": DisplayServer.window_get_vsync_mode(), + "max_fps": Engine.max_fps, + "mouse_mode": Input.get_mouse_mode(), + } + _saved_game_config = { + "settings": GameConfig.settings.duplicate(true), + "graphics": GameConfig.graphics.duplicate(true), + "world": GameConfig.world.duplicate(true), + "active_world_id": GameConfig.active_world_id, + "active_world_metadata": GameConfig.active_world_metadata.duplicate(true), + "pending_game_mode": GameConfig.pending_game_mode, + } + + +func _configure_isolated_fixture() -> void: + # Do not call GameConfig.set_setting(): a few setting paths save immediately. + # Replacing these in-memory dictionaries leaves the user's config untouched. + GameConfig.settings = GameConfig.DEFAULT_SETTINGS.duplicate(true) + GameConfig.graphics = GameConfig.GRAPHICS_PRESETS[GameConfig.PRESET_MEDIUM].duplicate(true) + GameConfig.world = GameConfig.DEFAULT_WORLD.duplicate(true) + GameConfig.world["seed"] = SEED + GameConfig.world["world_type"] = 0 + GameConfig.world["tree_density"] = 1.0 + GameConfig.settings["lod_mode"] = GameConfig.LOD_MODE_FULL + GameConfig.settings["lod_batching"] = false + GameConfig.settings["vsync"] = DisplayServer.VSYNC_DISABLED + GameConfig.settings["fps_cap"] = 0 + GameConfig.settings["dynamic_resolution"] = false + GameConfig.settings["autosave_interval"] = 0 + GameConfig.active_world_id = "" + GameConfig.active_world_metadata.clear() + GameConfig.pending_game_mode = GameMode.SURVIVAL + var window := get_window() + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + Engine.max_fps = 0 + window.size = VIEWPORT_SIZE + + +func _profile_distance(render_distance: int) -> Dictionary: + GameConfig.settings["render_distance"] = render_distance + var scene_root := MainScene.instantiate() as Node3D + if scene_root == null: + return {"ok": false, "error": "could not instantiate game/main.tscn"} + # This must happen before add_child() so Main startup follows the isolated + # overrides from its first _ready call. + scene_root.set_script(IsolatedProfileMain) + _main = scene_root as Main + add_child(_main) + # Desktop input must not move the stream center or open a paused modal during + # an unattended measurement. Keep Main's normal presentation updates running. + _main.player.set_physics_process(false) + _main.player.set_process_unhandled_input(false) + _main.set_process_unhandled_input(false) + _main._photo_mode.set_process_unhandled_input(false) + _main.get_node("ShadowCapture").set_process_unhandled_input(false) + _main.get_node("ShadowCapture").set_process_unhandled_key_input(false) + await get_tree().process_frame + _viewport = get_viewport() + _viewport_rid = _viewport.get_viewport_rid() + _timing_api_available = _has_viewport_timing_api() + if _timing_api_available: + RenderingServer.viewport_set_measure_render_time(_viewport_rid, true) + if not await _wait_for_world_settle(_main.world): + await _dispose_main() + return {"ok": false, "error": "RD%d did not settle within %.0f seconds" % [render_distance, SETTLE_TIMEOUT_SECONDS]} + _pin_render_state() + await _wait_rendered_frames(WARMUP_FRAMES) + _print_fixture_header(render_distance) + # All supported production quality effects are on for this first reference. + _print_sample("RD%d reference (all medium effects on)" % render_distance, await _sample()) + # The production settings expose SSAO and SSIL as one switch; all other + # phases toggle exactly one setting and use off -> on -> off control. + for effect in ["shadows", "ssao_ssil", "ssr", "volumetric_fog"]: + _set_effect(effect, false) + _print_sample("RD%d %s off" % [render_distance, _effect_label(effect)], await _sample()) + _set_effect(effect, true) + _print_sample("RD%d %s on" % [render_distance, _effect_label(effect)], await _sample()) + _set_effect(effect, false) + _print_sample("RD%d %s off control" % [render_distance, _effect_label(effect)], await _sample()) + _set_effect(effect, true) + await _dispose_main() + return {"ok": true} + + +func _wait_for_world_settle(world: VoxelWorld) -> bool: + var deadline := Time.get_ticks_msec() + int(SETTLE_TIMEOUT_SECONDS * 1000.0) + while Time.get_ticks_msec() < deadline: + await get_tree().create_timer(0.25).timeout + var progress := world.get_streaming_progress() + if not _main._world_entry_waiting and 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() \ + and int(progress.get("loaded", 0)) == int(progress.get("total", -1)): + return true + return false + + +func _pin_render_state() -> void: + var day_night := _main.get_node("DayNight") as DayNightCycle + day_night.auto_advance = false + day_night.set_time(NOON_HOUR) + var camera := _main.player.camera + # Safe-spawn selection depends on worker completion order; use the immutable + # generator spawn instead so repeated runs use the same camera transform. + var anchor := _main.world.get_spawn_position() + camera.top_level = true + camera.global_position = anchor + Vector3(0.0, 34.0, 74.0) + camera.look_at(anchor + Vector3(0.0, 20.0, 0.0), Vector3.UP) + camera.fov = GameConfig.get_fov() + # Main has already applied the Medium preset to the actual scene. Pin the + # viewport size but retain its real FSR/TAA/MSAA selection from that preset. + get_window().size = VIEWPORT_SIZE + + +func _has_viewport_timing_api() -> bool: + return ClassDB.class_has_method(&"RenderingServer", &"viewport_set_measure_render_time") \ + and ClassDB.class_has_method(&"RenderingServer", &"viewport_get_measured_render_time_cpu") \ + and ClassDB.class_has_method(&"RenderingServer", &"viewport_get_measured_render_time_gpu") + + +func _set_effect(effect: String, enabled: bool) -> void: + var environment := _main.get_node("WorldEnvironment").environment as Environment + match effect: + "shadows": + (_main.get_node("Sun") as DirectionalLight3D).shadow_enabled = enabled + "ssao_ssil": + environment.ssao_enabled = enabled + environment.ssil_enabled = enabled + "ssr": + environment.ssr_enabled = enabled + "volumetric_fog": + environment.volumetric_fog_enabled = enabled + + +func _effect_label(effect: String) -> String: + match effect: + "shadows": return "sun shadows" + "ssao_ssil": return "SSAO+SSIL" + "ssr": return "SSR" + "volumetric_fog": return "volumetric fog" + return effect + + +func _sample() -> Dictionary: + await _wait_rendered_frames(WARMUP_FRAMES) + var wall_ms: Array[float] = [] + var cpu_ms: Array[float] = [] + var gpu_ms: Array[float] = [] + var draw_calls := 0.0 + var primitives := 0.0 + for _frame in SAMPLE_FRAMES: + var started := Time.get_ticks_usec() + await RenderingServer.frame_post_draw + wall_ms.append(float(Time.get_ticks_usec() - started) / 1000.0) + if _timing_api_available: + cpu_ms.append(RenderingServer.viewport_get_measured_render_time_cpu(_viewport_rid)) + gpu_ms.append(RenderingServer.viewport_get_measured_render_time_gpu(_viewport_rid)) + draw_calls += float(RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME)) + primitives += float(RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)) + return { + "wall": _summarize(wall_ms), + "cpu": _summarize(cpu_ms), + "gpu": _summarize(gpu_ms), + "cpu_state": _timing_state(cpu_ms), + "gpu_state": _timing_state(gpu_ms), + "draw_calls": draw_calls / float(SAMPLE_FRAMES), + "primitives": primitives / float(SAMPLE_FRAMES), + } + + +func _wait_rendered_frames(frames: int) -> void: + for _frame in frames: + await RenderingServer.frame_post_draw + + +func _summarize(samples: Array[float]) -> Dictionary: + if samples.is_empty(): + return {"p50": NAN, "p95": NAN} + var sorted := samples.duplicate() + sorted.sort() + return {"p50": _percentile(sorted, 0.50), "p95": _percentile(sorted, 0.95)} + + +func _timing_state(samples: Array[float]) -> String: + if not _timing_api_available: + return "unavailable (engine lacks viewport timing API)" + for sample in samples: + if absf(sample) > 0.000001: + return "measured" + # A numeric zero is not silently called an unavailable timer. On a backend + # without timestamps it is an ambiguous reported value and the report says so. + return "reported-zero (API exists; backend/driver supplied 0.0)" + + +func _percentile(sorted: Array[float], percentile: float) -> float: + var index := clampi(ceili(float(sorted.size()) * percentile) - 1, 0, sorted.size() - 1) + return sorted[index] + + +func _print_fixture_header(render_distance: int) -> void: + print("GAMEPLAY RENDER PROFILE") + print(" renderer=%s driver=%s adapter=%s vendor=%s" % [ + RenderingServer.get_current_rendering_method(), RenderingServer.get_current_rendering_driver_name(), + RenderingServer.get_video_adapter_name(), RenderingServer.get_video_adapter_vendor()]) + print(" RD%d Full Detail, unbatched, seed=%d, viewport=%s, noon=%.1f, medium preset (3D scale %.2f, TAA requested=%s, MSAA=%d)" % [ + render_distance, SEED, VIEWPORT_SIZE, NOON_HOUR, _viewport.scaling_3d_scale, + str(_viewport.use_taa), _viewport.msaa_3d]) + print(" %d warmup + %d sampled rendered frames/phase; viewport timing API=%s" % [ + WARMUP_FRAMES, SAMPLE_FRAMES, str(_timing_api_available)]) + print(" camera transform=%s" % _main.player.camera.global_transform) + + +func _print_sample(label: String, sample: Dictionary) -> void: + var wall: Dictionary = sample["wall"] + var cpu: Dictionary = sample["cpu"] + var gpu: Dictionary = sample["gpu"] + print(" %s | draws %.1f primitives %.1f | viewport CPU %s %.3f/%.3f ms | GPU %s %.3f/%.3f ms | wall %.3f/%.3f ms" % [ + label, float(sample["draw_calls"]), float(sample["primitives"]), String(sample["cpu_state"]), + float(cpu["p50"]), float(cpu["p95"]), String(sample["gpu_state"]), float(gpu["p50"]), + float(gpu["p95"]), float(wall["p50"]), float(wall["p95"])]) + + +func _dispose_main() -> void: + if _timing_api_available and _viewport_rid.is_valid(): + RenderingServer.viewport_set_measure_render_time(_viewport_rid, false) + if _main != null and is_instance_valid(_main): + _main.queue_free() + await get_tree().process_frame + _main = null + + +func _restore_process_state() -> void: + GameConfig.settings = _saved_game_config["settings"] + GameConfig.graphics = _saved_game_config["graphics"] + GameConfig.world = _saved_game_config["world"] + GameConfig.active_world_id = String(_saved_game_config["active_world_id"]) + GameConfig.active_world_metadata = _saved_game_config["active_world_metadata"] + GameConfig.pending_game_mode = int(_saved_game_config["pending_game_mode"]) + var window := get_window() + window.size = _saved_window["size"] + DisplayServer.window_set_mode(int(_saved_window["mode"])) + DisplayServer.window_set_vsync_mode(int(_saved_window["vsync"])) + Engine.max_fps = int(_saved_window["max_fps"]) + Input.set_mouse_mode(int(_saved_window["mouse_mode"])) diff --git a/tools/gameplay_render_profile.gd.uid b/tools/gameplay_render_profile.gd.uid new file mode 100644 index 0000000..5908b2d --- /dev/null +++ b/tools/gameplay_render_profile.gd.uid @@ -0,0 +1 @@ +uid://ccg4je10df0xc diff --git a/tools/gameplay_render_profile.tscn b/tools/gameplay_render_profile.tscn new file mode 100644 index 0000000..c6286ec --- /dev/null +++ b/tools/gameplay_render_profile.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/gameplay_render_profile.gd" id="1_profile"] + +[node name="GameplayRenderProfile" type="Node"] +script = ExtResource("1_profile") diff --git a/tools/lod_batch_render_profile.gd b/tools/lod_batch_render_profile.gd new file mode 100644 index 0000000..04d9401 --- /dev/null +++ b/tools/lod_batch_render_profile.gd @@ -0,0 +1,179 @@ +## Rendered Forward+ A/B profiler for compact Balanced-LOD batching. +## +## Run only with a real display/Forward+ renderer; headless execution deliberately +## refuses to print performance numbers because it submits no rendered frames: +## redot --path . res://tools/lod_batch_render_profile.tscn +## +## The fixture uses a fixed viewport, seed, camera, and render settings. It +## samples renderer counters after frame_post_draw. Draw calls and primitives +## are actual rendered-frame values. Frame time is wall-clock presented-frame +## interval, not a GPU timestamp query; it includes engine/main-thread work and +## is affected by the display server and driver. +extends Node + +const SEED := 918273 +const BALANCED_DISTANCES := [10, 16] +const NORMAL_DISTANCES := [10, 16] +const PROFILE_VIEWPORT := Vector2i(1280, 720) +const WARMUP_FRAMES := 30 +const SAMPLE_FRAMES := 120 +const SETTLE_SECONDS := 90.0 +const BATCH_SETTLE_SECONDS := 45.0 + + +func _ready() -> void: + _run.call_deferred() + + +func _run() -> void: + if DisplayServer.get_name() == "headless": + push_error("LOD BATCH RENDER PROFILE requires a display; headless counters are not rendered measurements") + get_tree().quit(2) + return + var prior_vsync := DisplayServer.window_get_vsync_mode() + var prior_max_fps := Engine.max_fps + var window := get_window() + var prior_size := window.size + var viewport := get_viewport() + var prior_taa := viewport.use_taa + var prior_scale := viewport.scaling_3d_scale + var prior_scaling_mode := viewport.scaling_3d_mode + var prior_msaa := viewport.msaa_3d + # Remove the fixed presentation cadence so the sampled wall-frame interval can + # expose an A/B difference. This remains a frame interval rather than a GPU + # timestamp; renderer counters below are the authoritative draw measurements. + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + Engine.max_fps = 0 + window.size = PROFILE_VIEWPORT + viewport.use_taa = false + viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR + viewport.scaling_3d_scale = 1.0 + viewport.msaa_3d = Viewport.MSAA_DISABLED + await get_tree().process_frame + var normal := "--normal" in OS.get_cmdline_user_args() + var include_rd24 := "--rd24" in OS.get_cmdline_user_args() + var distances: Array[int] = [] + var source_distances: Array = NORMAL_DISTANCES if normal else BALANCED_DISTANCES + for distance in source_distances: + distances.append(distance) + if include_rd24: + distances.append(24) + var profile_failed := false + print("LOD BATCH RENDER PROFILE") + print(" renderer: %s" % RenderingServer.get_rendering_device().get_device_name()) + print(" display: %s; viewport: %s; seed: %d; fixture: %s Balanced LOD" % [ + DisplayServer.get_name(), PROFILE_VIEWPORT, SEED, "normal" if normal else "flat"]) + for distance in distances: + var world := VoxelWorld.new() + add_child(world) + world.configure({"seed": SEED, "world_type": 0 if normal else 1, + "tree_density": 1.0 if normal else 0.0}, distance, + VoxelWorld.LOD_MODE_BALANCED) + var target := Node3D.new() + target.position = Vector3(0.5, 64.0, 0.5) + add_child(target) + world.setup_player(target) + var camera := Camera3D.new() + camera.position = Vector3(0.0, 118.0, float(distance * 16 + 45)) + camera.fov = 66.0 + camera.far = float((distance + 2) * VoxelDefs.CHUNK_SIZE * 3) + camera.current = true + add_child(camera) + camera.look_at(Vector3(0.0, 30.0, 0.0), Vector3.UP) + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-55.0, -25.0, 0.0) + add_child(sun) + var settled := await _wait_for_settle(world) + if not settled: + push_error("LOD batch render profile timed out at RD%d: %s" % [distance, world.get_worldgen_stats()]) + await _cleanup(world, target, camera, sun) + profile_failed = true + continue + world.set_lod_batching_enabled(false) + var off_first := await _sample() + world.set_lod_batching_enabled(true) + if not await _wait_for_batches(world): + push_error("LOD batch render profile did not settle batches at RD%d: %s" % [distance, world.get_lod_batch_stats()]) + await _cleanup(world, target, camera, sun) + profile_failed = true + continue + var on := await _sample() + var on_batches := int(world.get_lod_batch_stats()["batches"]) + world.set_lod_batching_enabled(false) + var off_control := await _sample() + print(" RD%d OFF draws %.1f primitives %.1f p50/p95/p99 %.2f/%.2f/%.2f ms | ON draws %.1f primitives %.1f p50/p95/p99 %.2f/%.2f/%.2f ms | OFF control %.2f/%.2f/%.2f ms | batches %d" % [ + distance, off_first.draw_calls, off_first.primitives, off_first.p50_ms, off_first.p95_ms, off_first.p99_ms, + on.draw_calls, on.primitives, on.p50_ms, on.p95_ms, on.p99_ms, + off_control.p50_ms, off_control.p95_ms, off_control.p99_ms, + on_batches, + ]) + await _cleanup(world, target, camera, sun) + DisplayServer.window_set_vsync_mode(prior_vsync) + Engine.max_fps = prior_max_fps + window.size = prior_size + viewport.use_taa = prior_taa + viewport.scaling_3d_mode = prior_scaling_mode + viewport.scaling_3d_scale = prior_scale + viewport.msaa_3d = prior_msaa + print("LOD BATCH RENDER PROFILE: %s" % ("FAIL" if profile_failed else "COMPLETE")) + get_tree().quit(1 if profile_failed else 0) + + +func _wait_for_settle(world: VoxelWorld) -> bool: + var started := Time.get_ticks_msec() + while Time.get_ticks_msec() - started < SETTLE_SECONDS * 1000.0: + await get_tree().create_timer(0.25).timeout + if 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() \ + and int(world.get_streaming_progress()["loaded"]) == world._desired.size(): + return true + return false + + +func _wait_for_batches(world: VoxelWorld) -> bool: + var started := Time.get_ticks_msec() + while Time.get_ticks_msec() - started < BATCH_SETTLE_SECONDS * 1000.0: + await get_tree().process_frame + if world._lod_batch_refill_complete and world._lod_batch_queue.is_empty(): + return true + return false + + +func _sample() -> Dictionary: + for _frame in WARMUP_FRAMES: + await RenderingServer.frame_post_draw + var draws := 0.0 + var primitives := 0.0 + var frame_samples: Array[float] = [] + for _frame in SAMPLE_FRAMES: + var started := Time.get_ticks_usec() + await RenderingServer.frame_post_draw + frame_samples.append(float(Time.get_ticks_usec() - started) / 1000.0) + draws += float(RenderingServer.get_rendering_info( + RenderingServer.RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME)) + primitives += float(RenderingServer.get_rendering_info( + RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)) + frame_samples.sort() + return { + "draw_calls": draws / float(SAMPLE_FRAMES), + "primitives": primitives / float(SAMPLE_FRAMES), + "p50_ms": _percentile(frame_samples, 0.50), + "p95_ms": _percentile(frame_samples, 0.95), + "p99_ms": _percentile(frame_samples, 0.99), + } + + +func _percentile(sorted_samples: Array[float], percentile: float) -> float: + if sorted_samples.is_empty(): + return 0.0 + var index := clampi(ceili(float(sorted_samples.size()) * percentile) - 1, + 0, sorted_samples.size() - 1) + return sorted_samples[index] + + +func _cleanup(world: VoxelWorld, target: Node3D, camera: Camera3D, sun: DirectionalLight3D) -> void: + world.queue_free() + target.queue_free() + camera.queue_free() + sun.queue_free() + await get_tree().process_frame diff --git a/tools/lod_batch_render_profile.gd.uid b/tools/lod_batch_render_profile.gd.uid new file mode 100644 index 0000000..f793a55 --- /dev/null +++ b/tools/lod_batch_render_profile.gd.uid @@ -0,0 +1 @@ +uid://dsf7d8tcamtw8 diff --git a/tools/lod_batch_render_profile.tscn b/tools/lod_batch_render_profile.tscn new file mode 100644 index 0000000..ab2d7ba --- /dev/null +++ b/tools/lod_batch_render_profile.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/lod_batch_render_profile.gd" id="1_profile"] + +[node name="LodBatchRenderProfile" type="Node"] +script = ExtResource("1_profile") diff --git a/tools/lod_batch_verify.gd b/tools/lod_batch_verify.gd new file mode 100644 index 0000000..a0c5f48 --- /dev/null +++ b/tools/lod_batch_verify.gd @@ -0,0 +1,160 @@ +## Structural coverage for render-only 2x2 Balanced-LOD aggregation. This runs +## headless: it validates ownership/visibility, mesh attributes, invalidation, +## and bounded scheduling without claiming any GPU performance result. +extends SceneTree + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := VoxelWorld.new() + root.add_child(world) + await process_frame + _expect(not world.lod_batching_enabled, "experimental LOD batching must default off") + world.set_lod_batching_enabled(true) + world._stream_center = Vector2i.ZERO + world.render_distance = 24 + world.lod_distance = 0 + var group := [Vector2i(20, 20), Vector2i(21, 20), Vector2i(20, 21), Vector2i(21, 21)] + for pos in group: + world._desired[pos] = true + var chunk := world._create_chunk_nodes(pos) + chunk.lod = true + chunk.mesh.mesh = _source_mesh(world) + world._chunks[pos] = chunk + world._queue_lod_batch_for(pos) + # Pending commits have strict foreground priority over optional aggregation. + world._commit_queue.append(VoxelWorld.CommitItem.new(Vector2i(99, 99), + ChunkMesher.MeshResult.new(), 0, 0, true)) + world._process_lod_batch_queue() + _expect(world._lod_batches.is_empty() and not world._lod_batch_queue.is_empty(), + "batch upload ran while a commit was waiting") + world._commit_queue.clear() + world._process_lod_batch_queue() + var stats := world.get_lod_batch_stats() + _expect(int(stats["batches"]) == 1, "eligible 2x2 LOD group was not batched") + _expect(int(stats["batched members"]) == 4, "batch did not own all four compact chunks") + _expect(int(stats["visible lod members"]) == 0, "batched chunks also rendered source meshes") + _expect(int(stats["invalid batches"]) == 0, "batch node/member state is invalid") + _expect(int(stats["duplicate members"]) == 0, "a compact chunk appears in more than one batch") + var batch: VoxelWorld.LodRenderBatch = world._lod_batches.get(Vector2i(10, 10)) + _expect(batch != null and batch.mesh.mesh != null, "batch did not produce an opaque mesh") + if batch != null and batch.mesh.mesh != null: + var arrays: Array = batch.mesh.mesh.surface_get_arrays(0) + _expect(arrays[Mesh.ARRAY_CUSTOM0] is PackedFloat32Array, + "batched mesh lost block-light custom attribute") + _expect(arrays[Mesh.ARRAY_CUSTOM1] is PackedFloat32Array, + "batched mesh lost texture-layer custom attribute") + _expect((arrays[Mesh.ARRAY_INDEX] as PackedInt32Array).size() == 24, + "batched mesh did not preserve all source triangles") + _expect(batch == null or batch.mesh.get_child_count() == 0, + "render-only batch unexpectedly owns collision nodes") + # Fill the bounded queue with closer entries, then prove a farther eligible + # group rejected at that moment is revisited after the queue drains. Refill + # scans the desired grid incrementally; it owns no unbounded candidate list. + world._invalidate_lod_batch(Vector2i(10, 10)) + for z in 8: + for x in 8: + world._queue_lod_batch_for(Vector2i(x * 2 + 2, z * 2 + 2)) + _expect(world._lod_batch_queue.size() == VoxelWorld.MAX_QUEUED_LOD_BATCHES, + "near candidate setup did not fill the bounded LOD batch queue") + world._queue_lod_batch_for(group[0]) + _expect(not world._lod_batch_queued.has(Vector2i(10, 10)), + "a farther group unexpectedly bypassed a full nearest queue") + world._lod_batch_queue.clear() + world._lod_batch_queued.clear() + world._reset_lod_batch_refill() + for _scan in 70: + world._refill_lod_batch_queue() + _expect(not world._lod_batch_queue.is_empty(), "bounded refill did not revisit an eligible group") + world._process_lod_batch_queue() + world._free_chunk(group[0]) + stats = world.get_lod_batch_stats() + _expect(int(stats["batches"]) == 0, "unloading a source did not invalidate its batch") + for pos in group.slice(1): + var remaining: VoxelWorld.Chunk = world._chunks[pos] + _expect(remaining.mesh.visible, "batch invalidation left a surviving source hidden") + # A pathological source buffer must remain visible rather than making one + # nonpreemptible main-thread aggregate larger than the documented cap. + var cap_group := [Vector2i(30, 30), Vector2i(31, 30), Vector2i(30, 31), Vector2i(31, 31)] + for index in cap_group.size(): + var pos: Vector2i = cap_group[index] + world._desired[pos] = true + var chunk := world._create_chunk_nodes(pos) + chunk.lod = true + chunk.mesh.mesh = _source_mesh(world, + VoxelWorld.MAX_LOD_BATCH_VERTICES + 1 if index == 0 else 4) + world._chunks[pos] = chunk + world._queue_lod_batch_for(pos) + var rejected_before := world._lod_batch_geometry_rejected + world._process_lod_batch_queue() + _expect(world._lod_batch_geometry_rejected == rejected_before + 1, + "oversized LOD group was not rejected before upload") + _expect(not world._lod_batches.has(Vector2i(15, 15)), + "oversized LOD group created a render batch") + _expect((world._chunks[cap_group[0]] as VoxelWorld.Chunk).mesh.visible, + "oversized LOD source was hidden instead of retained") + _expect(not world._lod_batch_is_eligible(Vector2i(15, 15)), + "oversized group would be retried on every refill pass") + world._invalidate_lod_batch(Vector2i(15, 15)) + _expect(world._lod_batch_is_eligible(Vector2i(15, 15)), + "member invalidation did not allow the group to be reconsidered") + world._lod_batch_queue.clear() + world._lod_batch_queued.clear() + world._lod_batch_refill_complete = true + var idle_cursor := world._lod_batch_refill_cursor + world._process_lod_batch_queue() + _expect(world._lod_batch_refill_cursor == idle_cursor, + "settled batching continued scanning the desired grid") + world._queue_lod_batch_for(cap_group[0]) + _expect(not world._lod_batch_refill_complete, + "new chunk work did not wake settled batching") + + # No batch scheduling is a worker task, so it cannot consume urgent capacity. + _expect(world._pending.is_empty(), "LOD batch scheduling submitted a worker job") + world.set_lod_batching_enabled(false) + _expect(int(world.get_lod_batch_stats()["batches"]) == 0, + "disabling batching did not reveal/remove all aggregates") + _expect(world._lod_batch_queue.is_empty(), "disabling batching retained upload requests") + world.free() + for failure in _failures: + push_error(failure) + print("LOD BATCH VERIFY: %s" % ("PASS" if _failures.is_empty() else "FAIL")) + quit(0 if _failures.is_empty() else 1) + + +func _source_mesh(world: VoxelWorld, vertex_count: int = 4) -> ArrayMesh: + var vertices := PackedVector3Array([ + Vector3.ZERO, Vector3(1.0, 0.0, 0.0), Vector3(1.0, 0.0, 1.0), Vector3(0.0, 0.0, 1.0), + ]) + var normals := PackedVector3Array([Vector3.UP, Vector3.UP, Vector3.UP, Vector3.UP]) + var uvs := PackedVector2Array([Vector2.ZERO, Vector2.RIGHT, Vector2.ONE, Vector2.DOWN]) + var colors := PackedColorArray([Color.WHITE, Color.WHITE, Color.WHITE, Color.WHITE]) + var light := PackedFloat32Array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, + 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) + var layers := PackedFloat32Array([0.0, 0.0, 0.0, 0.0]) + var indices := PackedInt32Array([0, 2, 1, 0, 3, 2]) + if vertex_count > 4: + vertices.resize(vertex_count) + normals.resize(vertex_count) + uvs.resize(vertex_count) + colors.resize(vertex_count) + light.resize(vertex_count * 4) + layers.resize(vertex_count) + vertices[0] = Vector3.ZERO + vertices[1] = Vector3.RIGHT + vertices[2] = Vector3.FORWARD + for index in vertex_count: + normals[index] = Vector3.UP + colors[index] = Color.WHITE + return ChunkMesher.arrays_to_mesh(vertices, normals, uvs, colors, indices, + world.get_registry().material, light, layers) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/lod_batch_verify.gd.uid b/tools/lod_batch_verify.gd.uid new file mode 100644 index 0000000..d71b1e4 --- /dev/null +++ b/tools/lod_batch_verify.gd.uid @@ -0,0 +1 @@ +uid://0klaat3pd8dk diff --git a/tools/mesh_parity_verify.gd b/tools/mesh_parity_verify.gd new file mode 100644 index 0000000..f1bb30e --- /dev/null +++ b/tools/mesh_parity_verify.gd @@ -0,0 +1,124 @@ +extends SceneTree + +# Golden hashes are captured from the unoptimized mesher, not regenerated on success. +const EXPECTED := { + "normal_true": "d66408ad7adbb6bc62ebf27c28773d93d1f1c36fa4c04a5eb9c9a1c6b8452391", + "normal_false": "0694cd4e68e6dad1e63ec2cba41177987df7833903bb10e8fcfb2f5a217470cb", + "missing_true": "ad70bd22ffd1bfa9c8216929d7a12e421f13da21cdcc2c857548667ef845c845", + "missing_false": "27e27f4a81b502db87e7c99f5f47563a97cd100e86931150de38f196370d04af", + "emissive_true": "918cc0e96dc192e1506676582690ba24b86565400ae1722692e9fa6307d43347", + "emissive_false": "7bbab113d6155c85110e58f62575c37fdf1865929ec4c8d63398ace01a2f78ef", + "compact_true": "4d897666a4fb76e5283a3fe391f0e6ec7df9ec6c410d22149db11f02d1ee5559", + "compact_false": "3695d59fb72df384cc6ece9e8747a8be1d783880a5d897238c30ba4a8ffabf39", + "lod": "bda211a4c5ef034d5fedc5b0e9d6a0e23f8f99d13d120c4960779dfdc9541cdc", + # Captured before adding the bounded dense-emitter fallback. + "dense_emitters": "2421949d3e488e69ec11e1ddbb7cba70f6d89cf215d1add52d3d12f26a3d27bc", + "mesh_upload_contract": "3d299eed5994959d845cbe670b2d6a14fdfcf8ef2184fc5f62c24124ef5f061b", +} +const OUTPUT_FIELDS := [ + "data", "heights", "foliage_tints", "water_tints", "max_y", "mask", + "verts", "normals", "uvs", "colors", "light", "layers", "indices", "collision", + "water_verts", "water_normals", "water_uvs", "water_colors", "water_light", "water_indices", + "build_collision", "lod_solid_y", "lod_solid_id", "lod_sub_id", "lod_water_y", "lod_water_level", +] + + +func _initialize() -> void: + var registry := BlockRegistry.new() + var mesher := ChunkMesher.new(registry) + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + var heights := PackedInt32Array() + heights.resize(VoxelDefs.CHUNK_AREA) + var tints := PackedColorArray() + tints.resize(VoxelDefs.CHUNK_AREA) + for column in VoxelDefs.CHUNK_AREA: + tints[column] = Color(0.6 + float(column % 3) * 0.1, 0.8, 0.7, 1.0) + var top := 2 + column % 5 + heights[column] = top + for y in range(top + 1): + data[column + y * VoxelDefs.CHUNK_AREA] = BlockRegistry.BLOCK_STONE + # Every registered material/state, separated by air, plus ceiling/bottom bounds. + var n := 0 + for row in BlockRegistry.BLOCK_DEFS: + var id: int = row[0] + if id == BlockRegistry.BLOCK_AIR or BlockRegistry.EMISSIVE_COLORS.has(id): + continue + var x := (n % 8) * 2 + var z := (int(n / 8) % 8) * 2 + var y := 10 + int(n / 64) * 3 + data[x + z * 16 + y * 256] = id + heights[x + z * 16] = y + n += 1 + data[255 + (VoxelDefs.WORLD_HEIGHT - 1) * 256] = BlockRegistry.BLOCK_STONE + heights[255] = VoxelDefs.WORLD_HEIGHT - 1 + var solid := PackedInt32Array() + solid.resize(256) + solid.fill(5) + var ids := PackedByteArray() + ids.resize(256) + ids.fill(BlockRegistry.BLOCK_GRASS) + var subs := ids.duplicate() + subs.fill(BlockRegistry.BLOCK_STONE) + var water := solid.duplicate() + water.fill(7) + var levels := ids.duplicate() + levels.fill(8) + var failed := false + for fixture in ["normal", "missing", "emissive", "compact"]: + var input := data.duplicate() + if fixture == "emissive": + input[1 + 16 + 9 * 256] = BlockRegistry.BLOCK_TORCH + var neighbors := ChunkMesher.NeighborSet.new() + if fixture != "missing": + neighbors.mask = 255 + for z in range(-1, 2): + for x in range(-1, 2): + if x == 0 and z == 0: + continue + neighbors.samples[Vector2i(x, z)] = ChunkMesher.NeighborSample.from_lod(solid, ids, subs, water) if fixture == "compact" else ChunkMesher.NeighborSample.new(input, 191, heights) + for collision in [true, false]: + var result := mesher.build(input, 191, heights, tints, tints, neighbors, collision) + var values: Array = [] + for field in OUTPUT_FIELDS: + values.append(result.get(field)) + var volume := mesher.build_light_volume(input, 191, heights, neighbors) + values.append([volume.w, volume.d, volume.h, volume.blocks, volume.heights, volume.sky, volume.block_r, volume.block_g, volume.block_b]) + var key := "%s_%s" % [fixture, collision] + failed = not _verify(key, values) or failed + var lod := mesher.build_lod(solid, ids, subs, water, levels, 7, tints, tints, ChunkMesher.LodNeighbors.new()) + var values: Array = [] + for field in OUTPUT_FIELDS: + values.append(lod.get(field)) + failed = not _verify("lod", values) or failed + var dense := ChunkMesher.LightVolume.new() + dense.w = 48 + dense.d = 48 + dense.h = 8 + dense.blocks.resize(dense.w * dense.d * dense.h) + var source_ids := [BlockRegistry.BLOCK_GLOWSTONE, BlockRegistry.BLOCK_TORCH, + BlockRegistry.BLOCK_LAVA, BlockRegistry.BLOCK_AIR] + for index in dense.blocks.size(): + dense.blocks[index] = source_ids[index % source_ids.size()] + mesher._compute_block_light(dense) + failed = not _verify("dense_emitters", [dense.block_r, dense.block_g, dense.block_b]) or failed + # Headless rendering cannot validate GPU surfaces. Pin the unchanged upload + # function as well, including material assignment and custom-attribute flags. + var source := FileAccess.get_file_as_string("res://world/chunk_mesher.gd") + var upload := source.get_slice("static func arrays_to_mesh", 1).get_slice("\n\nfunc _build_light_tables", 0) + failed = not _verify("mesh_upload_contract", [upload]) or failed + if not failed: + print("MESH PARITY VERIFY: PASS") + quit(1 if failed else 0) + + +func _verify(key: String, values: Array) -> bool: + var hashing := HashingContext.new() + hashing.start(HashingContext.HASH_SHA256) + hashing.update(var_to_bytes(values)) + var digest := hashing.finish().hex_encode() + print("MESH PARITY ", key, " ", digest) + if not EXPECTED.has(key) or EXPECTED[key] != digest: + push_error("Missing or mismatched golden mesh hash: " + key) + return false + return true diff --git a/tools/mesh_parity_verify.gd.uid b/tools/mesh_parity_verify.gd.uid new file mode 100644 index 0000000..ff3d8eb --- /dev/null +++ b/tools/mesh_parity_verify.gd.uid @@ -0,0 +1 @@ +uid://byfg4ucy4e7n4 diff --git a/tools/player_target_verify.gd b/tools/player_target_verify.gd index 69eef3e..4f08153 100644 --- a/tools/player_target_verify.gd +++ b/tools/player_target_verify.gd @@ -20,6 +20,7 @@ func _ready() -> void: far_collisionless.shape = CollisionShape3D.new() world._chunks[Vector2i(5, 5)] = far_collisionless world._stream_center = Vector2i.ZERO + world._desired = {Vector2i.ZERO: true, Vector2i(1, 0): true, Vector2i(5, 5): true} 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") diff --git a/tools/population_benchmark.gd b/tools/population_benchmark.gd new file mode 100644 index 0000000..8568de3 --- /dev/null +++ b/tools/population_benchmark.gd @@ -0,0 +1,127 @@ +## Phase timings for the real full-detail population stages, in production order. +## Every profiled result is compared with populate() to detect harness drift. +extends SceneTree + +const POSITIONS: Array[Vector2i] = [Vector2i.ZERO, Vector2i(1, 0), Vector2i(-4, 7), + Vector2i(20, -12), Vector2i(-96, 80), Vector2i(120, -100)] +const REPEATS := 3 +# Captured before footprint pruning and noise short-circuiting. Never regenerate +# these from the result being checked: deterministic output alone is insufficient. +const EXPECTED := { + 123456789: [ + "8a6181c55f7a1cc1e841a8352418c86e45dfc1627d2e70bd57b8f3858dd1f4e8", + "f7f52098c7e95520fd627ccde4a599037bfaf58a2cc602b3c009adaa67687956", + "46ff76c7ccaf6b2ffc4e0953fac90beec66339e622784528275f1c4f8578c8bd", + "ebdc6300e0b3cb154d7d52793eafeb3467f7e0e7428a19a4fa031dcde2216575", + "99c29703e33abcd1f3b2f19e06ff42ad68dc897ab1ce3a8e2b98fc2f8474624e", + "d48ab91027b6915fb13e0d26fe950b16ffc86a8193bbfc0621a38492eab8ffe8", + ], + 918273: [ + "6980d141fb22dd70d2e83ca696a83f6d830e04cc85cbaef1335e91b7514803ee", + "65f3a4f8567e0f9f1c827585a248c6e9561b0710b9d5a1759dcec9b18823c356", + "4127b32d9ed2e6a0ab335cccae4ea0bf95cd46e9a4f2d978bff46e091c3e7fdb", + "52a3320419a5d15d1369c68febde6fb9cc879015d124bd018656f6ca04e475d3", + "75af8591fc3a1798ce66665dfe176e4d9605ed1990ca8068599d7d428e7e4316", + "b3773199f962948f3ffae9f7f2e0f50490e74f50a69c7c3a479a1f688ea0be5f", + ], +} + + +func _initialize() -> void: + var totals := {} + var count := 0 + var failed := false + var baseline_script: GDScript + var baseline_usec := 0 + var current_usec := 0 + for argument in OS.get_cmdline_user_args(): + if not argument.begins_with("--compare-ref="): + continue + var output: Array = [] + var revision := argument.trim_prefix("--compare-ref=") + if OS.execute("git", ["-C", ProjectSettings.globalize_path("res://"), "show", + revision + ":world/worldgen/voxel_populator.gd"], output) != 0: + push_error("Could not read local population baseline") + quit(1) + return + baseline_script = GDScript.new() + baseline_script.source_code = String(output[0]).replace("class_name VoxelPopulator", "") + if baseline_script.reload() != OK: + quit(1) + return + for seed_value in [123456789, 918273]: + var generator := TerrainGenerator.new() + generator.configure({"seed": seed_value, "worldgen_version": 14}) + var pop: VoxelPopulator = generator._populator + var baseline: RefCounted = baseline_script.new(generator._config, generator._biomes, generator._sampler) \ + if baseline_script != null else null + for position in POSITIONS: + var field := generator._sampler.build_field(position) + var reference := pop.populate(position, field, {}) + for iteration in REPEATS: + if baseline != null: + var started := Time.get_ticks_usec() + var old_result: Dictionary = baseline.populate(position, field, {}) + baseline_usec += Time.get_ticks_usec() - started + started = Time.get_ticks_usec() + var current_result := pop.populate(position, field, {}) + current_usec += Time.get_ticks_usec() - started + if old_result != current_result: + push_error("Population differs from local baseline: %s" % position) + failed = true + var result := _profile(pop, position, field, totals) + if result != reference: + push_error("Profiled population differs from production: %s" % position) + failed = true + count += 1 + var hashing := HashingContext.new() + hashing.start(HashingContext.HASH_SHA256) + hashing.update(var_to_bytes([reference.data, reference.max_y])) + var digest := hashing.finish().hex_encode() + if digest != EXPECTED[seed_value][POSITIONS.find(position)]: + push_error("Population changed from baseline: seed=%d chunk=%s" % [seed_value, position]) + failed = true + print("POPULATION FIXTURE seed=%d chunk=%s biome=%d hash=%s" % [seed_value, + position, field.dominant_biome[ChunkTerrainData.cell_index(8, 8)], digest]) + var total := 0.0 + for phase in totals: + var milliseconds := float(totals[phase]) / (1000.0 * count) + total += milliseconds + print("POPULATION PHASE %s %.3f ms" % [phase, milliseconds]) + print("POPULATION AVERAGE %.3f ms (%d samples)" % [total, count]) + if baseline_script != null: + print("POPULATION PAIRED old=%.3f ms current=%.3f ms speedup=%.2fx" % [ + float(baseline_usec) / (1000.0 * count), float(current_usec) / (1000.0 * count), + float(baseline_usec) / maxi(current_usec, 1)]) + quit(1 if failed else 0) + + +func _profile(pop: VoxelPopulator, pos: Vector2i, field: ChunkTerrainData, totals: Dictionary) -> Dictionary: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + var x := pos.x * VoxelDefs.CHUNK_SIZE + var z := pos.y * VoxelDefs.CHUNK_SIZE + var max_y: int = _measure("base/surface", pop._fill_base_and_surface.bind(data, field), totals) + _measure("floor patches", pop._decorate_floor_patches.bind(data, field, x, z), totals) + _measure("noise caves", pop._carve_noise_caves.bind(data, field, x, z), totals) + _measure("cave network", pop._carve_cave_network.bind(data, field, x, z), totals) + _measure("entrances", pop._carve_cave_entrances.bind(data, field, x, z), totals) + _measure("caverns", pop._carve_caverns.bind(data, field, x, z), totals) + _measure("liquids", pop._fill_underground_liquids.bind(data, field, x, z), totals) + _measure("ores", pop._place_ore_veins.bind(data, x, z), totals) + _measure("geodes", pop._place_geodes.bind(data, field, x, z), totals) + _measure("cave decoration", pop._decorate_caves.bind(data, field, x, z), totals) + var scratch := VoxelPopulator.DecorationGroundScratch.new() + max_y = _measure("surface decoration", pop._decorate.bind(data, field, x, z, max_y, scratch), totals) + max_y = _measure("underwater decoration", pop._decorate_underwater.bind(data, field, x, z, max_y, scratch), totals) + max_y = maxi(max_y, _measure("structures", pop._place_region_structures.bind(data, field, x, z), totals)) + max_y = _measure("edits", pop._apply_edits.bind(data, {}, x, z, max_y), totals) + max_y = _measure("max height", pop._actual_max_y.bind(data, max_y), totals) + return {"data": data, "max_y": max_y} + + +func _measure(phase: String, operation: Callable, totals: Dictionary) -> Variant: + var started := Time.get_ticks_usec() + var result: Variant = operation.call() + totals[phase] = int(totals.get(phase, 0)) + Time.get_ticks_usec() - started + return result diff --git a/tools/population_benchmark.gd.uid b/tools/population_benchmark.gd.uid new file mode 100644 index 0000000..49a2fc2 --- /dev/null +++ b/tools/population_benchmark.gd.uid @@ -0,0 +1 @@ +uid://4iwm2x2jc8or diff --git a/tools/population_pruning_verify.gd b/tools/population_pruning_verify.gd new file mode 100644 index 0000000..ac7657a --- /dev/null +++ b/tools/population_pruning_verify.gd @@ -0,0 +1,341 @@ +## Focused regression coverage for population candidate pruning. It deliberately +## exercises private stamp/population helpers: a candidate whose footprint cannot +## touch a chunk must not reach the terrain sampler, while every candidate that +## can touch it must retain its original clipped output. +extends SceneTree + +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 DecorationCatalogScript = preload("res://world/worldgen/decoration_catalog.gd") +const StructureCatalogScript = preload("res://world/worldgen/structure_catalog.gd") +const TerrainSamplerScript = preload("res://world/worldgen/terrain_sampler.gd") +const WorldGenHashScript = preload("res://world/worldgen/world_gen_hash.gd") +const BlockRegistryScript = preload("res://world/block_registry.gd") +const VoxelDefsScript = preload("res://world/voxel_defs.gd") + +const HASHES: Array[int] = [0, 1, 2, 7, 31, 127, 1023, 918273] +const LAND_FEATURES: Array[int] = [ + DecorationCatalogScript.FEATURE_OAK, DecorationCatalogScript.FEATURE_BIRCH, + DecorationCatalogScript.FEATURE_SPRUCE, DecorationCatalogScript.FEATURE_ACACIA, + DecorationCatalogScript.FEATURE_JUNGLE, DecorationCatalogScript.FEATURE_MANGROVE, + DecorationCatalogScript.FEATURE_CACTUS, DecorationCatalogScript.FEATURE_TALL_GRASS, + DecorationCatalogScript.FEATURE_YELLOW_FLOWER, DecorationCatalogScript.FEATURE_RED_FLOWER, + DecorationCatalogScript.FEATURE_DEAD_BUSH, DecorationCatalogScript.FEATURE_BAMBOO, + DecorationCatalogScript.FEATURE_VINE, DecorationCatalogScript.FEATURE_BOULDER, + DecorationCatalogScript.FEATURE_FALLEN_LOG, DecorationCatalogScript.FEATURE_MELON, + DecorationCatalogScript.FEATURE_BROWN_MUSHROOM, DecorationCatalogScript.FEATURE_RED_MUSHROOM, + DecorationCatalogScript.FEATURE_REEDS, DecorationCatalogScript.FEATURE_BUSH, + DecorationCatalogScript.FEATURE_PEBBLE, DecorationCatalogScript.FEATURE_ROCK_OUTCROP, + DecorationCatalogScript.FEATURE_STUMP, DecorationCatalogScript.FEATURE_DEAD_TREE, + DecorationCatalogScript.FEATURE_LARGE_TREE, DecorationCatalogScript.FEATURE_DRIFTWOOD, + DecorationCatalogScript.FEATURE_ANCIENT_TREE, +] + +var _failures := PackedStringArray() + + +class CountingTerrainSampler: + extends TerrainSamplerScript + + var ground := Vector2i(72, BiomeCatalogScript.FOREST) + var calls := {} + + func sample_decoration_ground(x: int, z: int) -> Vector2i: + var position := Vector2i(x, z) + calls[position] = int(calls.get(position, 0)) + 1 + return ground + + +func _initialize() -> void: + _verify_inclusive_bounds() + _verify_land_feature_footprints_and_heights() + _verify_underwater_species_footprints_and_heights() + _verify_structure_clear_halo() + _verify_versioned_catalog_coverage() + _verify_land_candidate_pruning() + _verify_tree_candidate_pruning() + _verify_underwater_candidate_pruning() + _verify_population_max_y() + if _failures.is_empty(): + print("POPULATION PRUNING VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("POPULATION PRUNING VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_inclusive_bounds() -> void: + var populator := _populator() + # Chunk coverage is [origin, origin + size - 1]. A radius touching either + # endpoint must be kept, including in negative coordinate space. + for origin in [Vector2i(0, 0), Vector2i(-32, -48)]: + var end_x: int = origin.x + VoxelDefsScript.CHUNK_SIZE - 1 + var end_z: int = origin.y + VoxelDefsScript.CHUNK_SIZE - 1 + _expect(populator._footprint_intersects_chunk(origin.x - 3, origin.y - 3, 3, origin.x, origin.y), + "lower inclusive footprint edge was rejected at %s" % origin) + _expect(populator._footprint_intersects_chunk(end_x + 3, end_z + 3, 3, origin.x, origin.y), + "upper inclusive footprint edge was rejected at %s" % origin) + _expect(not populator._footprint_intersects_chunk(origin.x - 4, origin.y, 3, origin.x, origin.y), + "lower non-overlap footprint was retained at %s" % origin) + _expect(not populator._footprint_intersects_chunk(end_x + 4, end_z, 3, origin.x, origin.y), + "upper non-overlap footprint was retained at %s" % origin) + _expect(populator._footprint_intersects_chunk(origin.x, origin.y, 0, origin.x, origin.y), + "point at chunk origin was rejected at %s" % origin) + _expect(populator._footprint_intersects_chunk(end_x, end_z, 0, origin.x, origin.y), + "point at chunk end was rejected at %s" % origin) + _expect(not populator._footprint_intersects_chunk(end_x + 1, end_z, 0, origin.x, origin.y), + "point beyond chunk end was retained at %s" % origin) + + +func _verify_land_feature_footprints_and_heights() -> void: + var populator := _populator() + for feature in LAND_FEATURES: + for hash_value in HASHES: + var blocks := _collect_land_stamp(populator, feature, hash_value) + _expect(not blocks.is_empty(), "feature %d hash %d stamped nothing" % [feature, hash_value]) + for position: Vector3i in blocks: + _expect(maxi(absi(position.x - 8), absi(position.z - 8)) <= VoxelPopulatorScript.FEATURE_FOOTPRINT_RADIUS, + "feature %d hash %d exceeded FEATURE_FOOTPRINT_RADIUS at %s" % [feature, hash_value, position]) + var central := _empty_data() + var reported: int = populator._stamp_feature(central, 0, 0, 8, 72, 8, feature, hash_value) + # A few one-cell feature branches intentionally return an inexpensive + # conservative hint. populate() subsequently canonicalizes it through + # _actual_max_y; never permit a hint below the real stamp. + _expect(reported >= _max_y(central), "feature %d hash %d underreported max_y %d < %d" % [feature, hash_value, reported, _max_y(central)]) + + +func _verify_underwater_species_footprints_and_heights() -> void: + var populator := _populator() + var species: Array[int] = [ + DecorationCatalogScript.FEATURE_SEAGRASS, DecorationCatalogScript.FEATURE_KELP, + DecorationCatalogScript.FEATURE_CORAL_FAN, DecorationCatalogScript.FEATURE_CORAL_BRANCH, + DecorationCatalogScript.FEATURE_SPONGE, DecorationCatalogScript.FEATURE_ANEMONE, + ] + for feature in species: + for hash_value in HASHES: + var data := _water_data() + var reported: int = populator._stamp_underwater_plant(data, 0, 0, 8, 28, 8, feature, hash_value, 20) + var actual: int = _max_non_water_y(data) + _expect(reported == actual, "underwater feature %d hash %d returned max_y %d, wrote %d" % [feature, hash_value, reported, actual]) + for position: Vector3i in _blocks_except(data, 0, 0, BlockRegistryScript.BLOCK_WATER): + _expect(position.x == 8 and position.z == 8, + "underwater feature %d hash %d exceeded its point stamp at %s" % [feature, hash_value, position]) + + +func _verify_structure_clear_halo() -> void: + var populator := _populator() + var anchor := Vector2i(16, -16) + var ground_y := 72 + for origin in [Vector2i(0, -32), Vector2i(16, -16), Vector2i(32, 0)]: + var data := _filled_data(BlockRegistryScript.BLOCK_TALL_GRASS) + populator._clear_structure_volume(data, origin.x, origin.y, anchor, ground_y) + for local_z in VoxelDefsScript.CHUNK_SIZE: + for local_x in VoxelDefsScript.CHUNK_SIZE: + var world_x: int = origin.x + local_x + var world_z: int = origin.y + local_z + var in_halo: bool = absi(world_x - anchor.x) <= StructureCatalogScript.HORIZONTAL_HALO \ + and absi(world_z - anchor.y) <= StructureCatalogScript.HORIZONTAL_HALO + for y in range(ground_y + 1, ground_y + StructureCatalogScript.CLEAR_HEIGHT + 1): + var cleared: bool = data[_index(local_x, y, local_z)] == BlockRegistryScript.BLOCK_AIR + _expect(cleared == in_halo, "structure clear halo mismatch at (%d, %d, %d)" % [world_x, y, world_z]) + for kind in [StructureCatalogScript.TYPE_ABANDONED_CAMP, StructureCatalogScript.TYPE_STONE_WATCHTOWER]: + for orientation in range(4): + for offset_z in range(-StructureCatalogScript.HORIZONTAL_HALO - 2, StructureCatalogScript.HORIZONTAL_HALO + 3): + for offset_x in range(-StructureCatalogScript.HORIZONTAL_HALO - 2, StructureCatalogScript.HORIZONTAL_HALO + 3): + for local_y in range(1, StructureCatalogScript.max_height(kind) + 1): + if StructureCatalogScript.block_at(kind, orientation, offset_x, local_y, offset_z) != BlockRegistryScript.BLOCK_AIR: + _expect(absi(offset_x) <= StructureCatalogScript.HORIZONTAL_HALO and absi(offset_z) <= StructureCatalogScript.HORIZONTAL_HALO, + "structure kind %d orientation %d escaped clear halo" % [kind, orientation]) + + +func _verify_versioned_catalog_coverage() -> void: + for version in range(1, 15): + var catalog := DecorationCatalogScript.new(version) + var seen := {} + for set_id in [BiomeCatalogScript.DECORATION_PLAINS, BiomeCatalogScript.DECORATION_MEADOW, + BiomeCatalogScript.DECORATION_SAVANNA, BiomeCatalogScript.DECORATION_FOREST, + BiomeCatalogScript.DECORATION_DESERT, BiomeCatalogScript.DECORATION_SWAMP, + BiomeCatalogScript.DECORATION_RIVERBANK, BiomeCatalogScript.DECORATION_BEACH, + BiomeCatalogScript.DECORATION_TROPICAL, BiomeCatalogScript.DECORATION_TAIGA, + BiomeCatalogScript.DECORATION_SNOWFIELD, BiomeCatalogScript.DECORATION_BADLANDS, + BiomeCatalogScript.DECORATION_ALPINE]: + for entry in catalog.entries_for_set(set_id): + seen[int(entry[0])] = true + for feature in seen: + _expect(LAND_FEATURES.has(feature), "v%d catalog exposes an untested land feature %d" % [version, feature]) + # V1-v10 use legacy aquifers and v11-v14 use global regions, but neither + # changes a surface stamp's footprint. Keep every legacy catalog row in + # this verifier so pruning cannot silently drop old saved-world output. + _expect(not seen.is_empty(), "v%d decoration catalog is unexpectedly empty" % version) + + +func _verify_land_candidate_pruning() -> void: + var sampler := CountingTerrainSampler.new() + var populator := _populator(sampler, 1.0, 0.0) + var origin := Vector2i(0, 0) + var field := _field(0, 0, 72, BiomeCatalogScript.PLAINS) + populator._decorate(_empty_data(), field, origin.x, origin.y, 72, VoxelPopulatorScript.DecorationGroundScratch.new()) + var excluded := _feature_anchors(origin, VoxelPopulatorScript.FEATURE_HALO, VoxelPopulatorScript.FEATURE_FOOTPRINT_RADIUS, false) + _expect(not excluded.is_empty(), "feature-pruning fixture found no non-overlapping anchors") + for anchor: Vector2i in excluded: + _expect(not sampler.calls.has(anchor), "non-overlapping feature candidate queried terrain at %s" % anchor) + _expect(not sampler.calls.is_empty(), "overlapping land candidates no longer queried terrain") + + +func _verify_tree_candidate_pruning() -> void: + var sampler := CountingTerrainSampler.new() + var populator := _populator(sampler) + var origin := Vector2i(-32, -32) + var field := _field(-2, -2, 72, BiomeCatalogScript.SAVANNA) + populator._collect_trees(field, origin.x, origin.y, VoxelPopulatorScript.DecorationGroundScratch.new()) + # The grove lattice is already enumerated at its exact radius. Sparse trees + # retain the wider legacy feature owner range, where pruning has work to do. + var excluded := _feature_anchors(origin, VoxelPopulatorScript.FEATURE_HALO, VoxelPopulatorScript.FEATURE_FOOTPRINT_RADIUS, false) + _expect(not excluded.is_empty(), "tree-pruning fixture found no non-overlapping anchors") + for anchor: Vector2i in excluded: + _expect(not sampler.calls.has(anchor), "non-overlapping tree candidate queried terrain at %s" % anchor) + _expect(not sampler.calls.is_empty(), "overlapping tree candidates no longer queried terrain") + + +func _verify_underwater_candidate_pruning() -> void: + var sampler := CountingTerrainSampler.new() + sampler.ground = Vector2i(30, BiomeCatalogScript.KELP_FOREST) + var populator := _populator(sampler) + var origin := Vector2i(0, -32) + var field := _field(0, -2, 30, BiomeCatalogScript.KELP_FOREST) + populator._decorate_underwater(_water_data(), field, origin.x, origin.y, 48, VoxelPopulatorScript.DecorationGroundScratch.new()) + var excluded := _underwater_anchors(origin, false) + _expect(not excluded.is_empty(), "underwater-pruning fixture found no non-overlapping anchors") + for anchor: Vector2i in excluded: + _expect(not sampler.calls.has(anchor), "non-overlapping underwater center queried terrain at %s" % anchor) + _expect(not sampler.calls.is_empty(), "overlapping underwater candidates no longer queried terrain") + + +func _verify_population_max_y() -> void: + for version in range(1, 15): + var populator := _populator(null, 1.0, 1.0, version) + var field := _field(0, 0, 72, BiomeCatalogScript.FOREST) + var full: Dictionary = populator.populate(Vector2i.ZERO, field, {}, true) + _expect(int(full.max_y) == _max_y(full.data), "v%d full population max_y is stale" % version) + var lod: Dictionary = populator.populate_lod(Vector2i.ZERO, field) + var compact_max := -1 + for column in VoxelDefsScript.CHUNK_AREA: + compact_max = maxi(compact_max, maxi(int(lod.solid_y[column]), int(lod.water_y[column]))) + _expect(int(lod.max_y) == compact_max, "v%d compact LOD max_y is stale" % version) + + +func _collect_land_stamp(populator: VoxelPopulator, feature: int, hash_value: int) -> Dictionary: + var blocks := {} + for origin_z in [-16, 0, 16]: + for origin_x in [-16, 0, 16]: + var data := _empty_data() + populator._stamp_feature(data, origin_x, origin_z, 8, 72, 8, feature, hash_value) + for position: Vector3i in _blocks_except(data, origin_x, origin_z, BlockRegistryScript.BLOCK_AIR): + blocks[position] = true + return blocks + + +func _feature_anchors(origin: Vector2i, halo: int, radius: int, want_overlap: bool) -> Array[Vector2i]: + var anchors: Array[Vector2i] = [] + var first_x: int = WorldGenHashScript.floor_div(origin.x - halo, VoxelPopulatorScript.FEATURE_CELL_SIZE) + var last_x: int = WorldGenHashScript.floor_div(origin.x + VoxelDefsScript.CHUNK_SIZE - 1 + halo, VoxelPopulatorScript.FEATURE_CELL_SIZE) + var first_z: int = WorldGenHashScript.floor_div(origin.y - halo, VoxelPopulatorScript.FEATURE_CELL_SIZE) + var last_z: int = WorldGenHashScript.floor_div(origin.y + VoxelDefsScript.CHUNK_SIZE - 1 + halo, VoxelPopulatorScript.FEATURE_CELL_SIZE) + var probe := _populator() + for cell_z in range(first_z, last_z + 1): + for cell_x in range(first_x, last_x + 1): + var hash_value: int = WorldGenHashScript.hash_2d(918273 + 1103, cell_x, cell_z) + var anchor := Vector2i(cell_x * VoxelPopulatorScript.FEATURE_CELL_SIZE + 1 + hash_value % 6, + cell_z * VoxelPopulatorScript.FEATURE_CELL_SIZE + 1 + (hash_value / 31) % 6) + if probe._footprint_intersects_chunk(anchor.x, anchor.y, radius, origin.x, origin.y) == want_overlap: + anchors.append(anchor) + return anchors + + +func _underwater_anchors(origin: Vector2i, want_overlap: bool) -> Array[Vector2i]: + var anchors: Array[Vector2i] = [] + var first_x: int = WorldGenHashScript.floor_div(origin.x - VoxelPopulatorScript.UNDERWATER_HALO, VoxelPopulatorScript.UNDERWATER_CELL_SIZE) + var last_x: int = WorldGenHashScript.floor_div(origin.x + VoxelDefsScript.CHUNK_SIZE - 1 + VoxelPopulatorScript.UNDERWATER_HALO, VoxelPopulatorScript.UNDERWATER_CELL_SIZE) + var first_z: int = WorldGenHashScript.floor_div(origin.y - VoxelPopulatorScript.UNDERWATER_HALO, VoxelPopulatorScript.UNDERWATER_CELL_SIZE) + var last_z: int = WorldGenHashScript.floor_div(origin.y + VoxelDefsScript.CHUNK_SIZE - 1 + VoxelPopulatorScript.UNDERWATER_HALO, VoxelPopulatorScript.UNDERWATER_CELL_SIZE) + var probe := _populator() + for cell_z in range(first_z, last_z + 1): + for cell_x in range(first_x, last_x + 1): + var hash_value: int = WorldGenHashScript.hash_2d(918273 + 1423, cell_x, cell_z) + var anchor := Vector2i(cell_x * VoxelPopulatorScript.UNDERWATER_CELL_SIZE + 2 + hash_value % 2, + cell_z * VoxelPopulatorScript.UNDERWATER_CELL_SIZE + 2 + (hash_value / 29) % 2) + if probe._footprint_intersects_chunk(anchor.x, anchor.y, VoxelPopulatorScript.UNDERWATER_TUFT_RADIUS, origin.x, origin.y) == want_overlap: + anchors.append(anchor) + return anchors + + +func _populator(sampler: TerrainSampler = null, tree_density: float = 1.0, decoration_density: float = 1.0, version: int = 14) -> VoxelPopulator: + return VoxelPopulatorScript.new(WorldGenConfigScript.new({"seed": 918273, "tree_density": tree_density, + "decoration_density": decoration_density, "cave_density": 0.0, "worldgen_version": version}), BiomeCatalogScript.new(), sampler) + + +func _field(chunk_x: int, chunk_z: int, height: int, biome: int) -> ChunkTerrainData: + var field := ChunkTerrainDataScript.new(chunk_x, chunk_z) + for index in ChunkTerrainDataScript.CELL_COUNT: + field.set_height(index, height, height, height, 0.0) + field.set_biome(index, biome, biome, 0, biome) + field.seal() + return field + + +func _empty_data() -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefsScript.CHUNK_AREA * VoxelDefsScript.WORLD_HEIGHT) + return data + + +func _filled_data(block_id: int) -> PackedByteArray: + var data := _empty_data() + data.fill(block_id) + return data + + +func _water_data() -> PackedByteArray: + return _filled_data(BlockRegistryScript.BLOCK_WATER) + + +func _blocks_except(data: PackedByteArray, origin_x: int, origin_z: int, excluded_id: int) -> Array[Vector3i]: + var blocks: Array[Vector3i] = [] + for y in VoxelDefsScript.WORLD_HEIGHT: + for local_z in VoxelDefsScript.CHUNK_SIZE: + for local_x in VoxelDefsScript.CHUNK_SIZE: + if data[_index(local_x, y, local_z)] != excluded_id: + blocks.append(Vector3i(origin_x + local_x, y, origin_z + local_z)) + return blocks + + +func _max_y(data: PackedByteArray) -> int: + for y in range(VoxelDefsScript.WORLD_HEIGHT - 1, -1, -1): + for local_z in VoxelDefsScript.CHUNK_SIZE: + for local_x in VoxelDefsScript.CHUNK_SIZE: + if data[_index(local_x, y, local_z)] != BlockRegistryScript.BLOCK_AIR: + return y + return 0 + + +func _max_non_water_y(data: PackedByteArray) -> int: + for y in range(VoxelDefsScript.WORLD_HEIGHT - 1, -1, -1): + for local_z in VoxelDefsScript.CHUNK_SIZE: + for local_x in VoxelDefsScript.CHUNK_SIZE: + var id: int = data[_index(local_x, y, local_z)] + if id != BlockRegistryScript.BLOCK_AIR and id != BlockRegistryScript.BLOCK_WATER: + return y + return -1 + + +func _index(local_x: int, y: int, local_z: int) -> int: + return local_x + local_z * VoxelDefsScript.DATA_STRIDE_Z + y * VoxelDefsScript.DATA_STRIDE_Y + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/population_pruning_verify.gd.uid b/tools/population_pruning_verify.gd.uid new file mode 100644 index 0000000..4562cf7 --- /dev/null +++ b/tools/population_pruning_verify.gd.uid @@ -0,0 +1 @@ +uid://clnbq54tqqlyf diff --git a/tools/stream_full_verify.gd b/tools/stream_full_verify.gd index fe2d80f..bf81c9d 100644 --- a/tools/stream_full_verify.gd +++ b/tools/stream_full_verify.gd @@ -24,26 +24,41 @@ func _run() -> void: var world := VoxelWorld.new() root.add_child(world) world.configure(CONFIG, RENDER_DISTANCE) + # Optional local profiling override; CI exercises the production policy. + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--workers="): + world._max_active_jobs = clampi(argument.trim_prefix("--workers=").to_int(), 1, 16) var player := Node3D.new() root.add_child(player) player.global_position = Vector3.ZERO - world.setup_player(player) + var asynchronous_entry := "--async-start" in OS.get_cmdline_user_args() + var entry_ready_usec: Array[int] = [-1] + if asynchronous_entry: + world.initial_stream_ready.connect(func() -> void: entry_ready_usec[0] = Time.get_ticks_usec()) + world.begin_initial_stream(player) + else: + world.setup_player(player) var expected := (RENDER_DISTANCE * 2 + 1) * (RENDER_DISTANCE * 2 + 1) var waited := 0 var stream_start := Time.get_ticks_usec() var visible_ms := -1.0 + var entry_ms := -1.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 + if asynchronous_entry and entry_ms < 0.0 and entry_ready_usec[0] >= 0: + entry_ms = float(entry_ready_usec[0] - stream_start) / 1000.0 if visible_ms < 0.0 and world._chunks.size() >= expected: visible_ms = float(Time.get_ticks_usec() - stream_start) / 1000.0 print("STREAM FULL: chunks=%d/%d visible_ms=%.1f full_ms=%.1f wait_ticks=%d" % [ world._chunks.size(), expected, visible_ms, float(Time.get_ticks_usec() - stream_start) / 1000.0, waited]) + if asynchronous_entry: + print("STREAM ENTRY: safe_ring_ms=%.1f" % entry_ms) if world._chunks.size() != expected: _fail("stream timed out with %d/%d chunks" % [world._chunks.size(), expected]) if not world._pending.is_empty() or not world._gen_queue.is_empty() \ @@ -66,6 +81,7 @@ func _run() -> void: 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) + print("STREAM FULL METRICS: ", world.get_worldgen_stats()) 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") diff --git a/tools/stream_transition_verify.gd b/tools/stream_transition_verify.gd new file mode 100644 index 0000000..1907c55 --- /dev/null +++ b/tools/stream_transition_verify.gd @@ -0,0 +1,101 @@ +extends SceneTree + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := VoxelWorld.new() + root.add_child(world) + world.set_lod_batching_enabled(true) + var normal := "--normal" in OS.get_cmdline_user_args() + world.configure({"seed": 918273, "world_type": 0 if normal else 1}, 2) + # Exercise the Extreme queue builder without submitting 40,401 jobs. + world._stream_center = Vector2i.ZERO + world.render_distance = 100 + world._update_lod_distance() + world._rebuild_desired() + _expect(world._desired.size() == 40401, "Extreme ring enumeration omitted cells") + _expect(world._gen_queue[0] == Vector2i.ZERO, "Extreme queue does not start at the player") + for index in range(1, world._gen_queue.size()): + _expect(not world._work_less(world._gen_queue[index], world._gen_queue[(index - 1) / 2], world._gen_queued), + "Extreme generation heap violates nearest-first priority") + world.set_render_distance(2) + var target := Node3D.new() + root.add_child(target) + world.begin_initial_stream(target) + await _settle(world, "initial", 20.0) + paused = true + world.set_render_distance(3) + var water_time := world._water_accum + var fire_time := world._fire_accum + var gravity_time := world._gravity_accum + await _settle(world, "paused expansion", 20.0) + _expect(world._water_accum == water_time and world._fire_accum == fire_time + and world._gravity_accum == gravity_time, "paused streaming advanced simulation") + paused = false + # Replace queued and in-flight requests, including crossings of the LOD band. + world.set_render_distance(10) + world.set_lod_mode(VoxelWorld.LOD_MODE_BALANCED) + await create_timer(0.1).timeout + world.set_render_distance(2) + world.set_render_distance(10) + await _settle(world, "rapid distance changes", 60.0) + world.set_lod_mode(VoxelWorld.LOD_MODE_FULL) + await create_timer(0.1).timeout + world.set_lod_mode(VoxelWorld.LOD_MODE_BALANCED) + await _settle(world, "in-flight mode reversal", 60.0) + world.set_lod_mode(VoxelWorld.LOD_MODE_FULL) + await _settle(world, "Balanced to Full", 60.0) + world.set_lod_mode(VoxelWorld.LOD_MODE_BALANCED) + await _settle(world, "Full to Balanced", 60.0) + world.set_render_distance(2) + await _settle(world, "contraction", 20.0) + print("TRANSITION METRICS: ", world.get_worldgen_stats()) + world.free() + target.free() + for failure in _failures: + push_error(failure) + print("STREAM TRANSITION VERIFY: %s" % ("PASS" if _failures.is_empty() else "FAIL")) + quit(0 if _failures.is_empty() else 1) + + +func _settle(world: VoxelWorld, label: String, budget: float) -> void: + var started := Time.get_ticks_msec() + while Time.get_ticks_msec() - started < budget * 1000.0: + await create_timer(0.1).timeout + if 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(): + continue + if world.lod_batching_enabled and world.lod_distance < world.render_distance \ + and (not world._lod_batch_refill_complete or not world._lod_batch_queue.is_empty()): + continue + var complete := true + for pos in world._desired: + var chunk: VoxelWorld.Chunk = world._chunks.get(pos) + if chunk == null or chunk.lod != world._chunk_uses_lod(pos): + complete = false + if complete: + var compact_count := 0 + for chunk: VoxelWorld.Chunk in world._chunks.values(): + if chunk.lod: + compact_count += 1 + var batches := world.get_lod_batch_stats() + _expect(int(batches["invalid batches"]) == 0 and int(batches["duplicate members"]) == 0, + "invalid/duplicate batches after %s" % label) + _expect(int(batches["visible lod members"]) + int(batches["batched members"]) == compact_count, + "missing or double-rendered compact chunks after %s" % label) + _expect(int(world.get_streaming_progress().loaded) == world._desired.size(), + "stream progress counter drifted after %s" % label) + print("TRANSITION %s: %d desired, %d ms" % [label, world._desired.size(), Time.get_ticks_msec() - started]) + return + _expect(false, "%s stalled: %s" % [label, world.get_worldgen_stats()]) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/stream_transition_verify.gd.uid b/tools/stream_transition_verify.gd.uid new file mode 100644 index 0000000..ee0c4c4 --- /dev/null +++ b/tools/stream_transition_verify.gd.uid @@ -0,0 +1 @@ +uid://bqsjb6oiebc diff --git a/tools/ui_flow_verify.gd b/tools/ui_flow_verify.gd index 9ed7d36..a8d753f 100644 --- a/tools/ui_flow_verify.gd +++ b/tools/ui_flow_verify.gd @@ -216,9 +216,24 @@ func _run() -> void: display.open_panel() await process_frame _expect(display.visible and settings.visible, "display category did not open over the hub") + var original_distance: Variant = game_config.get_setting("render_distance") + var applied_distances: Array[int] = [] + display.setting_changed.connect(func(key: String, value: Variant) -> void: + if key == "render_distance": + applied_distances.append(int(value)) + ) + var distance_slider: HSlider = display._first_focus + for value in [4, 5, 6]: + distance_slider.value = value + _expect(applied_distances.is_empty(), "slider drag rebuilt terrain immediately") + await create_timer(0.3).timeout + _expect(applied_distances == [6], "slider changes did not coalesce to the final distance") + distance_slider.value = 7 _send_cancel() await process_frame _expect(not display.visible and settings.visible, "cancel should close only the display category") + _expect(applied_distances == [6, 7], "closing Display lost the pending distance change") + game_config.set_setting("render_distance", original_distance) # Graphics -> advanced -> nested cancel -> hub -> menu. graphics.open_panel() diff --git a/tools/world_storage_verify.gd b/tools/world_storage_verify.gd index bee0192..e2982fb 100644 --- a/tools/world_storage_verify.gd +++ b/tools/world_storage_verify.gd @@ -244,7 +244,11 @@ func _verify_session_state_migration_fixtures() -> void: var future_main: Variant = _new_session_main() _expect(future_main._migrate_session_state({"state_version": session_version + 1, "inventory": current["inventory"]}).is_empty(), "future session version was accepted") + _expect(future_main._session_state_read_only, + "future session version did not protect metadata from downgrade writes") _expect(future_main._migrate_session_state([]).is_empty(), "non-dictionary session fixture was accepted") + _expect(not future_main._session_state_read_only, + "malformed unversioned session incorrectly made metadata read-only") var malformed_main: Variant = _restore_session_fixture({ "state_version": session_version, "inventory": "not an inventory array", diff --git a/ui/settings_category_panel.gd b/ui/settings_category_panel.gd index 64902ae..2942838 100644 --- a/ui/settings_category_panel.gd +++ b/ui/settings_category_panel.gd @@ -24,6 +24,7 @@ const EXTREME_WARNING := "Warning: extreme render distance can take minutes to l var _first_focus: Control var _scroll: ScrollContainer +var _distance_timer: Timer func _ready() -> void: @@ -65,6 +66,9 @@ func _resize_to_viewport() -> void: func close_panel() -> void: if not visible: return + if _distance_timer != null and not _distance_timer.is_stopped(): + _distance_timer.stop() + setting_changed.emit("render_distance", GameConfig.get_setting("render_distance")) visible = false closed.emit() @@ -117,6 +121,7 @@ func _category_definition() -> Dictionary: "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": "check", "label": "Experimental LOD Batching", "key": "lod_batching", "tooltip": "Balanced LOD only: merges eligible 2x2 opaque distance meshes to reduce draw calls. Water and Full Detail are unchanged. Disable it if profiling or play feels slower on this GPU."}, {"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."}, @@ -308,10 +313,17 @@ func _add_render_distance(entry: Dictionary) -> Control: _rows_box.add_child(warning) if _first_focus == null: _first_focus = slider + _distance_timer = Timer.new() + _distance_timer.one_shot = true + _distance_timer.wait_time = 0.2 + add_child(_distance_timer) + _distance_timer.timeout.connect(func() -> void: + setting_changed.emit(key, GameConfig.get_setting(key)) + ) slider.value_changed.connect(func(value: float) -> void: value_label.text = entry["format"] % roundi(value) GameConfig.set_setting(key, value) - setting_changed.emit(key, value) + _distance_timer.start() ) toggle.toggled.connect(func(pressed: bool) -> void: GameConfig.set_setting("extreme_render_distance", pressed) diff --git a/world/chunk_mesher.gd b/world/chunk_mesher.gd index 7373fea..c58e4cb 100644 --- a/world/chunk_mesher.gd +++ b/world/chunk_mesher.gd @@ -11,6 +11,8 @@ const CROSS_UVS := [Vector2(0.0, 1.0), Vector2(1.0, 1.0), Vector2(1.0, 0.0), Vec var _blocks: BlockRegistry var _face_ao_offsets: Array = [] +var _face_ao_steps: Array = [] +var _face_steps := PackedInt32Array() var _opacity: PackedByteArray = PackedByteArray() var _cross: PackedByteArray = PackedByteArray() var _leaves: PackedByteArray = PackedByteArray() @@ -23,7 +25,8 @@ 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 _emissive_ids := PackedByteArray() +var _emissive := PackedByteArray() var _emission_r: PackedByteArray = PackedByteArray() var _emission_g: PackedByteArray = PackedByteArray() var _emission_b: PackedByteArray = PackedByteArray() @@ -256,15 +259,18 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo _append_custom_block(Vector3(local_x, y, local_z), id, _tint_for(id, column, foliage_tints), result) continue var is_opaque := _opacity[id] >= MAX_LEVEL - var tint := _tint_for(id, column, foliage_tints) + var tint := Color.WHITE + var tint_ready := false for face in 6: - var normal: Vector3i = VoxelDefs.FACE_NORMALS[face] - var neighbor_index := pad_index + normal.x + normal.z * VoxelDefs.PAD_STRIDE_Z + normal.y * VoxelDefs.PAD_STRIDE_Y + var neighbor_index := pad_index + _face_steps[face] var neighbor_id := padded[neighbor_index] if _opacity[neighbor_id] >= MAX_LEVEL: continue if not is_opaque and neighbor_id == id and _leaves[id] == 0: continue + if not tint_ready: + tint = _tint_for(id, column, foliage_tints) + tint_ready = true _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 @@ -557,6 +563,7 @@ func _build_light_tables() -> void: _layer_top.resize(256) _layer_bottom.resize(256) _layer_side.resize(256) + _emissive_ids.clear() _emissive.resize(256) _emission_r.resize(256) _emission_g.resize(256) @@ -580,6 +587,8 @@ func _build_light_tables() -> void: _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 + if _emissive[id] != 0: + _emissive_ids.append(id) var color := _blocks.emission_color(id) if color == Color.BLACK: continue @@ -848,7 +857,27 @@ func _compute_block_light(volume: LightVolume) -> void: var volume_z := 0 var y := 0 var vx := 0 - for emitter in size: + # Native byte searches avoid an interpreted scan of every light-volume cell. + # Sort the sparse hits to preserve the original spatial BFS seeding order. + # Dense emitter fields fall back to a spatial scan, bounding scratch to 16 KiB. + const MAX_SPARSE_EMITTERS := 4096 + var emitters := PackedInt32Array() + var dense := false + for id in _emissive_ids: + var index := blocks.find(id) + while index >= 0: + emitters.append(index) + if emitters.size() >= MAX_SPARSE_EMITTERS: + dense = true + break + index = blocks.find(id, index + 1) + if dense: + break + if not dense: + emitters.sort() + var source_count := size if dense else emitters.size() + for source_index in source_count: + var emitter := source_index if dense else emitters[source_index] var block_id := blocks[emitter] if _emissive[block_id] == 0: continue @@ -973,19 +1002,33 @@ func _compute_block_light(volume: LightVolume) -> void: func _build_ao_offsets() -> void: _face_ao_offsets = [] + _face_ao_steps = [] + _face_steps = PackedInt32Array() + var light_width := VoxelDefs.CHUNK_SIZE * 3 for face in VoxelDefs.FACE_NORMALS.size(): var normal: Vector3i = VoxelDefs.FACE_NORMALS[face] + _face_steps.append(normal.x + normal.z * VoxelDefs.PAD_STRIDE_Z + normal.y * VoxelDefs.PAD_STRIDE_Y) var tangent_a: Vector3i = VoxelDefs.FACE_TANGENT_A[face] var tangent_b: Vector3i = VoxelDefs.FACE_TANGENT_B[face] var axis_a := 0 if tangent_a.x != 0 else 1 var axis_b := 0 if tangent_b.x != 0 else (1 if tangent_b.y != 0 else 2) var corners: Array = [] + var corner_steps: Array = [] for corner in 4: var offset: Vector3i = VoxelDefs.FACE_VERTS[face][corner] var a: Vector3i = tangent_a * (offset[axis_a] * 2 - 1) var b: Vector3i = tangent_b * (offset[axis_b] * 2 - 1) corners.append([normal + a, normal + b, normal + a + b]) + var steps: Array[Vector3i] = [] + for sample: Vector3i in corners.back(): + # Immutable linear strides for padding, light volume, and vertical bounds. + steps.append(Vector3i( + sample.x + sample.z * VoxelDefs.PAD_STRIDE_Z + sample.y * VoxelDefs.PAD_STRIDE_Y, + sample.x + sample.z * light_width + sample.y * light_width * light_width, + sample.y)) + corner_steps.append(steps) _face_ao_offsets.append(corners) + _face_ao_steps.append(corner_steps) func _append_face(face: int, pad_index: int, local_x: int, y: int, local_z: int, block_id: int, padded: PackedByteArray, tint: Color, result: MeshResult) -> void: @@ -997,6 +1040,7 @@ func _append_face(face: int, pad_index: int, local_x: int, y: int, local_z: int, var face_uvs: Array = VoxelDefs.FACE_UVS[face] var volume: LightVolume = result.light_volume var block_light_active := not volume.block_r.is_empty() + var light_origin := (y * volume.d + local_z + LIGHT_PAD) * volume.w + local_x + LIGHT_PAD var front_x := local_x + LIGHT_PAD + normal.x var front_y := y + normal.y var front_z := local_z + LIGHT_PAD + normal.z @@ -1023,19 +1067,17 @@ func _append_face(face: int, pad_index: int, local_x: int, y: int, local_z: int, r_sum += int(volume.block_r[front_index]) g_sum += int(volume.block_g[front_index]) b_sum += int(volume.block_b[front_index]) - var samples: Array = _face_ao_offsets[face][corner] - for sample in samples: - var sample_offset: Vector3i = sample - var sample_index := pad_index + sample_offset.x + sample_offset.z * VoxelDefs.PAD_STRIDE_Z + sample_offset.y * VoxelDefs.PAD_STRIDE_Y + var samples: Array[Vector3i] = _face_ao_steps[face][corner] + for step in samples: + var sample_index := pad_index + step.x if _opacity[padded[sample_index]] >= MAX_LEVEL: occlusion += 1 continue - var sample_x := local_x + LIGHT_PAD + sample_offset.x - var sample_y := y + sample_offset.y - var sample_z := local_z + LIGHT_PAD + sample_offset.z - if sample_x < 0 or sample_x >= volume.w or sample_z < 0 or sample_z >= volume.d or sample_y < 0 or sample_y >= volume.h: + # The center tile plus one-cell AO offsets is always inside the 3x3 footprint. + var sample_y := y + step.z + if sample_y < 0 or sample_y >= volume.h: continue - var light_index := (sample_y * volume.d + sample_z) * volume.w + sample_x + var light_index := light_origin + step.y sky_sum += int(volume.sky[light_index]) light_count += 1 if block_light_active: @@ -1049,13 +1091,23 @@ func _append_face(face: int, pad_index: int, local_x: int, y: int, local_z: int, result.light.push_back(float(g_sum) * inverse) result.light.push_back(float(b_sum) * inverse) result.light.push_back(float(sky_sum) * inverse) - var p0: Vector3 = result.verts[base] - var p1: Vector3 = result.verts[base + 1] - var p2: Vector3 = result.verts[base + 2] - var p3: Vector3 = result.verts[base + 3] - result.indices.append_array(PackedInt32Array([base, base + 2, base + 1, base, base + 3, base + 2])) + result.indices.push_back(base) + result.indices.push_back(base + 2) + result.indices.push_back(base + 1) + result.indices.push_back(base) + result.indices.push_back(base + 3) + result.indices.push_back(base + 2) if result.build_collision: - result.collision.append_array(PackedVector3Array([p0, p2, p1, p0, p3, p2])) + var p0: Vector3 = result.verts[base] + var p1: Vector3 = result.verts[base + 1] + var p2: Vector3 = result.verts[base + 2] + var p3: Vector3 = result.verts[base + 3] + result.collision.push_back(p0) + result.collision.push_back(p2) + result.collision.push_back(p1) + result.collision.push_back(p0) + result.collision.push_back(p3) + result.collision.push_back(p2) ## Stateful blocks deliberately do not participate in cube face culling: their diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 6e0388d..8d28a0e 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -34,8 +34,22 @@ const STREAM_BOUNDARY_MARGIN := 0.05 ## putting compression in the active meshing path. const ENABLE_COLD_CHUNK_COMPRESSION := false const COMMIT_BUDGET_MS := 2 +## Balanced LOD can reduce its opaque terrain submissions by combining four +## already-built compact chunk meshes. This is strictly a render-only cache: +## generation, edit ownership, neighbour snapshots, collision, and water stay +## with their authoritative chunks. One upload per frame keeps this optional +## presentation work from becoming a distant-streaming hitch. +const LOD_BATCH_SIZE := 2 +const MAX_LOD_BATCH_UPLOADS_PER_TICK := 1 +const MAX_QUEUED_LOD_BATCHES := 64 +## The cap is deliberately below a worst-case group: an aggregate is rebuilt +## from four GPU surfaces on the main thread, so a pathological cliff/canopy +## group must gracefully keep its source meshes instead of causing a long hitch. +const MAX_LOD_BATCH_VERTICES := 16384 +const MAX_LOD_BATCH_INDICES := 24576 +const MAX_LOD_BATCH_REFILL_CANDIDATES_PER_TICK := 12 +const BATCH_UPLOAD_SOFT_BUDGET_USEC := 1000 const STREAM_PROGRESS_INTERVAL := 0.15 -const STREAM_PROGRESS_EXTREME_INTERVAL := 1.0 ## 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 ## fewer pixels for those modes instead of freezing their refresh for seconds. @@ -86,6 +100,10 @@ var render_distance := 10 var lod_distance := 5 var lod_mode := LOD_MODE_FULL var unload_radius := 12 +## Experimental: it is off by default even in Balanced LOD until a target +## machine profile demonstrates that its draw-call reduction beats coarser +## 2x2 visibility culling. Full Detail never enters this path. +var lod_batching_enabled := false var _blocks: BlockRegistry var _generator: TerrainGenerator @@ -94,6 +112,18 @@ var _mesher: ChunkMesher var _chunks: Dictionary = {} var _pending: Dictionary = {} var _commit_queue: Array = [] +var _lod_batches: Dictionary = {} +var _lod_batch_queue: Array[Vector2i] = [] +var _lod_batch_queued: Dictionary = {} +var _lod_batch_rejected: Dictionary = {} +var _lod_batch_refill_found_work := false +var _lod_batch_uploads := 0 +var _lod_batch_skipped := 0 +var _lod_batch_geometry_rejected := 0 +var _lod_batch_refill_cursor := 0 +var _lod_batch_refill_complete := false +var _lod_batch_refill_passes := 0 +var _commit_budget_used_usec := 0 var _gen_queue: Array[Vector2i] = [] var _gen_queued: Dictionary = {} var _mesh_queue: Array[Vector2i] = [] @@ -101,6 +131,7 @@ var _mesh_queued: Dictionary = {} var _generated: Dictionary = {} var _dirty: Dictionary = {} var _desired: Dictionary = {} +var _streamed_count := 0 var _edited_blocks: Dictionary = {} var _edits_by_chunk: Dictionary = {} var _hydrated_edit_chunks: Dictionary = {} @@ -133,6 +164,13 @@ var _initial_stream_center := Vector2i(999999, 999999) var _initial_stream_active := false var _initial_stream_complete := false var _max_active_jobs := MIN_ACTIVE_JOBS +var _generation_jobs := 0 +var _mesh_jobs := 0 +var _discarded_jobs := 0 +var _generation_usec := 0 +var _mesh_usec := 0 +var _commit_usec := 0 +var _stream_main_usec := 0 var _full_jobs_measured := 0 var _lod_jobs_measured := 0 var _generation_ema_ms := 0.0 @@ -260,7 +298,17 @@ class CommitItem: lod = p_lod +## A batch deliberately contains no voxel state and no physics. It owns only +## one opaque MeshInstance3D plus the four source positions whose original +## render instances are hidden while this cache is valid. +class LodRenderBatch: + var members: Array[Vector2i] = [] + var mesh: MeshInstance3D + + func _ready() -> void: + # Menus pause simulation, not the background terrain pipeline. + process_mode = Node.PROCESS_MODE_ALWAYS _blocks = BlockRegistry.new() _generator = TerrainGenerator.new() _mesher = ChunkMesher.new(_blocks) @@ -276,11 +324,11 @@ func _process(delta: float) -> void: return _stream_tick() _stream_progress_time += delta - var progress_interval := STREAM_PROGRESS_INTERVAL \ - if _desired.size() <= 4096 else STREAM_PROGRESS_EXTREME_INTERVAL - if _stream_progress_time >= progress_interval: + if _stream_progress_time >= STREAM_PROGRESS_INTERVAL: _stream_progress_time = 0.0 _emit_stream_progress() + if get_tree().paused: + return _water_accum += delta if _water_accum >= WATER_TICK_INTERVAL: _water_accum = 0.0 @@ -296,6 +344,7 @@ func _process(delta: float) -> void: func _exit_tree() -> void: + _clear_lod_batches() for pos in _pending.keys(): WorkerThreadPool.wait_for_task_completion((_pending[pos] as PendingJob).task) _pending.clear() @@ -333,7 +382,13 @@ func flush_edit_store() -> Error: func set_render_distance(value: int) -> void: - render_distance = maxi(value, 1) + var normalized := maxi(value, 1) + if render_distance == normalized: + return + render_distance = normalized + # A changed boundary can turn one member of a cached 2x2 compact group into + # Full Detail. Reveal source meshes before the asynchronous transition lands. + _clear_lod_batches() _update_lod_distance() unload_radius = render_distance + 2 _rebuild_desired() @@ -345,10 +400,21 @@ func set_lod_mode(value: int) -> void: if lod_mode == normalized: return lod_mode = normalized + _clear_lod_batches() _update_lod_distance() _rebuild_desired() +## Exposed for controlled Forward+ A/B profiling and safe fallback on hardware +## where four-way array uploads do not pay for their draw-call reduction. +func set_lod_batching_enabled(enabled: bool) -> void: + if lod_batching_enabled == enabled: + return + lod_batching_enabled = enabled + _clear_lod_batches() + _reset_lod_batch_refill() + + 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 @@ -460,6 +526,7 @@ func begin_initial_stream(player_node: Node3D) -> void: func _stream_tick() -> void: + var started := Time.get_ticks_usec() var center := _chunk_for_position(_player.global_position) if center != _stream_center: _stream_center = center @@ -468,12 +535,14 @@ func _stream_tick() -> void: _drop_far_collision() _collect_jobs() _process_commit_queue() + _process_lod_batch_queue() # 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() _update_initial_stream_state() + _stream_main_usec += Time.get_ticks_usec() - started func _update_initial_stream_state() -> void: @@ -494,10 +563,11 @@ func _update_initial_stream_state() -> void: ## 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) + if not _desired.has(pos): + continue var chunk: Chunk = _chunks.get(pos) if chunk == null or chunk.lod: continue @@ -511,18 +581,7 @@ func _ensure_near_collision() -> void: 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) + _queue_rebuild(pos) func _drop_far_collision() -> void: @@ -552,6 +611,7 @@ func _generate_spawn_area() -> void: func _rebuild_desired() -> void: _desired.clear() + _streamed_count = 0 _mesh_queue.clear() _mesh_queued.clear() var wanted: Array[Vector2i] = [] @@ -560,14 +620,17 @@ func _rebuild_desired() -> void: # 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 + # Enumerate the perimeter itself, not every interior cell on every + # ring (the latter visits O(radius^3) cells during a distance change). + var zs := range(-ring, ring + 1) if absi(dx) == ring else [-ring, ring] + for dz in zs: 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 + elif _chunks.has(pos): + _streamed_count += 1 var staged: TerrainGenerator.GenResult = _generated.get(pos) if staged != null and (staged.lod != want_lod \ or staged.config_revision != _worldgen_revision): @@ -577,7 +640,10 @@ func _rebuild_desired() -> void: _gen_queue = wanted _gen_queued.clear() for pos in _gen_queue: - _gen_queued[pos] = true + _gen_queued[pos] = false + # Bottom-up heap construction is linear, including at Extreme distance. + for index in range(_gen_queue.size() / 2 - 1, -1, -1): + _work_sift_down(_gen_queue, _gen_queued, index) for pos in _dirty.keys(): if _desired.has(pos) and not _pending.has(pos) and not _gen_queued.has(pos): _queue_rebuild(pos) @@ -586,42 +652,29 @@ func _rebuild_desired() -> void: _generated.erase(pos) else: _queue_generated_mesh_if_ready(pos) + _reconcile_lod_batches() + _reset_lod_batch_refill() func _schedule_jobs() -> void: if _gen_queue.is_empty() and _mesh_queue.is_empty(): return 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: + var mesh_job := _next_work_is_mesh() + var next_pos: Vector2i = _mesh_queue[0] if mesh_job else _gen_queue[0] + var urgent := not _chunk_uses_lod(next_pos) and _within_collision_range(next_pos) + if _pending.size() >= _max_active_jobs and not urgent: 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. + # Only nearby full-detail work can use the one bounded overflow slot. var pos: Vector2i if mesh_job: - pos = _mesh_queue[queue_index] - _mesh_queue.remove_at(queue_index) - _mesh_queued.erase(pos) + pos = _work_pop(_mesh_queue, _mesh_queued) else: - pos = _gen_queue[queue_index] - _gen_queue.remove_at(queue_index) - _gen_queued.erase(pos) + pos = _work_pop(_gen_queue, _gen_queued) + if not _desired.has(pos): + continue if _pending.has(pos): continue if mesh_job and not _chunks.has(pos) and not _generated.has(pos): @@ -669,32 +722,111 @@ func _schedule_jobs() -> void: high_priority, "voxel_generate") _pending[pos] = job + if job.kind == "generate": + _generation_jobs += 1 + else: + _mesh_jobs += 1 + + +## Queues are min-heaps. The membership maps also mark urgent edited owners. +## Keys depend only on the stream center and that mark, not mutable chunk state. +## Re-centering rebuilds both heaps; queued edits can only promote an entry. +func _work_priority(pos: Vector2i, edited: bool) -> int: + var offset := pos - _stream_center + var ring := maxi(absi(offset.x), absi(offset.y)) + var tier := 4 + if ring == 0: + tier = 0 + elif ring <= SPAWN_RADIUS: + tier = 1 if edited else 2 + elif ring <= COLLISION_DISTANCE: + tier = 3 + return tier * 1000000000 + offset.length_squared() + + +func _work_less(a: Vector2i, b: Vector2i, queued: Dictionary) -> bool: + var a_priority := _work_priority(a, bool(queued.get(a, false))) + var b_priority := _work_priority(b, bool(queued.get(b, false))) + if a_priority != b_priority: + return a_priority < b_priority + return a.x < b.x or (a.x == b.x and a.y < b.y) + + +func _work_push(queue: Array[Vector2i], queued: Dictionary, pos: Vector2i, edited := false) -> void: + var index := queue.size() + if queued.has(pos): + if not edited or bool(queued[pos]): + return + index = queue.find(pos) + else: + queue.append(pos) + queued[pos] = edited + while index > 0: + var parent := (index - 1) / 2 + if not _work_less(queue[index], queue[parent], queued): + break + var swap := queue[parent] + queue[parent] = queue[index] + queue[index] = swap + index = parent + + +func _work_sift_down(queue: Array[Vector2i], queued: Dictionary, index: int) -> void: + while index * 2 + 1 < queue.size(): + var child := index * 2 + 1 + if child + 1 < queue.size() and _work_less(queue[child + 1], queue[child], queued): + child += 1 + if not _work_less(queue[child], queue[index], queued): + return + var swap := queue[index] + queue[index] = queue[child] + queue[child] = swap + index = child -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 _work_pop(queue: Array[Vector2i], queued: Dictionary) -> Vector2i: + var pos := queue[0] + var last: Vector2i = queue.pop_back() + queued.erase(pos) + if not queue.is_empty(): + queue[0] = last + _work_sift_down(queue, queued, 0) + return pos + + +func _next_work_is_mesh() -> bool: + if _mesh_queue.is_empty(): + return false + if _gen_queue.is_empty(): + return true + # Finish render/collision work first only when its priority is at least as + # close. Distant completed terrain must not starve nearby missing chunks. + return _work_priority(_mesh_queue[0], bool(_mesh_queued[_mesh_queue[0]])) \ + <= _work_priority(_gen_queue[0], bool(_gen_queued[_gen_queue[0]])) func _process_commit_queue() -> void: var start := Time.get_ticks_msec() + _commit_budget_used_usec = 0 while not _commit_queue.is_empty(): 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): + _discarded_jobs += 1 _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: + _discarded_jobs += 1 _queue_rebuild(item.pos) continue + var commit_start := Time.get_ticks_usec() _commit_chunk(item.pos, item.result, item.lod) + _commit_usec += Time.get_ticks_usec() - commit_start if Time.get_ticks_msec() - start > COMMIT_BUDGET_MS: break + _commit_budget_used_usec = Time.get_ticks_usec() - start * 1000 func _next_commit_index() -> int: @@ -721,12 +853,20 @@ func _collect_jobs() -> void: continue WorkerThreadPool.wait_for_task_completion(job.task) _pending.erase(pos) + if job.kind == "generate": + var generated_result: TerrainGenerator.GenResult = job.slot.get("generated") + if generated_result != null: + _generation_usec += int(generated_result.timings.get("generation_us", 0)) + else: + _mesh_usec += int(job.slot.get("mesh_us", 0)) 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. + _discarded_jobs += 1 _discard_unloaded_chunk_state(pos) continue if _dirty.has(pos): + _discarded_jobs += 1 # An edit invalidated this job's neighbor snapshot while it was running. # Discard the stale result and immediately schedule a fresh build. if _desired.has(pos): @@ -741,6 +881,7 @@ func _collect_jobs() -> void: _generated[pos] = generated _queue_generated_meshes_around(pos) elif _desired.has(pos): + _discarded_jobs += 1 _queue_rebuild(pos) continue if job.slot.has("result") and job.slot["result"] != null: @@ -863,8 +1004,7 @@ func _queue_generated_mesh_if_ready(pos: Vector2i) -> void: if not _generated.has(pos) or not _desired.has(pos) or _pending.has(pos) \ or _mesh_queued.has(pos) or not _generated_neighbors_ready(pos): return - _mesh_queue.append(pos) - _mesh_queued[pos] = true + _work_push(_mesh_queue, _mesh_queued, pos) func _generated_neighbors_ready(pos: Vector2i) -> bool: @@ -1048,9 +1188,262 @@ func _compress_distant_chunks() -> void: chunk.data.clear() +## Groups are aligned in chunk coordinates (including negatives) so a stream +## recenter never changes ownership. They only combine opaque compact LOD +## surfaces; water remains per chunk to retain the renderer's existing +## transparent-object ordering and culling behaviour. +func _lod_batch_key(pos: Vector2i) -> Vector2i: + return Vector2i(floori(float(pos.x) / float(LOD_BATCH_SIZE)), + floori(float(pos.y) / float(LOD_BATCH_SIZE))) + + +func _lod_batch_members(key: Vector2i) -> Array[Vector2i]: + var origin := key * LOD_BATCH_SIZE + return [origin, origin + Vector2i(1, 0), origin + Vector2i(0, 1), origin + Vector2i(1, 1)] + + +func _lod_batch_priority(key: Vector2i) -> int: + var center := key * LOD_BATCH_SIZE + Vector2i(1, 1) + return (center - _stream_center).length_squared() + + +func _queue_lod_batch_for(pos: Vector2i) -> void: + if not lod_batching_enabled or not _chunk_uses_lod(pos): + return + _lod_batch_refill_complete = false + var key := _lod_batch_key(pos) + if _lod_batches.has(key): + _invalidate_lod_batch(key) + if _lod_batch_queued.has(key): + return + if _lod_batch_queue.size() >= MAX_QUEUED_LOD_BATCHES: + var furthest_index := 0 + var furthest_priority := _lod_batch_priority(_lod_batch_queue[0]) + for index in range(1, _lod_batch_queue.size()): + var priority := _lod_batch_priority(_lod_batch_queue[index]) + if priority > furthest_priority: + furthest_index = index + furthest_priority = priority + if _lod_batch_priority(key) >= furthest_priority: + _lod_batch_skipped += 1 + return + var evicted: Vector2i = _lod_batch_queue[furthest_index] + _lod_batch_queue.remove_at(furthest_index) + _lod_batch_queued.erase(evicted) + _lod_batch_skipped += 1 + _lod_batch_queue.append(key) + _lod_batch_queued[key] = true + + +## Scan the existing desired grid incrementally instead of retaining an +## unbounded "all batches" list. A queue cap can therefore protect a frame +## without permanently abandoning a group that was far away when batching was +## toggled on: future bounded passes revisit it after nearer uploads drain. +func _reset_lod_batch_refill() -> void: + _lod_batch_rejected.clear() + _lod_batch_refill_cursor = 0 + _lod_batch_refill_complete = false + _lod_batch_refill_passes = 0 + _lod_batch_refill_found_work = false + + +func _refill_lod_batch_queue() -> void: + if not lod_batching_enabled or _desired.is_empty() or _lod_batch_refill_complete: + return + var minimum := _lod_batch_key(_stream_center - Vector2i(render_distance, render_distance)) + var maximum := _lod_batch_key(_stream_center + Vector2i(render_distance, render_distance)) + var width := maximum.x - minimum.x + 1 + var height := maximum.y - minimum.y + 1 + var total := width * height + if total <= 0: + return + for _candidate in MAX_LOD_BATCH_REFILL_CANDIDATES_PER_TICK: + var index := _lod_batch_refill_cursor + var key := minimum + Vector2i(index % width, index / width) + if not _lod_batches.has(key) and not _lod_batch_queued.has(key) and _lod_batch_is_eligible(key): + _lod_batch_refill_found_work = true + _queue_lod_batch_for(key * LOD_BATCH_SIZE) + _lod_batch_refill_cursor = (_lod_batch_refill_cursor + 1) % total + if _lod_batch_refill_cursor == 0: + _lod_batch_refill_passes += 1 + _lod_batch_refill_complete = not _lod_batch_refill_found_work + _lod_batch_refill_found_work = false + if _lod_batch_refill_complete: + return + + +func _lod_batch_is_eligible(key: Vector2i) -> bool: + if not lod_batching_enabled or _lod_batch_rejected.has(key): + return false + for pos in _lod_batch_members(key): + if not _desired.has(pos) or not _chunk_uses_lod(pos): + return false + var chunk: Chunk = _chunks.get(pos) + if chunk == null or not chunk.lod or chunk.mesh == null or chunk.mesh.mesh == null: + return false + return true + + +## The foreground stream always wins. Compact aggregates are presentation-only: +## if a collision-ring chunk is missing/not ready or a commit still needs the +## scene thread, leave all source meshes visible and service that work first. +func _batching_has_foreground_pressure() -> bool: + if not _commit_queue.is_empty(): + return true + 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) + if not _desired.has(pos): + continue + var chunk: Chunk = _chunks.get(pos) + if chunk == null or chunk.lod or not _is_chunk_collision_ready(pos): + return true + return false + + +## ArrayMesh construction is a RenderingServer-facing operation and therefore +## intentionally stays on the scene thread. It does not create worker tasks, +## cannot consume the urgent collision slot, and is capped by the caller. +func _process_lod_batch_queue() -> void: + if not lod_batching_enabled or lod_distance >= render_distance: + return + if _lod_batch_refill_complete and _lod_batch_queue.is_empty(): + return + if _batching_has_foreground_pressure(): + return + # `_process_commit_queue()` gets the first share of the streaming frame. The + # actual ArrayMesh upload below cannot be preempted, so this is a soft guard: + # do not begin it after commits have already used most of their time budget. + if _commit_budget_used_usec > COMMIT_BUDGET_MS * 1000 - BATCH_UPLOAD_SOFT_BUDGET_USEC: + return + _refill_lod_batch_queue() + var uploads := 0 + while uploads < MAX_LOD_BATCH_UPLOADS_PER_TICK and not _lod_batch_queue.is_empty(): + var closest_index := 0 + var closest_priority := _lod_batch_priority(_lod_batch_queue[0]) + for index in range(1, _lod_batch_queue.size()): + var priority := _lod_batch_priority(_lod_batch_queue[index]) + if priority < closest_priority: + closest_index = index + closest_priority = priority + var key: Vector2i = _lod_batch_queue[closest_index] + _lod_batch_queue.remove_at(closest_index) + _lod_batch_queued.erase(key) + if not _lod_batch_is_eligible(key): + continue + _build_lod_batch(key) + uploads += 1 + + +func _build_lod_batch(key: Vector2i) -> void: + _invalidate_lod_batch(key) + if not _lod_batch_is_eligible(key): + return + var origin_chunks := key * LOD_BATCH_SIZE + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uvs := PackedVector2Array() + var colors := PackedColorArray() + var light := PackedFloat32Array() + var layers := PackedFloat32Array() + var indices := PackedInt32Array() + var members := _lod_batch_members(key) + var vertex_total := 0 + var index_total := 0 + for pos in members: + var source_mesh: Mesh = (_chunks[pos] as Chunk).mesh.mesh + vertex_total += source_mesh.surface_get_array_len(0) + index_total += source_mesh.surface_get_array_index_len(0) + if vertex_total > MAX_LOD_BATCH_VERTICES or index_total > MAX_LOD_BATCH_INDICES: + _lod_batch_geometry_rejected += 1 + _lod_batch_rejected[key] = true + return + for pos in members: + var chunk: Chunk = _chunks[pos] + var arrays: Array = chunk.mesh.mesh.surface_get_arrays(0) + if arrays.size() <= Mesh.ARRAY_INDEX: + return + var source_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var source_normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] + var source_uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV] + var source_colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR] + var source_light: PackedFloat32Array = arrays[Mesh.ARRAY_CUSTOM0] + var source_layers: PackedFloat32Array = arrays[Mesh.ARRAY_CUSTOM1] + var source_indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + if source_vertices.is_empty() or source_light.is_empty() or source_layers.is_empty(): + return + var offset := Vector3(float((pos.x - origin_chunks.x) * VoxelDefs.CHUNK_SIZE), 0.0, + float((pos.y - origin_chunks.y) * VoxelDefs.CHUNK_SIZE)) + var base := vertices.size() + for vertex in source_vertices: + vertices.append(vertex + offset) + normals.append_array(source_normals) + uvs.append_array(source_uvs) + colors.append_array(source_colors) + light.append_array(source_light) + layers.append_array(source_layers) + for index in source_indices: + indices.append(index + base) + var mesh := ChunkMesher.arrays_to_mesh(vertices, normals, uvs, colors, indices, + _blocks.material, light, layers) + if mesh == null: + return + var batch := LodRenderBatch.new() + batch.members = members + batch.mesh = MeshInstance3D.new() + batch.mesh.name = "LodBatch_%d_%d" % [key.x, key.y] + batch.mesh.position = Vector3(origin_chunks.x * VoxelDefs.CHUNK_SIZE, 0.0, + origin_chunks.y * VoxelDefs.CHUNK_SIZE) + batch.mesh.mesh = mesh + add_child(batch.mesh) + _lod_batches[key] = batch + for pos in members: + (_chunks[pos] as Chunk).mesh.visible = false + _lod_batch_uploads += 1 + + +func _invalidate_lod_batch(key: Vector2i) -> void: + # A new member mesh may fit even when the previous group was oversized. + _lod_batch_rejected.erase(key) + var batch: LodRenderBatch = _lod_batches.get(key) + if batch == null: + return + if batch.mesh != null and is_instance_valid(batch.mesh): + batch.mesh.visible = false + for pos in batch.members: + var chunk: Chunk = _chunks.get(pos) + if chunk != null and chunk.mesh != null and is_instance_valid(chunk.mesh): + chunk.mesh.visible = true + if batch.mesh != null and is_instance_valid(batch.mesh): + # Hiding above is immediate; deleting the old aggregate is deferred. + batch.mesh.queue_free() + _lod_batches.erase(key) + + +func _clear_lod_batches() -> void: + for key in _lod_batches.keys(): + _invalidate_lod_batch(key) + _lod_batch_queue.clear() + _lod_batch_queued.clear() + _reset_lod_batch_refill() + + +func _reconcile_lod_batches() -> void: + for key in _lod_batches.keys(): + if not _lod_batch_is_eligible(key): + _invalidate_lod_batch(key) + + func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> void: + # A replacement cannot share an old aggregate: reveal its current authoritative + # mesh first, then request a fresh group only after this commit has landed. + _invalidate_lod_batch(_lod_batch_key(pos)) _generated.erase(pos) var chunk: Chunk = _chunks.get(pos) + var was_streamed := chunk != null and _desired.has(pos) \ + and chunk.lod == _chunk_uses_lod(pos) + if not was_streamed and _desired.has(pos) and lod == _chunk_uses_lod(pos): + _streamed_count += 1 var mode_changed := chunk != null and chunk.lod != lod if chunk == null: chunk = _create_chunk_nodes(pos) @@ -1073,6 +1466,7 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi _seed_gravity_edits(pos) 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) + chunk.mesh.visible = true if not lod and _within_collision_range(pos) and not res.collision.is_empty(): _ensure_chunk_collision_nodes(chunk, pos) var shape := ConcavePolygonShape3D.new() @@ -1086,6 +1480,8 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi _invalidate_mode_change_neighbors(pos) if ENABLE_COLD_CHUNK_COMPRESSION: _compress_distant_chunks() + if lod: + _queue_lod_batch_for(pos) func _invalidate_mode_change_neighbors(pos: Vector2i) -> void: @@ -1172,23 +1568,20 @@ func _queue_rebuild(pos: Vector2i, preserve_if_pending := false, _dirty[pos] = true return if _gen_queued.has(pos) or _mesh_queued.has(pos): + if preserve_if_pending and not low_priority: + if _mesh_queued.has(pos): + _work_push(_mesh_queue, _mesh_queued, pos, true) + else: + _work_push(_gen_queue, _gen_queued, pos, true) return _dirty[pos] = true var chunk: Chunk = _chunks.get(pos) var can_remesh := chunk != null and chunk.lod == _chunk_uses_lod(pos) if can_remesh: - if low_priority: - _mesh_queue.append(pos) - else: - _mesh_queue.push_front(pos) - _mesh_queued[pos] = true + _work_push(_mesh_queue, _mesh_queued, pos, preserve_if_pending and not low_priority) else: _generated.erase(pos) - if low_priority: - _gen_queue.append(pos) - else: - _gen_queue.push_front(pos) - _gen_queued[pos] = true + _work_push(_gen_queue, _gen_queued, pos, preserve_if_pending and not low_priority) func _unload_far() -> void: @@ -1202,6 +1595,9 @@ func _free_chunk(pos: Vector2i) -> void: var chunk: Chunk = _chunks.get(pos) if chunk == null: return + _invalidate_lod_batch(_lod_batch_key(pos)) + if _desired.has(pos) and chunk.lod == _chunk_uses_lod(pos): + _streamed_count -= 1 # 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) @@ -2190,8 +2586,9 @@ func _update_gravity_cell(position: Vector3i, changed_chunks: Dictionary) -> voi func _is_gravity_passable(block_id: int) -> bool: - return block_id == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(block_id) \ - or _blocks.has_flag(block_id, BlockRegistry.FLAG_CROSS) + # Cross blocks have inventory value and VoxelWorld has no drop owner. Treat + # them as support rather than silently replacing them during simulation. + return block_id == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(block_id) func _flush_gravity_changes(changed_chunks: Dictionary) -> void: @@ -2223,10 +2620,8 @@ func _touch_chunk(chunk_position: Vector2i, block_position: Vector3i, 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. + # Neighbor light/seam updates remain required, but the edited owner gets + # explicit feedback priority instead of waiting behind their heavy remeshes. for neighbor_position in rebuild_neighbors: _queue_rebuild(neighbor_position, true, true) _queue_rebuild(chunk_position, true) @@ -2483,6 +2878,61 @@ func get_worldgen_stats() -> Dictionary: "populate ms": _populate_ema_ms, "mesh ms ema": _mesh_ema_ms, "revision": _worldgen_revision, + "workers": "%d / %d (+1 urgent)" % [_pending.size(), _max_active_jobs], + "generation jobs": _generation_jobs, + "mesh jobs": _mesh_jobs, + "discarded jobs": _discarded_jobs, + "generation worker ms total": float(_generation_usec) / 1000.0, + "mesh worker ms total": float(_mesh_usec) / 1000.0, + "commit ms total": float(_commit_usec) / 1000.0, + "stream main ms total": float(_stream_main_usec) / 1000.0, + "lod render batches": _lod_batches.size(), + "lod batch queued": _lod_batch_queue.size(), + "lod batch uploads": _lod_batch_uploads, + "lod batch skipped": _lod_batch_skipped, + "lod batch geometry rejected": _lod_batch_geometry_rejected, + } + + +## Render-only diagnostic for the headless structural verifier and the display +## profiler. A compact source is counted once: either hidden under exactly one +## valid batch or visible as its own mesh. Water is intentionally not counted +## as batched because it remains an individual transparent surface. +func get_lod_batch_stats() -> Dictionary: + var batched_members: Dictionary = {} + var visible_members := 0 + var invalid_batches := 0 + for key in _lod_batches: + var batch: LodRenderBatch = _lod_batches[key] + if batch == null or batch.members.size() != LOD_BATCH_SIZE * LOD_BATCH_SIZE \ + or batch.mesh == null or not is_instance_valid(batch.mesh): + invalid_batches += 1 + continue + for pos in batch.members: + batched_members[pos] = int(batched_members.get(pos, 0)) + 1 + for pos in _chunks: + var chunk: Chunk = _chunks[pos] + if not chunk.lod: + continue + if chunk.mesh != null and chunk.mesh.visible: + visible_members += 1 + var duplicate_members := 0 + for count in batched_members.values(): + if int(count) > 1: + duplicate_members += 1 + return { + "enabled": lod_batching_enabled, + "batches": _lod_batches.size(), + "batched members": batched_members.size(), + "visible lod members": visible_members, + "duplicate members": duplicate_members, + "invalid batches": invalid_batches, + "queued": _lod_batch_queue.size(), + "uploads": _lod_batch_uploads, + "skipped": _lod_batch_skipped, + "geometry rejected": _lod_batch_geometry_rejected, + "refill complete": _lod_batch_refill_complete, + "refill passes": _lod_batch_refill_passes, } @@ -2490,11 +2940,7 @@ func get_worldgen_stats() -> Dictionary: ## feedback. `loaded` counts only chunks that match the current detail mode, so ## a Full/LOD transition never reports stale terrain as finished streaming. func get_streaming_progress() -> Dictionary: - var loaded := 0 - for pos in _desired: - var chunk: Chunk = _chunks.get(pos) - if chunk != null and chunk.lod == _chunk_uses_lod(pos): - loaded += 1 + var loaded := _streamed_count var total := _desired.size() var spawn_loaded := 0 var spawn_collision := 0 diff --git a/world/worldgen/README.md b/world/worldgen/README.md index c4cff73..761b985 100644 --- a/world/worldgen/README.md +++ b/world/worldgen/README.md @@ -45,6 +45,14 @@ each chunk as eight neighbors arrive. Later edits, collision additions, and neighbor invalidations rebuild from immutable snapshots of loaded chunk data; only an actual full/LOD mode transition reruns terrain generation. +Generation and meshing use min-heaps with a shared priority policy: the current +stream-center chunk first, then its immediate ring (local edited owners get +feedback priority), then the collision-radius ring, then distant chunks. Within +each tier use squared distance, not worker completion order. Ready meshes win +equal priorities; distant meshes never unconditionally outrank nearer generation. +Heaps are rebuilt in linear time on stream-center changes and dispatch costs +O(log queued chunks), without shifting the full generation array on each pop. + All coordinates are global and all generation state is read-only after configuration. Do not add mutable sampler caches or scene-tree access to worker code. Any cache must be bounded, synchronized, and keyed by the complete config @@ -63,6 +71,21 @@ chunks. It intentionally excludes `VoxelWorld` scheduling and neighbors; use `stream_full_verify.gd` to time the end-to-end authoritative stream. Compare like-for-like runs on the same machine. +The Ryzen 7 7700 streaming investigation, actual worker-limit sweep, and +output-preserving mesh improvements are recorded in `tools/CHUNK_PERFORMANCE.md`. +`tools/stream_transition_verify.gd` covers distance changes while paused, rapid +in-flight changes, and Full/Balanced transitions (`-- --normal` uses normal +terrain instead of the fast flat fixture). Streaming continues while menus are +paused, but the water/gravity/fire simulation clocks do not advance. + +`tools/population_benchmark.gd` breaks full population into individual stages and +pins twelve preoptimization output hashes. The current footprint pruning skips +terrain queries only when the candidate's entire write/clear footprint cannot +touch the target chunk. Keep `FEATURE_FOOTPRINT_RADIUS` conservative when adding +new stamps; `tools/population_pruning_verify.gd` verifies catalog stamp extents. +All overlapping candidates must still use the same global site decisions and +stamping order, including across negative-coordinate chunk borders. + ## Main tuning controls Canonical defaults, validation, and import ranges live in `world_gen_config.gd`; diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index bc91160..3307664 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -16,6 +16,9 @@ const VoxelDefsScript = preload("res://world/voxel_defs.gd") const SURFACE_CLEARANCE: int = 3 const FEATURE_CELL_SIZE: int = 8 const FEATURE_HALO: int = 6 +# All current stamps, including legacy fallen logs and asymmetric acacia, +# remain within three blocks of their anchor. Keep the wider owner enumeration. +const FEATURE_FOOTPRINT_RADIUS: int = 3 # Grove candidates are six blocks apart and have a maximum horizontal tree # footprint of three blocks (jungle/spruce). The three-block halo makes every # touched chunk evaluate the same global candidate; FEATURE_HALO remains the @@ -366,6 +369,9 @@ func _accepted_region_structures(field: ChunkTerrainData, origin_x: int, origin_ var candidate: StructureCatalog.Candidate = StructureCatalogScript.candidate_for(config.seed, owner_x, owner_z) if candidate == null: continue + if not _footprint_intersects_chunk(candidate.anchor.x, candidate.anchor.y, + StructureCatalogScript.HORIZONTAL_HALO, origin_x, origin_z): + 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]) @@ -524,6 +530,13 @@ func _badlands_stratum(world_x: int, y: int, world_z: int) -> int: func _carve_noise_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: var tunnel_width := clampf(0.055 + 0.035 * config.cave_density, 0.04, 0.16) var chamber_threshold := clampf(0.62 - 0.07 * config.cave_density, 0.44, 0.62) + var tunnel_limits := PackedFloat64Array() + var chamber_limits := PackedFloat64Array() + tunnel_limits.resize(VoxelDefsScript.SEA_LEVEL + 53) + chamber_limits.resize(VoxelDefsScript.SEA_LEVEL + 53) + for y in range(4, tunnel_limits.size()): + tunnel_limits[y] = tunnel_width * clampf(float(VoxelDefsScript.SEA_LEVEL + 52 - y) / 24.0, 0.35, 1.0) + chamber_limits[y] = chamber_threshold + clampf(float(y - 12) / 72.0, 0.0, 1.0) * 0.10 for local_z in VoxelDefsScript.CHUNK_SIZE: var world_z := origin_z + local_z for local_x in VoxelDefsScript.CHUNK_SIZE: @@ -540,13 +553,12 @@ func _carve_noise_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x var voxel_index := _index(local_x, y, local_z) if not _is_carvable_stone(data[voxel_index]): continue - var high_taper := clampf(float(VoxelDefsScript.SEA_LEVEL + 52 - y) / 24.0, 0.35, 1.0) var ridge_a := absf(_cave_spaghetti_a.get_noise_3d(world_x, y, world_z)) - var ridge_b := absf(_cave_spaghetti_b.get_noise_3d(world_x, y, world_z)) - var spaghetti := maxf(ridge_a, ridge_b) < tunnel_width * high_taper - var depth_bias := clampf(float(y - 12) / 72.0, 0.0, 1.0) * 0.10 - var cheese := _cave_cheese.get_noise_3d(world_x, y, world_z) > chamber_threshold + depth_bias - if spaghetti or cheese: + # Noise is immutable: skip the second ridge when the first already + # rejects the tunnel, and skip cheese when a tunnel already carves. + var spaghetti := ridge_a < tunnel_limits[y] \ + and absf(_cave_spaghetti_b.get_noise_3d(world_x, y, world_z)) < tunnel_limits[y] + if spaghetti or _cave_cheese.get_noise_3d(world_x, y, world_z) > chamber_limits[y]: data[voxel_index] = BlockRegistryScript.BLOCK_AIR @@ -1125,6 +1137,8 @@ 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) + if not _footprint_intersects_chunk(world_x, world_z, FEATURE_FOOTPRINT_RADIUS, origin_x, origin_z): + continue var ground := _cached_decoration_ground(field, world_x, world_z, ground_scratch) if ground.x < 0: continue @@ -1172,6 +1186,8 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, # from occupying another candidate's root/trunk volume. var world_x: int = cell_x * TREE_CELL_SIZE + 2 + anchor_hash % 2 var world_z: int = cell_z * TREE_CELL_SIZE + 2 + (anchor_hash / 23) % 2 + if not _footprint_intersects_chunk(world_x, world_z, TREE_FOOTPRINT_RADIUS, origin_x, origin_z): + continue 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_scratch) @@ -1212,6 +1228,8 @@ func _collect_trees(field: ChunkTerrainData, origin_x: int, origin_z: int, 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) + if not _footprint_intersects_chunk(world_x, world_z, FEATURE_FOOTPRINT_RADIUS, origin_x, origin_z): + continue 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_scratch) @@ -1411,6 +1429,10 @@ func _decorate_ground_cover(data: PackedByteArray, field: ChunkTerrainData, orig var anchor_hash: int = WorldGenHashScript.hash_2d(config.seed + 1181, cell_x, cell_z) var center_x: int = cell_x * GROUND_COVER_CELL_SIZE + 2 + anchor_hash % (GROUND_COVER_CELL_SIZE - 4) var center_z: int = cell_z * GROUND_COVER_CELL_SIZE + 2 + (anchor_hash / 29) % (GROUND_COVER_CELL_SIZE - 4) + # Tufts spread two blocks from the anchor; excluded anchors cannot + # write any voxel here, regardless of their terrain/biome decision. + if not _footprint_intersects_chunk(center_x, center_z, 2, origin_x, origin_z): + continue var ground := _decoration_ground(field, center_x, center_z) if ground.x <= VoxelDefsScript.SEA_LEVEL + 1: continue @@ -1483,6 +1505,8 @@ 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) + if not _footprint_intersects_chunk(center_x, center_z, UNDERWATER_TUFT_RADIUS, origin_x, origin_z): + continue 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 @@ -1499,6 +1523,8 @@ 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 + if not _footprint_intersects_chunk(world_x, world_z, 0, origin_x, origin_z): + continue 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 @@ -1708,6 +1734,14 @@ func _grove_leaf_block(decoration_set: int) -> int: return BlockRegistryScript.BLOCK_AIR +## Reject only footprints that cannot touch this chunk. Terrain/site decisions +## for every overlapping candidate remain global and generation-order independent. +func _footprint_intersects_chunk(world_x: int, world_z: int, radius: int, origin_x: int, origin_z: int) -> bool: + return world_x + radius >= origin_x and world_z + radius >= origin_z \ + and world_x - radius < origin_x + VoxelDefsScript.CHUNK_SIZE \ + and world_z - radius < origin_z + VoxelDefsScript.CHUNK_SIZE + + ## The field handles the immediate border; farther feature-cell origins query ## the same immutable global sampler so large decorations agree across chunks. func _decoration_ground(field: ChunkTerrainData, world_x: int, world_z: int) -> Vector2i: From c2de5dff7699cff3addcef158543d6d17c10fc1d Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 19 Sep 2026 18:00:44 +0100 Subject: [PATCH 3/3] Avoid repeated gravity edit scans --- AGENTS.md | 7 +++++-- tools/block_physics_verify.gd | 20 ++++++++++++++++++++ ui/loading_overlay.gd | 4 +++- world/voxel_world.gd | 10 ++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d7db96..4ce10f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - `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. + - `begin_initial_stream()` keeps player/photo input locked until `initial_stream_ready` confirms the authoritative 3x3 collision ring. `VoxelWorld` uses `PROCESS_MODE_ALWAYS`, so worker collection and commits continue under menus while water, fire, and gravity simulation stay paused. - 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, caves, data-driven ores, and tree/structure stamping. Deterministic per seed, no mutable state after `configure()`. `WorldGenConfig.CURRENT_VERSION` is 14: 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, v13 removes rigid fallen-log/driftwood decorations, and v14 generates ores independently of cave density in non-flat worlds (older worlds retain the old gate). `generate_data(pos, edits, lod)` returns either full voxel data or compact LOD columns; both modes consume the same authoritative inland-water field. @@ -38,6 +39,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - Cutout blocks (leaves, glass, torch) are antialiased with `alpha_to_coverage`, so their edge quality depends on MSAA being enabled. - Water: `BLOCK_WATER` (16) is a source; IDs 28-34 are flowing levels 7..1, resolved through `BlockRegistry.is_water_id()`/`water_level()`/`water_id_for_level()`. `VoxelWorld` runs a 4 Hz cellular flow tick (`_water_tick`, 1024 cells/tick) seeded by breaks/places near water; flow replaces only air or weaker flowing water, falls when the cell below is air, and dries when unfed. Every change goes through `_record_edit()`, so the settled state is reapplied on chunk regeneration; `_edited_blocks` is mirrored into `_edits_by_chunk` so job snapshots and `TerrainGenerator.generate_data()` only see the chunk's own edits. Meshes are non-opaque, non-breakable, no collision and get the same `ARRAY_CUSTOM0` light attribute as blocks; flowing surfaces render at `_water_top(level)` and set vertex-color alpha so `water.gdshader` can calm the waves. `water.gdshader` samples `hint_screen_texture` for screen-space refraction (wave-distorted background with a faint chromatic split, carried through `EMISSION` so the refracted scene is not lit twice), `hint_depth_texture` for shore blending (shallow water is lighter, more transparent, and foams at the edge), and block light for warm glow. - Fire: burning a block never replaces it. A `BlockRegistry.FLAG_FLAMMABLE` log/leaf/dry plant/mushroom enters the `_burning` state for `FUEL_BURN_TICKS` with its texture and collision intact, and `FireOverlay` (`world/fire_overlay.gd` + `fire_overlay.gdshader`/`smoke_overlay.gdshader`) clusters flame and smoke billboards on it — one MultiMesh per effect, animated entirely in-shader from per-instance phase/progress and rebuilt only on the 4 Hz `VoxelWorld._fire_tick`. Burning blocks crawl into flammable neighbours one at a time, paced by a per-block `FIRE_IGNITION_DELAY_TICKS` (4), `FIRE_SPREAD_PER_TICK` (1) on every `FIRE_SPREAD_PERIOD_TICKS` (2), plus a per-cell `FIRE_SPREAD_INTERVAL_TICKS` cooldown (~2 blocks/s), and can chain-detonate adjacent TNT/nukes. When the timer expires the block crumbles to air and that edit goes through `_record_edit()`, so the settled result survives chunk regeneration (mid-burn state does not persist). `BLOCK_FIRE` (70) is only the standalone cutout cross flame that flint-and-steel leaves on a non-flammable face (`FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE`, animated by `block.gdshader`); it decays when starved/unsupported and can catch adjacent fuel. `use_flint_and_steel()` detonates an explosive, starts the clicked flammable block burning, or lights the clicked face. `tools/fire_verify.tscn` covers the lot. +- Gravity: sand, red sand, and gravel use a deduplicated 4 Hz queue seeded by edits and once per full-detail chunk residency after persisted edits hydrate. Moves record both cells through `_record_edit()` and refuse unloaded/LOD destinations; cross blocks support falling blocks because replacement has no item-drop owner. `tools/block_physics_verify.tscn` covers persistence and lifecycle behavior. - Underwater biomes: `OCEAN`/`DEEP_OCEAN` are split with `KELP_FOREST`, `SEAGRASS_MEADOW`, `CORAL_REEF`, `FROZEN_OCEAN` (BiomeCatalog IDs 15-18), chosen in `TerrainSampler._underwater_biome()` from depth, temperature, and patch noise; `TerrainSampler._ocean_floor_at()` shapes the shelf, slope, and abyss. `VoxelPopulator._decorate_underwater()` grows seabed plants from `DecorationCatalog._underwater_sets` (new blocks 52-58: coral substrate, seagrass, kelp, coral fan/branch, sponge, anemone) and only replaces water; the water mesh culls faces against `FLAG_CROSS` plants and `get_water_ambience()` treats a camera inside one as submerged, so foliage does not sit in glassy air pockets. `VoxelWorld.get_water_ambience()` + `Main._update_underwater()` grade the overlay, `DayNightCycle.set_underwater()` the fog/light/caustics global, and `AudioManager.set_underwater()` a Master low-pass; keep their throttled world queries out of per-frame hot paths. ## Weather / atmosphere @@ -93,13 +95,14 @@ 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_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`. + `unit_tests.gd`, `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`, `stream_transition_verify.gd`, `chunk_priority_verify.gd`, `mesh_parity_verify.gd`, `population_pruning_verify.gd`, `lod_batch_verify.gd`, `light_invalidation_verify.gd`, `asset_license_verify.gd`, `audio_verify.gd`, `weather_verify.gd`, `ui_flow_verify.gd`, `ui_scale_verify.gd`, `input_rebind_verify.gd`, `loading_progress_verify.gd`, `first_run_hints_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. - `redot --headless --path . res://tools/fire_verify.tscn` checks flammability flags, fire ignition/spread/decay, chain detonation of explosives, the flint-and-steel face-light path, persistence reseeding, and the fire shader wiring. Scene-based for the same class_name reason as above. +- `redot --headless --path . res://tools/block_physics_verify.tscn` checks persisted falling blocks; `motion_camera_verify.tscn` checks autoload-backed settings and camera behavior. - `tools/icon_check.gd` needs a rendering display and fails headless. -- Benchmarks: `worldgen_benchmark.gd`, `worldgen_mesh_benchmark.gd`, and `worldgen_stream_benchmark.gd` (the last is minutes long at 32 chunks). +- Benchmarks: `population_benchmark.gd`, `worldgen_benchmark.gd`, `worldgen_mesh_benchmark.gd`, and `worldgen_stream_benchmark.gd` (the last is minutes long at 32 chunks). Rendered profiles are `lod_batch_render_profile.tscn` and `gameplay_render_profile.tscn`. ## Conventions diff --git a/tools/block_physics_verify.gd b/tools/block_physics_verify.gd index 9b2c77b..22e244e 100644 --- a/tools/block_physics_verify.gd +++ b/tools/block_physics_verify.gd @@ -13,6 +13,7 @@ func _ready() -> void: _check_stacked_no_duplication_or_loss() _check_chunk_edge_and_unloaded_cells() _check_lod_safety() + _check_edit_seeding_once_per_residency() _check_persistence() if _failures == 0: print("BLOCK PHYSICS VERIFY: PASS") @@ -157,6 +158,25 @@ func _check_persistence() -> void: world.free() +func _check_edit_seeding_once_per_residency() -> void: + var world := _make_world(4) + var pos := Vector2i.ZERO + var sand := Vector3i(8, 5, 8) + _set_block(world._chunks[pos].data, sand, BlockRegistry.BLOCK_SAND) + world._edits_by_chunk[pos] = {sand: BlockRegistry.BLOCK_SAND} + world._seed_gravity_edits(pos) + world._gravity_tick() + _expect(world._gravity_queue.is_empty(), "supported gravity seed did not drain") + world._seed_gravity_edits(pos) + _expect(world._gravity_queue.is_empty(), + "full-detail remesh reseeded an already resident edit bucket") + world._gravity_seeded_chunks.erase(pos) + world._seed_gravity_edits(pos) + _expect(world._gravity_queue.size() == 1, + "residency reset did not permit gravity edit reseeding") + world.free() + + func _make_world(floor_y: int, add_neighbors := true) -> VoxelWorld: var world := VoxelWorld.new() world._blocks = BlockRegistry.new() diff --git a/ui/loading_overlay.gd b/ui/loading_overlay.gd index 44a29ba..1f558f9 100644 --- a/ui/loading_overlay.gd +++ b/ui/loading_overlay.gd @@ -137,7 +137,9 @@ func complete() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE focus_mode = Control.FOCUS_NONE set_process_unhandled_input(false) - if GameConfig.is_reduced_motion(): + var config := get_tree().root.get_node_or_null("GameConfig") + if config != null and config.has_method("is_reduced_motion") \ + and bool(config.is_reduced_motion()): queue_free() return var tween := create_tween() diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 8d28a0e..2fc2678 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -145,6 +145,7 @@ var _water_head := 0 var _water_accum := 0.0 var _gravity_queue: Array[Vector3i] = [] var _gravity_queued: Dictionary = {} +var _gravity_seeded_chunks: Dictionary = {} var _gravity_head := 0 var _gravity_accum := 0.0 var _fire_queue: Array[Vector3i] = [] @@ -371,6 +372,7 @@ func configure(world_config: Dictionary, render_distance_chunks: int, func set_edit_store(store: WorldStorage) -> void: _edit_store = store _hydrated_edit_chunks.clear() + _gravity_seeded_chunks.clear() _edited_blocks.clear() _edits_by_chunk.clear() @@ -1464,6 +1466,10 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi if not lod: _seed_fire_edits(pos) _seed_gravity_edits(pos) + else: + # A later transition back to Full Detail must retry candidates that were + # intentionally unavailable while this chunk held compact data. + _gravity_seeded_chunks.erase(pos) 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) chunk.mesh.visible = true @@ -1604,6 +1610,7 @@ func _free_chunk(pos: Vector2i) -> void: chunk.mesh.queue_free() chunk.water.queue_free() _chunks.erase(pos) + _gravity_seeded_chunks.erase(pos) _discard_unloaded_chunk_state(pos) @@ -2404,6 +2411,9 @@ func _seed_gravity(block_position: Vector3i) -> void: ## Rechecking every edited cell and its upper neighbour resumes interrupted ## falls after reload without depending on dictionary or chunk load order. func _seed_gravity_edits(pos: Vector2i) -> void: + if _gravity_seeded_chunks.has(pos): + return + _gravity_seeded_chunks[pos] = true var bucket: Dictionary = _edits_by_chunk.get(pos, {}) var positions: Array[Vector3i] = [] for key in bucket: