diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
deleted file mode 100644
index e27d317..0000000
--- a/.gitea/workflows/ci.yml
+++ /dev/null
@@ -1,62 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
-
-jobs:
- test:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13.1"]
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e .[dev]
-
- - name: Run linters
- run: |
- black . --check
- isort . --check
-# flake8 .
-
- - name: Run tests
- run: |
- pytest tests/ --cov=src --cov-report=xml
-
-# Deployment job - uncomment and configure when PyPI uploads are needed
-# This job will build and upload your package to PyPI when merging to main
-#
-# deploy:
-# needs: test
-# runs-on: ubuntu-latest
-# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
-#
-# steps:
-# - uses: actions/checkout@v3
-#
-# - name: Set up Python
-# uses: actions/setup-python@v3
-# with:
-# python-version: "3.8"
-#
-# - name: Build and publish
-# env:
-# TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
-# TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
-# run: |
-# pip install build twine
-# python -m build
-# twine upload dist/*
diff --git a/.gitignore b/.gitignore
index d7ba603..4dccd8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,9 +45,22 @@ unittest/
# Distribution
dist/
build/
+docs/
*.exe
+output/
# Logs
logs/
*.log
livenotes.txt
+AGENT.md
+.pyrightignore
+.gitignore
+sandpypi-old.7z
+.sdt
+
+# Builds
+bin/
+obj/
+Sand.Core\artifacts
+Sand.App\artifacts
diff --git a/.pre-commit-config.yaml.disabled b/.pre-commit-config.yaml.disabled
deleted file mode 100644
index 7d9c87a..0000000
--- a/.pre-commit-config.yaml.disabled
+++ /dev/null
@@ -1,30 +0,0 @@
-#repos:
-#- repo: https://github.com/psf/black
-# rev: 23.12.1
-# hooks:
-# - id: black
-# language_version: python3
-# args: ["--line-length", "79"]
-
-#- repo: https://github.com/pycqa/isort
-# rev: 5.13.2
-# hooks:
-# - id: isort
-# args: ["--profile", "black", "--filter-files"]
-
-#- repo: https://github.com/pre-commit/pre-commit-hooks
-# rev: v4.5.0
-# hooks:
-# - id: trailing-whitespace
-# - id: end-of-file-fixer
-# - id: check-yaml
-# - id: check-added-large-files
-
-#- repo: local
-# hooks:
-# - id: pytest
-# name: pytest
-# entry: pytest
-# language: system
-# pass_filenames: false
-# always_run: false
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 7774467..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
-
- {
- "name": "Python Debugger: Current File",
- "type": "debugpy",
- "request": "launch",
- "program": "${file}",
- "console": "integratedTerminal"
- }
- ]
-}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 0d3d120..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "python.linting.pylintArgs": [
- "--disable=E1101",
- "--ignored-modules=pygame"
- ],
- "cody.agentic.context.experimentalShell": true
-}
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..5b918b1
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+ $(DefaultItemExcludes);**\artifacts\**
+
+
diff --git a/README.md b/README.md
index ab64952..b00c779 100644
--- a/README.md
+++ b/README.md
@@ -1,55 +1,60 @@
-# ***Falling Sand Simulation Concept in Python***
+# Sand C#
-mostly a concept in python for falling sand simulation
-i guess the goal is to make a python version of the powder toy which is a falling sand sim
-the code is not finished yet, but i will update it as i go.
+This repo is a C# falling-sand / falling-everything engine and app shell. The first game is a falling-sand game, but the longer-term direction is a broader simulation-heavy engine with larger worlds.
-## Main Features
+## Repo layout
-- Particle Physics
- - Gravity and wind effects (maybe on the wind zones)
- - Temperature dynamics
- - State transitions (melting, freezing, evaporation)
-
-- Particle Interactions
- - Collision detection
- - Chemical reactions (e.g., water + sand = wet sand, Lava + lower temperature = molten rock = rock)
- - Heat transfer between particles (e.g., Things seem to cool off as for heating up that's a different thing)
-
-- Special Effects
- - Fire propagation sorta
- - Smoke generation
- - Liquid spreading (Could be improved )
-
-- Optimization Features
- - Spatial partitioning grid (to reduce calculations)
- - Dormant particle tracking (to reduce unnecessary calculations)
- - Batch processing (to reduce unnecessary calculations)
- - Static User Interface (to reduce unnecessary calculations)
+- `Sand.Core/`, `Sand.App/`, `Sand.Tests/`: production dense backend, app shell, and regression tests
+- `Sand.ChunkPrototype/`, `Sand.ChunkPrototype.Tests/`: experimental chunk backend and its tests
+- `Sand.Benchmarks/`: dense/chunk benchmark runner with app-sized and snapshot modes
+- `advchksys/`: imported chunk-system library kept as a support/reference asset
+- `docs/csharp-parity-audit.md`: current content/runtime mapping audit
+- `docs/chunk-engine-progress.md`: chunk program log, blockers, and milestone measurements
+- `ROADMAP.md`: authoritative implementation order and promotion gates
-### **Current Features**
+## Current status
-| **Working** | **Partial** | **Not Working/Implemented** |
-| ----------------- | ----------------------------- | --------------------------- |
-| Gravity | Gas | Zoom |
-| Liquid/Solid | Liquid | Pause |
-| Particle Data | Fire/Flame | Pressure |
-| Brush Sizes | Particle Basic Interactions | explosiveness |
-| Debugging Info | Particle Heat interactions | conductiveness |
-| Menus and Buttons | Ambient Heat | |
-| | Wind | |
-| | Core Physics and Interactions | |
-| | Core Rendering System | |
+The dense backend is still the production/reference path. It already supports fixed-timestep stepping, pressure, thermal, explosions, tool fields, and the current app/debug shell.
-#### **Controls**
+The chunk backend also exists in the main app as an experimental backend, but it is not yet faster than dense in the full game shell. Current chunk work is focused on stabilization, optimization, truthful measurement, and subsystem-by-subsystem parity growth.
-| Key | Action |
-| ------------------- | ------------------------------ |
-| Z | Zoom window |
-| ESC | Exit Program |
-| C | Clear Screen |
-| Space | Pause Simulation |
-| Mouse 1 {Left} | Spawn Particle with brush size |
-| Mouse 3 {Right} | Erase Particles at cursor |
-| Mouse 2 {Middle} | Spawn Particle at cursor |
-| Mouse Wheel Up/Down | Change Brush size |
+Real app runs are the source of truth for chunk progress. Benchmarks support decisions, but they do not replace interactive testing.
+
+## Run
+
+Dense app:
+
+```powershell
+Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue
+dotnet run --project .\Sand.App\Sand.App.csproj -c Release
+```
+
+Chunk app:
+
+```powershell
+$env:SAND_BACKEND='chunk'
+dotnet run --project .\Sand.App\Sand.App.csproj -c Release
+```
+
+Clear the override after chunk testing:
+
+```powershell
+Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue
+```
+
+Benchmarks:
+
+```powershell
+dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode app-sized
+```
+
+```powershell
+dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes
+```
+
+## Validation
+
+```powershell
+dotnet test Sand.sln
+dotnet test .\Sand.ChunkPrototype.Tests\Sand.ChunkPrototype.Tests.csproj -c Release
+```
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..ab5d007
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,170 @@
+# Roadmap
+
+This file is the single roadmap for the C# engine, the first falling-sand game, and the experimental chunk-engine program.
+
+Status labels:
+
+- `[x]` done
+- `[-]` partial / in progress
+- `[ ]` not started
+
+## Current State
+
+### Repo and runtime
+
+- `[x]` C# code split into `Sand.Core`, `Sand.App`, and `Sand.Tests`
+- `[x]` dense production backend with fixed-timestep stepping, pressure, thermal, explosions, and tool fields
+- `[x]` main app backend seam with dense default and chunk experimental selection via `SAND_BACKEND`
+- `[x]` benchmark harness under `Sand.Benchmarks`
+- `[x]` chunk residency/prototype runtime under `Sand.ChunkPrototype`
+- `[x]` imported `advchksys` kept as a support/prototype asset, not the production runtime substrate
+
+### Chunk status
+
+- `[x]` chunk residency scaffold exists
+- `[x]` chunk backend is integrated into the main app as an experimental backend
+- `[x]` chunk motion-regression tests cover wake-on-paint and shimmer issues found so far
+- `[x]` active-move bottleneck instrumentation completed
+- `[-]` high-motion chunk workloads are materially more stable in the real main app shell, and long-press gas overload no longer easily death-spirals the app, but mixed-scene runtime hotspots remain
+- `[x]` chunk runtime can attribute active-frame cost to movement / runtime / activation / render buckets
+- `[-]` live-app branch metrics now identify mixed-scene full-runtime work across solids, liquids, and gases as the main remaining single-thread hotspot after gas-stress stabilization
+- `[ ]` chunk backend competitive in the real main app shell
+- `[ ]` chunk-local page-step architecture fully optimized
+- `[ ]` chunk dirty-page render path fully paying off in the real app
+- `[x]` chunk metrics/progress log added in [`docs/chunk-engine-progress.md`](/f:/Documents/Dev/sandpypi/docs/chunk-engine-progress.md)
+
+## Parity and Engine Gaps
+
+### Dense/runtime gaps
+
+- `[-]` movement-property tuning from metadata (`velocity`, `friction`, `viscosity`, `heat_capacity`)
+- `[-]` pressure/breakage tuning polish
+- `[ ]` deterministic snapshot capture and comparison harness
+- `[ ]` richer UI/debug overlays beyond current parity level
+- `[ ]` accelerator implementation behind `ISimulationAccelerator`
+
+### Chunk/runtime gaps
+
+- `[ ]` chunk-local contiguous hot-path storage replacing the current prototype-level iteration costs
+- `[ ]` chunk-local active-band stepping and better neighbor wake scheduling
+- `[ ]` chunk-aware frame generation that clearly reduces bytes touched on localized scenes
+- `[ ]` parity ladder for reactions, phase transitions, thermal, burning, and explosions
+- `[ ]` deterministic dense-vs-chunk snapshot parity for ported subsystems
+
+## Next Milestones
+
+### Dense Track
+
+- `[-]` continue movement-property and force tuning from metadata
+- `[ ]` add snapshot capture/comparison harness for named scenes
+- `[ ]` keep regression coverage growing around perf-sensitive fixes
+- `[ ]` stage the first accelerator backend only after snapshot coverage exists
+
+### Chunk Engine Program
+
+#### Chunk Program A: Truthful measurement and tracking
+
+- `[x]` add dated chunk progress / metrics log
+- `[x]` add app-sized benchmark mode matching the main app sim dimensions
+- `[x]` record first dense-vs-chunk app baseline
+- `[x]` split chunk measurements into clearer step-core / activation / render buckets
+- `[ ]` keep milestone-relevant benchmark and app results logged in the progress doc
+
+#### Chunk Program B: Step-core stabilization
+
+- `[x]` remove wake-on-paint settling artifacts
+- `[x]` remove trapped liquid/gas shimmer regressions currently covered by tests
+- `[x]` add chunk motion-regression suite
+- `[-]` continue stabilizing gas/liquid/solid motion under mixed tool-field stress
+
+#### Chunk Program C: Chunk-local architecture rewrite
+
+- `[x]` remove LINQ / global particle sorting from the chunk step hot path
+- `[x]` introduce chunk-local page storage types and scheduler scaffolding
+- `[-]` step active pages using dirty-band guidance
+- `[x]` wake neighbor pages on border writes
+- `[ ]` reduce remaining page-lookup and page-render overhead in real app workloads
+
+#### Chunk Program D: Rendering + stats
+
+- `[-]` dirty-page / dirty-chunk frame generation path
+- `[x]` extend shared chunk frame stats
+- `[x]` surface chunk workload metrics in the app debug overlay
+- `[ ]` make localized chunk rendering wins clearly visible in app-sized measurements
+
+#### Chunk Program E: Active-Move Optimization Gate
+
+- `[x]` add move-attempt / move-result / stalled-motion metrics
+- `[x]` add runtime bucket timing for movement / runtime / activation / field decay / render
+- `[x]` add active-motion benchmark scenes and app procedures
+- `[-]` reduce chunk cost on `10k-15k` simultaneous move frames
+- `[x]` verify chunk remains stable under gas-heavy active scenes enough to stop easy long-press collapse
+- `[x]` document high-motion baselines and improvements in the progress log
+- `[-]` use the stabilized gas/app baseline to reduce remaining mixed-scene full-runtime cost before resuming parity work
+
+#### Chunk Program F: Feature parity ladder
+
+- `[ ]` base motion parity improvements
+- `[ ]` chunk force / pressure tuning
+- `[ ]` common-material phase transitions
+- `[ ]` core local reactions
+- `[ ]` thermal subset
+- `[ ]` burning/effect subset
+- `[ ]` explosion / breakage subset
+
+#### Chunk Program G: Promotion gate
+
+- `[ ]` sparse app workload beats dense by `>=25%`
+- `[ ]` mixed app workload beats dense by `>=10%`
+- `[ ]` dense-like workload remains within `15%`
+- `[ ]` snapshot suite passes for all ported subsystems
+
+## Suggested Order
+
+1. add active-move instrumentation and logging
+2. establish dense vs chunk high-motion baselines in the real app shell
+3. reduce chunk active-move overhead in the step core
+4. confirm gains on gas-heavy and continuous-paint scenes
+5. reduce remaining mixed-scene full-runtime hotspots from the stabilized single-thread baseline
+6. plan multithreaded architecture from the stabilized single-thread baseline
+7. only then resume chunk parity expansion and deeper engine storage promotion
+
+## Commands
+
+Dense app:
+
+```powershell
+Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue
+dotnet run --project .\Sand.App\Sand.App.csproj -c Release
+```
+
+Chunk app:
+
+```powershell
+$env:SAND_BACKEND='chunk'
+dotnet run --project .\Sand.App\Sand.App.csproj -c Release
+```
+
+Benchmarks:
+
+```powershell
+dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode app-sized
+```
+
+```powershell
+dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes
+```
+
+## Notes
+
+- Dense remains the production/reference backend while chunk continues as the experimental engine lane.
+- The current chunk bottleneck is high simultaneous movement volume, not simple on-screen particle count.
+- Gas-stress long-press collapse is now materially harder to trigger in the main app after overload control and gas-path tuning.
+- The main remaining single-thread hotspot is mixed-scene full-runtime work across solids, liquids, and gases.
+- Recent live-app runs show the stable-gas case is now mostly under control; the next optimization passes should target mixed runtime-chain cost rather than reopening gas-specific hacks.
+- Parity work is paused behind the active-move optimization gate.
+- `moves/frame` and FPS are both required signals; benchmark-only wins are not enough by themselves.
+- No particle-id-specific throttles are planned; chunk optimizations need to generalize across gas/runtime-heavy materials.
+- Multithreaded architecture is a follow-on goal only after the single-thread chunk step is stable, measured, and behaviorally trustworthy.
+- The chunk backend is now an engine program, not a one-off feasibility toy, but it still has to beat dense under real workloads before promotion.
+- `advchksys` remains a support library and reference asset, not the final Sand engine storage contract.
diff --git a/Sand.App/AppSimulationFrameStats.cs b/Sand.App/AppSimulationFrameStats.cs
new file mode 100644
index 0000000..443adaa
--- /dev/null
+++ b/Sand.App/AppSimulationFrameStats.cs
@@ -0,0 +1,39 @@
+namespace Sand.App;
+
+internal sealed class AppSimulationFrameStats
+{
+ public int Frame { get; set; }
+ public int ProcessedCells { get; set; }
+ public int ParticleCount { get; set; }
+ public int MinActiveX { get; set; }
+ public int MinActiveY { get; set; }
+ public int MaxActiveX { get; set; }
+ public int MaxActiveY { get; set; }
+ public int LoadedChunkCount { get; set; }
+ public int ActiveChunkCount { get; set; }
+ public int DirtyChunkCount { get; set; }
+ public int SteppedChunkCount { get; set; }
+ public int SleepingChunkCount { get; set; }
+ public int FieldPageCount { get; set; }
+ public int MoveAttemptCount { get; set; }
+ public int VerticalMoveAttemptCount { get; set; }
+ public int DiagonalMoveAttemptCount { get; set; }
+ public int LateralMoveAttemptCount { get; set; }
+ public int SuccessfulMoveCount { get; set; }
+ public int SwapAttemptCount { get; set; }
+ public int StalledMovableCount { get; set; }
+ public int MovementOnlyFastPathCount { get; set; }
+ public int FullRuntimeStepCount { get; set; }
+ public int FullRuntimeSolidCount { get; set; }
+ public int FullRuntimeLiquidCount { get; set; }
+ public int FullRuntimeGasCount { get; set; }
+ public int MovedParticleCount { get; set; }
+ public int SwappedParticleCount { get; set; }
+ public int VisualDirtyPageCount { get; set; }
+ public long FrameBuildBytesTouched { get; set; }
+ public long ActivationTimeMicroseconds { get; set; }
+ public long MovementTimeMicroseconds { get; set; }
+ public long RuntimeTimeMicroseconds { get; set; }
+ public long FieldDecayTimeMicroseconds { get; set; }
+ public long RenderTimeMicroseconds { get; set; }
+}
diff --git a/Sand.App/ChunkPrototypeSimulationBackend.cs b/Sand.App/ChunkPrototypeSimulationBackend.cs
new file mode 100644
index 0000000..f2314ab
--- /dev/null
+++ b/Sand.App/ChunkPrototypeSimulationBackend.cs
@@ -0,0 +1,562 @@
+using Sand.ChunkPrototype;
+using Sand.Core;
+
+namespace Sand.App;
+
+internal sealed class ChunkPrototypeSimulationBackend : ISimulationBackend
+{
+ private readonly PrototypeSparseSandAdapter _adapter;
+ private readonly SimulationSettings _settings;
+ private readonly ParticleLibrary _library;
+ private readonly Dictionary _particleProfiles;
+ private readonly PrototypeParticle _wallParticle;
+ private readonly AppSimulationFrameStats _frameStats = new();
+ private int _frame;
+ private int _trimCounter;
+
+ public ChunkPrototypeSimulationBackend(int width, int height, int particleSize, IParticleLibrary library, SimulationSettings settings)
+ {
+ _adapter = new PrototypeSparseSandAdapter(width, height, settings.AmbientTemperature);
+ _settings = settings;
+ _library = library as ParticleLibrary ?? throw new ArgumentException("Expected ParticleLibrary implementation.", nameof(library));
+ _particleProfiles = BuildParticleProfiles(_library);
+ foreach (var particle in _particleProfiles.Values)
+ {
+ _adapter.RegisterParticleProfile(particle);
+ }
+
+ _wallParticle = _particleProfiles.GetValueOrDefault("wall");
+ ParticleSize = particleSize;
+ RefreshSettingsState();
+ }
+
+ public string BackendName => "chunk";
+ public SimulationSettings Settings => _settings;
+ public AppSimulationFrameStats FrameStats => _frameStats;
+ public int Frame => _frame;
+ public int ParticleCount => _adapter.ParticleCount;
+ public int ParticleSize { get; }
+
+ public ReadOnlySpan BuildRgbaFrame()
+ {
+ var frame = _adapter.BuildRgbaFrame(_settings.EnableWindVisuals, _settings.EnablePressureVisuals);
+ var stats = _adapter.LastStepStats;
+ _frameStats.VisualDirtyPageCount = stats.VisualDirtyPages;
+ _frameStats.FrameBuildBytesTouched = stats.FrameBuildBytesTouched;
+ _frameStats.RenderTimeMicroseconds = stats.RenderTimeMicroseconds;
+ return frame;
+ }
+
+ public void Step(float dt)
+ {
+ if (_settings.PauseSim)
+ {
+ return;
+ }
+
+ _adapter.World.ClearDirtyChunks();
+ var processed = _adapter.Step();
+ _frame++;
+ _trimCounter++;
+ if (_trimCounter >= 30)
+ {
+ _adapter.TrimResidency(marginChunks: 1);
+ _trimCounter = 0;
+ }
+
+ _frameStats.Frame = _frame;
+ _frameStats.ProcessedCells = Math.Max(processed, _adapter.ParticleCount);
+ _frameStats.ParticleCount = _adapter.ParticleCount;
+ _frameStats.LoadedChunkCount = _adapter.World.LoadedChunkCount;
+ _frameStats.ActiveChunkCount = _adapter.World.ActiveChunkCount;
+ _frameStats.DirtyChunkCount = _adapter.World.DirtyChunkCount;
+ _frameStats.SteppedChunkCount = _adapter.LastStepStats.SteppedChunks;
+ _frameStats.SleepingChunkCount = _adapter.LastStepStats.SleepingChunks;
+ _frameStats.FieldPageCount = _adapter.LastStepStats.FieldPages;
+ _frameStats.MoveAttemptCount = _adapter.LastStepStats.MoveAttempts;
+ _frameStats.VerticalMoveAttemptCount = _adapter.LastStepStats.VerticalMoveAttempts;
+ _frameStats.DiagonalMoveAttemptCount = _adapter.LastStepStats.DiagonalMoveAttempts;
+ _frameStats.LateralMoveAttemptCount = _adapter.LastStepStats.LateralMoveAttempts;
+ _frameStats.SuccessfulMoveCount = _adapter.LastStepStats.SuccessfulMoves;
+ _frameStats.SwapAttemptCount = _adapter.LastStepStats.SwapAttempts;
+ _frameStats.StalledMovableCount = _adapter.LastStepStats.StalledMovableCells;
+ _frameStats.MovementOnlyFastPathCount = _adapter.LastStepStats.MovementOnlyFastPathCount;
+ _frameStats.FullRuntimeStepCount = _adapter.LastStepStats.FullRuntimeStepCount;
+ _frameStats.FullRuntimeSolidCount = _adapter.LastStepStats.FullRuntimeSolidCount;
+ _frameStats.FullRuntimeLiquidCount = _adapter.LastStepStats.FullRuntimeLiquidCount;
+ _frameStats.FullRuntimeGasCount = _adapter.LastStepStats.FullRuntimeGasCount;
+ _frameStats.MovedParticleCount = _adapter.LastStepStats.MovedParticles;
+ _frameStats.SwappedParticleCount = _adapter.LastStepStats.SwappedParticles;
+ _frameStats.VisualDirtyPageCount = _adapter.LastStepStats.VisualDirtyPages;
+ _frameStats.ActivationTimeMicroseconds = _adapter.LastStepStats.ActivationTimeMicroseconds;
+ _frameStats.MovementTimeMicroseconds = _adapter.LastStepStats.MovementTimeMicroseconds;
+ _frameStats.RuntimeTimeMicroseconds = _adapter.LastStepStats.RuntimeTimeMicroseconds;
+ _frameStats.FieldDecayTimeMicroseconds = _adapter.LastStepStats.FieldDecayTimeMicroseconds;
+ _frameStats.RenderTimeMicroseconds = _adapter.LastStepStats.RenderTimeMicroseconds;
+ UpdateBoundsStats();
+ }
+
+ public void Clear()
+ {
+ foreach (var (x, y) in _adapter.Particles.ToArray())
+ {
+ _adapter.RemoveParticle(x, y);
+ }
+
+ _adapter.ClearFields();
+ _frame = 0;
+ _trimCounter = 0;
+ _frameStats.Frame = 0;
+ _frameStats.ProcessedCells = 0;
+ _frameStats.ParticleCount = 0;
+ _frameStats.MinActiveX = 0;
+ _frameStats.MinActiveY = 0;
+ _frameStats.MaxActiveX = 0;
+ _frameStats.MaxActiveY = 0;
+ _frameStats.LoadedChunkCount = 0;
+ _frameStats.ActiveChunkCount = 0;
+ _frameStats.DirtyChunkCount = 0;
+ _frameStats.SteppedChunkCount = 0;
+ _frameStats.SleepingChunkCount = 0;
+ _frameStats.FieldPageCount = 0;
+ _frameStats.MoveAttemptCount = 0;
+ _frameStats.VerticalMoveAttemptCount = 0;
+ _frameStats.DiagonalMoveAttemptCount = 0;
+ _frameStats.LateralMoveAttemptCount = 0;
+ _frameStats.SuccessfulMoveCount = 0;
+ _frameStats.SwapAttemptCount = 0;
+ _frameStats.StalledMovableCount = 0;
+ _frameStats.MovementOnlyFastPathCount = 0;
+ _frameStats.FullRuntimeStepCount = 0;
+ _frameStats.FullRuntimeSolidCount = 0;
+ _frameStats.FullRuntimeLiquidCount = 0;
+ _frameStats.FullRuntimeGasCount = 0;
+ _frameStats.MovedParticleCount = 0;
+ _frameStats.SwappedParticleCount = 0;
+ _frameStats.VisualDirtyPageCount = 0;
+ _frameStats.FrameBuildBytesTouched = 0;
+ _frameStats.ActivationTimeMicroseconds = 0;
+ _frameStats.MovementTimeMicroseconds = 0;
+ _frameStats.RuntimeTimeMicroseconds = 0;
+ _frameStats.FieldDecayTimeMicroseconds = 0;
+ _frameStats.RenderTimeMicroseconds = 0;
+ RefreshSettingsState();
+ }
+
+ public void RefreshSettingsState()
+ {
+ if (_settings.OuterWall)
+ {
+ for (var x = 0; x < _adapter.Width; x++)
+ {
+ _adapter.AddParticle(x, 0, _wallParticle);
+ _adapter.AddParticle(x, _adapter.Height - 1, _wallParticle);
+ }
+
+ for (var y = 0; y < _adapter.Height; y++)
+ {
+ _adapter.AddParticle(0, y, _wallParticle);
+ _adapter.AddParticle(_adapter.Width - 1, y, _wallParticle);
+ }
+ }
+ else
+ {
+ for (var x = 0; x < _adapter.Width; x++)
+ {
+ RemoveBoundaryWallIfPresent(x, 0);
+ RemoveBoundaryWallIfPresent(x, _adapter.Height - 1);
+ }
+
+ for (var y = 0; y < _adapter.Height; y++)
+ {
+ RemoveBoundaryWallIfPresent(0, y);
+ RemoveBoundaryWallIfPresent(_adapter.Width - 1, y);
+ }
+ }
+ }
+
+ public void ClearParticleCircle(int centerX, int centerY, int brushRadius)
+ {
+ PaintCircle(centerX, centerY, brushRadius, particleId: null);
+ }
+
+ public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed)
+ {
+ PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId: null);
+ }
+
+ public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId)
+ {
+ PaintCircle(centerX, centerY, brushRadius, particleId);
+ }
+
+ public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed)
+ {
+ PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId);
+ }
+
+ private void PaintCirclePour(int centerX, int centerY, int brushRadius, int maxParticles, int seed, string? particleId)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ var diameter = (brushRadius * 2) + 1;
+ var touched = 0;
+ for (var i = 0; i < Math.Max(maxParticles * 6, diameter * diameter) && touched < maxParticles; i++)
+ {
+ var dx = ((seed + (i * 17)) % diameter) - brushRadius;
+ var dy = ((seed + (i * 31)) % diameter) - brushRadius;
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ if (particleId is null)
+ {
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y)) && _adapter.RemoveParticle(x, y))
+ {
+ touched++;
+ }
+ }
+ else if (TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId))
+ {
+ touched++;
+ }
+ }
+ }
+
+ public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY)
+ {
+ _adapter.ApplyWindBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY);
+ }
+
+ public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY)
+ {
+ _adapter.ApplyAirBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY);
+ }
+
+ public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength)
+ {
+ _adapter.ApplyGravityBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength);
+ }
+
+ public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength)
+ {
+ _adapter.ApplyRepulsorBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength);
+ }
+
+ private void PaintCircle(int centerX, int centerY, int brushRadius, string? particleId)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ for (var dx = -brushRadius; dx <= brushRadius; dx++)
+ {
+ for (var dy = -brushRadius; dy <= brushRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ if (particleId is null)
+ {
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y)))
+ {
+ _adapter.RemoveParticle(x, y);
+ }
+
+ continue;
+ }
+
+ TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId);
+ }
+ }
+ }
+
+ private bool TryPaintAtCell(int x, int y, string particleId)
+ {
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ return false;
+ }
+
+ if (!_particleProfiles.TryGetValue(particleId, out var particle) || particle.IsEmpty)
+ {
+ return false;
+ }
+
+ if (_settings.OuterWall && particle.MotionType != PrototypeParticleType.Wall && IsBoundary(x, y))
+ {
+ return false;
+ }
+
+ return _adapter.AddParticle(x, y, particle);
+ }
+
+ private void RemoveBoundaryWallIfPresent(int x, int y)
+ {
+ if (_adapter.GetParticleTypeAt(x, y) == PrototypeParticleType.Wall)
+ {
+ _adapter.RemoveParticle(x, y);
+ }
+ }
+
+ private void UpdateBoundsStats()
+ {
+ if (_adapter.ParticleCount == 0)
+ {
+ _frameStats.MinActiveX = 0;
+ _frameStats.MinActiveY = 0;
+ _frameStats.MaxActiveX = 0;
+ _frameStats.MaxActiveY = 0;
+ return;
+ }
+
+ var minX = int.MaxValue;
+ var minY = int.MaxValue;
+ var maxX = int.MinValue;
+ var maxY = int.MinValue;
+ foreach (var ((x, y), _) in _adapter.ParticleEntries)
+ {
+ minX = Math.Min(minX, x);
+ minY = Math.Min(minY, y);
+ maxX = Math.Max(maxX, x);
+ maxY = Math.Max(maxY, y);
+ }
+
+ _frameStats.MinActiveX = minX;
+ _frameStats.MinActiveY = minY;
+ _frameStats.MaxActiveX = maxX;
+ _frameStats.MaxActiveY = maxY;
+ }
+
+ private bool TryNormalizeCoordinate(ref int x, ref int y)
+ {
+ if (_settings.WrapParticles)
+ {
+ x = ((x % _adapter.Width) + _adapter.Width) % _adapter.Width;
+ y = ((y % _adapter.Height) + _adapter.Height) % _adapter.Height;
+ return true;
+ }
+
+ return x >= 0 && x < _adapter.Width && y >= 0 && y < _adapter.Height;
+ }
+
+ private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == _adapter.Width - 1 || y == _adapter.Height - 1;
+
+ private static Dictionary BuildParticleProfiles(ParticleLibrary library)
+ {
+ var idLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++)
+ {
+ var definition = library.GetDefinition(typeId);
+ idLookup[definition.Id] = typeId;
+ }
+
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++)
+ {
+ var definition = library.GetDefinition(typeId);
+ result[definition.Id] = CreateParticleProfile(typeId, definition, idLookup);
+ }
+
+ return result;
+ }
+
+ private static PrototypeParticle CreateParticleProfile(ushort typeId, ParticleDef definition, IReadOnlyDictionary idLookup)
+ {
+ var motionType = ResolveMotionType(definition);
+ if (motionType == PrototypeParticleType.Empty)
+ {
+ return default;
+ }
+
+ var runtime = ParticleRuntimeProfileBuilder.Build(definition);
+ var velocity = definition.Velocity > 0f
+ ? definition.Velocity
+ : definition.Kind switch
+ {
+ ParticleKind.Solid => 0.45f,
+ ParticleKind.Liquid => 0.35f,
+ ParticleKind.Gas => 0.25f,
+ _ => 0.35f,
+ };
+
+ var friction = definition.Friction;
+ var viscosity = definition.Viscosity;
+ switch (motionType)
+ {
+ case PrototypeParticleType.Sand:
+ velocity = MathF.Max(0.5f, velocity);
+ friction = Math.Clamp(friction * 0.75f, 0f, 1f);
+ viscosity = Math.Clamp(viscosity * 0.75f, 0f, 1.25f);
+ break;
+ case PrototypeParticleType.Water:
+ velocity = MathF.Max(0.4f, velocity);
+ friction = Math.Clamp(friction * 0.6f, 0f, 1f);
+ viscosity = Math.Clamp(viscosity * 0.65f, 0f, 1.25f);
+ break;
+ case PrototypeParticleType.Steam:
+ velocity = MathF.Max(0.45f, velocity + (runtime.Balance.UpwardBias * 0.35f));
+ friction = Math.Clamp(friction * 0.4f, 0f, 0.75f);
+ viscosity = Math.Clamp(viscosity * 0.5f, 0f, 0.85f);
+ break;
+ }
+
+ var flags = PrototypeParticleFlags.None;
+ if (definition.Id is "water" or "wet_sand" or "mud")
+ {
+ flags |= PrototypeParticleFlags.WaterLike;
+ }
+
+ if (definition.Id is "fire" or "plasma" or "ember" or "energy")
+ {
+ flags |= PrototypeParticleFlags.FireLike | PrototypeParticleFlags.HotSource;
+ }
+
+ if (definition.Id == "lava" || definition.Id == "burning_wood" || definition.Id.StartsWith("molten_", StringComparison.Ordinal))
+ {
+ flags |= PrototypeParticleFlags.HotSource;
+ }
+
+ if (definition.Id == "acid")
+ {
+ flags |= PrototypeParticleFlags.Acidic;
+ }
+
+ var hydrateTargetTypeId = definition.Id switch
+ {
+ "sand" when idLookup.TryGetValue("wet_sand", out var wetSand) => wetSand,
+ "dirt" when idLookup.TryGetValue("mud", out var mud) => mud,
+ _ => (ushort)0,
+ };
+
+ var pressureResponse = MathF.Max(0.15f, runtime.Balance.PressureSensitivity);
+
+ return new PrototypeParticle(
+ TypeId: typeId,
+ Id: definition.Id,
+ MotionType: motionType,
+ Kind: definition.Kind,
+ BehaviorKind: runtime.Balance.BehaviorKind,
+ R: definition.Color.R,
+ G: definition.Color.G,
+ B: definition.Color.B,
+ Mass: MathF.Max(0.05f, definition.Mass),
+ Velocity: Math.Clamp(velocity, 0.05f, 1.4f),
+ Friction: Math.Clamp(friction, 0f, 1.5f),
+ Viscosity: Math.Clamp(viscosity, 0f, 2f),
+ IsStatic: definition.IsStatic || motionType == PrototypeParticleType.Wall,
+ Flags: flags,
+ IsMolten: definition.Id == "lava" || definition.Id.StartsWith("molten_", StringComparison.Ordinal),
+ HydrateTargetTypeId: hydrateTargetTypeId,
+ MeltTypeId: ResolveOptionalTarget(idLookup, definition.Melt),
+ EvaporateTypeId: ResolveOptionalTarget(idLookup, definition.Evaporate),
+ SolidifyTypeId: ResolveOptionalTarget(idLookup, definition.Solidify),
+ FreezeTypeId: ResolveOptionalTarget(idLookup, definition.Freeze),
+ BrokenTypeId: ResolveOptionalTarget(idLookup, definition.Broken),
+ PressureThreshold: definition.PressureThreshold,
+ PressureResistance: definition.PressureResistance,
+ PressureTolerance: definition.PressureTolerance,
+ PressureThresholdDuration: checked((short)Math.Clamp(definition.PressureThresholdDuration, 0, short.MaxValue)),
+ PressureResponse: pressureResponse,
+ ForceResponseMultiplier: MathF.Max(0.1f, runtime.Balance.ForceResponseMultiplier),
+ LateralFlowMultiplier: MathF.Max(0.05f, runtime.Balance.LateralFlowMultiplier),
+ DiagonalFlowMultiplier: MathF.Max(0.05f, runtime.Balance.DiagonalFlowMultiplier),
+ UpwardBias: runtime.Balance.UpwardBias,
+ SideDriftBias: runtime.Balance.SideDriftBias,
+ InitialTemperature: definition.Temperature,
+ MeltTemperature: definition.MeltTemperature ?? float.PositiveInfinity,
+ EvaporateTemperature: definition.EvaporateTemperature ?? float.PositiveInfinity,
+ SolidifyTemperature: definition.SolidifyTemperature ?? float.NegativeInfinity,
+ FreezeTemperature: definition.FreezeTemperature ?? float.NegativeInfinity,
+ BurnDuration: definition.BurnDuration,
+ BurnTemperature: definition.BurnTemperature,
+ BurnRate: MathF.Max(0.05f, definition.BurnRate),
+ BurningInit: definition.Burning,
+ DefaultLifetime: ResolveDefaultLifetime(definition, runtime.Balance),
+ HeatEmission: definition.HeatEmission * MathF.Max(0.01f, runtime.Balance.HeatEmissionMultiplier),
+ SmokeSpawnChance: runtime.Balance.SmokeSpawnChance,
+ EmberSpawnChance: runtime.Balance.EmberSpawnChance,
+ Hardness: definition.Hardness,
+ Durability: definition.Durability,
+ Flamability: definition.Flamability,
+ Conductivity: definition.Conductivity,
+ Conductive: definition.Conductive,
+ AmbientCoolingMultiplier: runtime.Balance.AmbientCoolingMultiplier,
+ NeighborHeatTransferMultiplier: runtime.Balance.NeighborHeatTransferMultiplier,
+ PhaseTransitionHysteresis: runtime.Balance.PhaseTransitionHysteresis,
+ ProduceTypeId: ResolveOptionalTarget(idLookup, definition.Produces),
+ ProducesOnDeathTypeId: ResolveOptionalTarget(idLookup, definition.ProducesOnDeath));
+ }
+
+ private static ushort ResolveOptionalTarget(IReadOnlyDictionary idLookup, string? particleId)
+ {
+ if (string.IsNullOrWhiteSpace(particleId))
+ {
+ return 0;
+ }
+
+ return idLookup.TryGetValue(particleId, out var typeId) ? typeId : (ushort)0;
+ }
+
+ private static float ResolveDefaultLifetime(ParticleDef definition, ParticleBalanceProfile balance)
+ {
+ var lifetime = (definition.Lifetime ?? 0f) * MathF.Max(0.01f, balance.LifetimeMultiplier);
+ if (lifetime <= 0f && balance.MaxLifetimeTicks > 0f)
+ {
+ lifetime = (balance.MinLifetimeTicks + balance.MaxLifetimeTicks) * 0.5f;
+ }
+
+ if (balance.MinLifetimeTicks > 0f)
+ {
+ lifetime = MathF.Max(lifetime, balance.MinLifetimeTicks);
+ }
+
+ if (balance.MaxLifetimeTicks > 0f)
+ {
+ lifetime = MathF.Min(lifetime, balance.MaxLifetimeTicks);
+ }
+
+ return lifetime;
+ }
+
+ private static PrototypeParticleType ResolveMotionType(ParticleDef definition)
+ {
+ if (definition.Id is "air" or "wind" or "gravity_well" or "repulsor")
+ {
+ return PrototypeParticleType.Empty;
+ }
+
+ if (definition.Id == "wall" || definition.IsStatic)
+ {
+ return PrototypeParticleType.Wall;
+ }
+
+ if (definition.Id is "fire" or "plasma" or "smoke" or "steam" or "spark" or "energy")
+ {
+ return PrototypeParticleType.Steam;
+ }
+
+ if (definition.Id is "burning_wood")
+ {
+ return PrototypeParticleType.Wall;
+ }
+
+ if (definition.Id is "ember" or "snow")
+ {
+ return PrototypeParticleType.Sand;
+ }
+
+ return definition.Kind switch
+ {
+ ParticleKind.Gas => PrototypeParticleType.Steam,
+ ParticleKind.Liquid => PrototypeParticleType.Water,
+ ParticleKind.Solid => PrototypeParticleType.Sand,
+ _ => PrototypeParticleType.Empty,
+ };
+ }
+}
diff --git a/Sand.App/CoreSimulationBackend.cs b/Sand.App/CoreSimulationBackend.cs
new file mode 100644
index 0000000..2e4a61d
--- /dev/null
+++ b/Sand.App/CoreSimulationBackend.cs
@@ -0,0 +1,73 @@
+using Sand.Core;
+
+namespace Sand.App;
+
+internal sealed class CoreSimulationBackend : ISimulationBackend
+{
+ private readonly SandSimulation _simulation;
+ private readonly AppSimulationFrameStats _frameStats = new();
+
+ public CoreSimulationBackend(SandSimulation simulation)
+ {
+ _simulation = simulation;
+ }
+
+ public string BackendName => "dense";
+ public SimulationSettings Settings => _simulation.Settings;
+ public AppSimulationFrameStats FrameStats
+ {
+ get
+ {
+ var source = _simulation.FrameStats;
+ _frameStats.Frame = source.Frame;
+ _frameStats.ProcessedCells = source.ProcessedCells;
+ _frameStats.ParticleCount = source.ParticleCount;
+ _frameStats.MinActiveX = source.MinActiveX;
+ _frameStats.MinActiveY = source.MinActiveY;
+ _frameStats.MaxActiveX = source.MaxActiveX;
+ _frameStats.MaxActiveY = source.MaxActiveY;
+ _frameStats.LoadedChunkCount = source.LoadedChunkCount;
+ _frameStats.ActiveChunkCount = source.ActiveChunkCount;
+ _frameStats.DirtyChunkCount = source.DirtyChunkCount;
+ _frameStats.SteppedChunkCount = source.SteppedChunkCount;
+ _frameStats.SleepingChunkCount = source.SleepingChunkCount;
+ _frameStats.FieldPageCount = source.FieldPageCount;
+ _frameStats.MoveAttemptCount = source.MoveAttemptCount;
+ _frameStats.VerticalMoveAttemptCount = source.VerticalMoveAttemptCount;
+ _frameStats.DiagonalMoveAttemptCount = source.DiagonalMoveAttemptCount;
+ _frameStats.LateralMoveAttemptCount = source.LateralMoveAttemptCount;
+ _frameStats.SuccessfulMoveCount = source.SuccessfulMoveCount;
+ _frameStats.SwapAttemptCount = source.SwapAttemptCount;
+ _frameStats.StalledMovableCount = source.StalledMovableCount;
+ _frameStats.MovementOnlyFastPathCount = source.MovementOnlyFastPathCount;
+ _frameStats.FullRuntimeStepCount = source.FullRuntimeStepCount;
+ _frameStats.FullRuntimeSolidCount = source.FullRuntimeSolidCount;
+ _frameStats.FullRuntimeLiquidCount = source.FullRuntimeLiquidCount;
+ _frameStats.FullRuntimeGasCount = source.FullRuntimeGasCount;
+ _frameStats.MovedParticleCount = source.MovedParticleCount;
+ _frameStats.SwappedParticleCount = source.SwappedParticleCount;
+ _frameStats.VisualDirtyPageCount = source.VisualDirtyPageCount;
+ _frameStats.FrameBuildBytesTouched = source.FrameBuildBytesTouched;
+ _frameStats.ActivationTimeMicroseconds = source.ActivationTimeMicroseconds;
+ _frameStats.MovementTimeMicroseconds = source.MovementTimeMicroseconds;
+ _frameStats.RuntimeTimeMicroseconds = source.RuntimeTimeMicroseconds;
+ _frameStats.FieldDecayTimeMicroseconds = source.FieldDecayTimeMicroseconds;
+ _frameStats.RenderTimeMicroseconds = source.RenderTimeMicroseconds;
+ return _frameStats;
+ }
+ }
+ public int Frame => _simulation.Frame;
+ public int ParticleCount => _simulation.ParticleCount;
+ public ReadOnlySpan BuildRgbaFrame() => _simulation.BuildRgbaFrame();
+ public void Step(float dt) => _simulation.Step(dt);
+ public void Clear() => _simulation.Clear();
+ public void RefreshSettingsState() => _simulation.RefreshSettingsState();
+ public void ClearParticleCircle(int centerX, int centerY, int brushRadius) => _simulation.ClearParticleCircle(centerX, centerY, brushRadius);
+ public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed) => _simulation.ClearParticlePourAtPixel(centerX, centerY, brushRadius, maxParticles, seed);
+ public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId) => _simulation.CreateParticleCircle(centerX, centerY, brushRadius, particleId);
+ public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) => _simulation.CreateParticlePourAtPixel(centerX, centerY, brushRadius, particleId, maxParticles, seed);
+ public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) => _simulation.ApplyWindBrushAtPixel(centerX, centerY, brushRadius, forceX, forceY);
+ public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) => _simulation.ApplyAirBrushAtPixel(centerX, centerY, brushRadius, forceX, forceY);
+ public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) => _simulation.ApplyGravityBrushAtPixel(centerX, centerY, brushRadius, strength);
+ public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) => _simulation.ApplyRepulsorBrushAtPixel(centerX, centerY, brushRadius, strength);
+}
diff --git a/Sand.App/ISimulationBackend.cs b/Sand.App/ISimulationBackend.cs
new file mode 100644
index 0000000..887cbf4
--- /dev/null
+++ b/Sand.App/ISimulationBackend.cs
@@ -0,0 +1,24 @@
+using Sand.Core;
+
+namespace Sand.App;
+
+internal interface ISimulationBackend
+{
+ string BackendName { get; }
+ SimulationSettings Settings { get; }
+ AppSimulationFrameStats FrameStats { get; }
+ int Frame { get; }
+ int ParticleCount { get; }
+ ReadOnlySpan BuildRgbaFrame();
+ void Step(float dt);
+ void Clear();
+ void RefreshSettingsState();
+ void ClearParticleCircle(int centerX, int centerY, int brushRadius);
+ void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed);
+ void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId);
+ void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed);
+ void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY);
+ void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY);
+ void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength);
+ void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength);
+}
diff --git a/Sand.App/Program.cs b/Sand.App/Program.cs
new file mode 100644
index 0000000..7fd8659
--- /dev/null
+++ b/Sand.App/Program.cs
@@ -0,0 +1,9 @@
+namespace Sand.App;
+
+internal static class Program
+{
+ public static void Main(string[] args)
+ {
+ SandApp.Run();
+ }
+}
diff --git a/Sand.App/Sand.App.csproj b/Sand.App/Sand.App.csproj
new file mode 100644
index 0000000..748e3d4
--- /dev/null
+++ b/Sand.App/Sand.App.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
diff --git a/Sand.App/SandApp.Models.cs b/Sand.App/SandApp.Models.cs
new file mode 100644
index 0000000..10a02f5
--- /dev/null
+++ b/Sand.App/SandApp.Models.cs
@@ -0,0 +1,48 @@
+using Raylib_cs;
+using Sand.Core;
+using System.Numerics;
+
+namespace Sand.App;
+
+internal sealed class AppState
+{
+ public required IParticleLibrary Library { get; init; }
+ public required SimulationSettings Settings { get; init; }
+ public required ISimulationBackend Simulation { get; init; }
+ public required Dictionary> Categories { get; init; }
+ public required SettingItem[] SettingItems { get; init; }
+ public required byte[] UploadBuffer { get; init; }
+ public required string CurrentCategory { get; set; }
+ public required string CurrentParticle { get; set; }
+ public required string BackendName { get; init; }
+ public required int SimWidth { get; init; }
+ public required int SimHeight { get; init; }
+ public Texture2D FrameTexture { get; set; }
+ public int BrushRadius { get; set; }
+ public bool ZoomEnabled { get; set; }
+ public bool SettingsVisible { get; set; }
+ public int CategoryScrollOffset { get; set; }
+ public int ParticleScrollOffset { get; set; }
+ public int SettingsScrollOffset { get; set; }
+ public float SimulationAccumulator { get; set; }
+ public Vector2 PreviousMouse { get; set; }
+ public Vector2 LastWindDirection { get; set; }
+ public PendingWorldAction PendingWorldAction { get; set; }
+ public int LastSimulationStepCount { get; set; }
+ public long LastAppFrameTimeMicroseconds { get; set; }
+ public long LastUpdateTimeMicroseconds { get; set; }
+ public long LastSimulationLoopTimeMicroseconds { get; set; }
+ public long LastFrameBuildCallTimeMicroseconds { get; set; }
+ public long LastTextureUploadTimeMicroseconds { get; set; }
+ public long LastDrawTimeMicroseconds { get; set; }
+ public long LastAppOtherTimeMicroseconds { get; set; }
+}
+
+internal readonly record struct SettingItem(string Label, Func Get, Action Toggle);
+
+internal readonly record struct PendingWorldAction(
+ bool Active,
+ bool IsErase,
+ int SimX,
+ int SimY,
+ Vector2 Mouse);
diff --git a/Sand.App/SandApp.Rendering.cs b/Sand.App/SandApp.Rendering.cs
new file mode 100644
index 0000000..bbd2aab
--- /dev/null
+++ b/Sand.App/SandApp.Rendering.cs
@@ -0,0 +1,252 @@
+using Raylib_cs;
+using Sand.Core;
+using System.Numerics;
+using System.Diagnostics;
+
+namespace Sand.App;
+
+internal static partial class SandApp
+{
+ private static void Draw(AppState state)
+ {
+ var buildStart = Stopwatch.GetTimestamp();
+ var rgbaFrame = state.Simulation.BuildRgbaFrame();
+ state.LastFrameBuildCallTimeMicroseconds = ToMicroseconds(buildStart, Stopwatch.GetTimestamp());
+
+ var uploadStart = Stopwatch.GetTimestamp();
+ unsafe
+ {
+ fixed (byte* pixels = rgbaFrame)
+ {
+ Raylib.UpdateTexture(state.FrameTexture, pixels);
+ }
+ }
+ state.LastTextureUploadTimeMicroseconds = ToMicroseconds(uploadStart, Stopwatch.GetTimestamp());
+
+ var mouse = Raylib.GetMousePosition();
+ var worldRect = new Rectangle(SidebarWidth, 0, state.SimWidth * ParticleSize, state.SimHeight * ParticleSize);
+ var settingsRect = GetSettingsRect(state);
+
+ var drawStart = Stopwatch.GetTimestamp();
+ Raylib.BeginDrawing();
+ Raylib.ClearBackground(new Color(18, 18, 24, 255));
+ DrawSidebar(state);
+ Raylib.DrawTextureEx(state.FrameTexture, new Vector2(SidebarWidth, 0), 0f, ParticleSize, Color.White);
+
+ if (state.Settings.EnableCursor)
+ {
+ DrawBrushCursor(mouse, state.BrushRadius);
+ }
+
+ if (state.ZoomEnabled && Raylib.CheckCollisionPointRec(mouse, worldRect))
+ {
+ DrawZoom(state.FrameTexture, mouse, state.SimWidth, state.SimHeight);
+ }
+
+ if (state.SettingsVisible)
+ {
+ DrawSettingsPanel(state, settingsRect, state.SettingItems);
+ }
+
+ DrawStatusBar(state);
+
+ if (state.Settings.EnableDebug || state.Settings.EnableFps)
+ {
+ DrawDebug(state, Raylib.GetFrameTime());
+ }
+
+ Raylib.EndDrawing();
+ state.LastDrawTimeMicroseconds = ToMicroseconds(drawStart, Stopwatch.GetTimestamp());
+ }
+
+ private static void DrawSidebar(AppState state)
+ {
+ Raylib.DrawRectangle(0, 0, SidebarWidth, Raylib.GetScreenHeight(), new Color(28, 32, 40, 255));
+ Raylib.DrawText($"Sand C# ({state.BackendName})", 12, 16, 24, new Color(100, 170, 255, 255));
+
+ var y = 60;
+ var categoryTop = 60;
+ var categoryHeight = 180;
+ var visibleCategoryRows = GetVisibleCategoryRows();
+ Raylib.BeginScissorMode(12, categoryTop, 196, categoryHeight);
+ foreach (var category in state.Categories.Keys.Skip(state.CategoryScrollOffset).Take(visibleCategoryRows))
+ {
+ var selected = category == state.CurrentCategory;
+ Raylib.DrawRectangleRounded(new Rectangle(12, y, 196, 32), 0.2f, 6, selected ? new Color(66, 128, 182, 255) : new Color(48, 54, 66, 255));
+ Raylib.DrawText(category, 22, y + 8, 18, Color.White);
+ y += 38;
+ }
+ Raylib.EndScissorMode();
+
+ var categoryCount = state.Categories.Count;
+ if (categoryCount > visibleCategoryRows)
+ {
+ var trackRect = new Rectangle(202, categoryTop, 6, categoryHeight);
+ var thumbHeight = MathF.Max(24f, categoryHeight * (visibleCategoryRows / (float)categoryCount));
+ var maxOffset = categoryCount - visibleCategoryRows;
+ var thumbTravel = MathF.Max(0f, categoryHeight - thumbHeight);
+ var thumbY = categoryTop + ((state.CategoryScrollOffset / (float)maxOffset) * thumbTravel);
+ Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255));
+ Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255));
+ }
+
+ y = 240;
+ var listTop = 240;
+ var listHeight = Math.Max(28, Raylib.GetScreenHeight() - 404);
+ var visibleRows = GetVisibleParticleRows();
+ Raylib.BeginScissorMode(12, listTop, 196, listHeight);
+ foreach (var particle in state.Categories[state.CurrentCategory].Skip(state.ParticleScrollOffset).Take(visibleRows))
+ {
+ var selected = particle.Id == state.CurrentParticle;
+ var bg = selected ? new Color(230, 230, 240, 255) : new Color(particle.Color.R, particle.Color.G, particle.Color.B, (byte)255);
+ Raylib.DrawRectangle(12, y, 196, 24, bg);
+ Raylib.DrawText(particle.Name, 18, y + 4, 16, Color.Black);
+ y += 28;
+ }
+ Raylib.EndScissorMode();
+
+ var particleCount = state.Categories[state.CurrentCategory].Count;
+ if (particleCount > visibleRows)
+ {
+ var trackRect = new Rectangle(202, listTop, 6, listHeight);
+ var thumbHeight = MathF.Max(24f, listHeight * (visibleRows / (float)particleCount));
+ var maxOffset = particleCount - visibleRows;
+ var thumbTravel = MathF.Max(0f, listHeight - thumbHeight);
+ var thumbY = listTop + ((state.ParticleScrollOffset / (float)maxOffset) * thumbTravel);
+ Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255));
+ Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255));
+ }
+
+ Raylib.DrawText($"Brush {state.BrushRadius}", 12, 204, 18, Color.White);
+ Raylib.DrawText(state.Settings.PauseSim ? "Paused" : "Running", 120, 204, 18, state.Settings.PauseSim ? Color.Yellow : Color.Green);
+ Raylib.DrawText($"Particles {state.Simulation.ParticleCount}", 12, Raylib.GetScreenHeight() - 126, 18, Color.LightGray);
+
+ Raylib.DrawRectangleRounded(new Rectangle(12, Raylib.GetScreenHeight() - 96, 196, 32), 0.2f, 6, new Color(160, 58, 58, 255));
+ Raylib.DrawText("Clear Grid", 62, Raylib.GetScreenHeight() - 88, 18, Color.White);
+ Raylib.DrawRectangleRounded(new Rectangle(12, Raylib.GetScreenHeight() - 54, 196, 32), 0.2f, 6, state.SettingsVisible ? new Color(98, 112, 136, 255) : new Color(72, 80, 90, 255));
+ Raylib.DrawText("Settings", 68, Raylib.GetScreenHeight() - 46, 18, Color.White);
+ }
+
+ private static void DrawSettingsPanel(AppState state, Rectangle panelRect, IReadOnlyList items)
+ {
+ Raylib.DrawRectangleRounded(panelRect, 0.08f, 8, new Color(18, 22, 28, 240));
+ Raylib.DrawRectangleLinesEx(panelRect, 1.5f, new Color(110, 140, 170, 255));
+ Raylib.DrawText("Engine Settings", (int)panelRect.X + 14, (int)panelRect.Y + 10, 22, Color.White);
+
+ var y = panelRect.Y + 18;
+ var listTop = panelRect.Y + 18;
+ var listHeight = Math.Max(28f, panelRect.Height - 68f);
+ var visibleRows = GetVisibleSettingsRows(panelRect);
+ Raylib.BeginScissorMode((int)panelRect.X + 12, (int)listTop, (int)panelRect.Width - 24, (int)listHeight);
+ foreach (var item in items.Skip(state.SettingsScrollOffset).Take(visibleRows))
+ {
+ var rowRect = new Rectangle(panelRect.X + 12, y, panelRect.Width - 24, 26);
+ Raylib.DrawRectangleRounded(rowRect, 0.15f, 6, item.Get() ? new Color(63, 117, 87, 255) : new Color(63, 69, 78, 255));
+ Raylib.DrawText(item.Label, (int)rowRect.X + 10, (int)rowRect.Y + 5, 18, Color.White);
+ Raylib.DrawText(item.Get() ? "On" : "Off", (int)rowRect.X + (int)rowRect.Width - 38, (int)rowRect.Y + 5, 18, Color.White);
+ y += 34;
+ }
+ Raylib.EndScissorMode();
+
+ if (items.Count > visibleRows)
+ {
+ var trackRect = new Rectangle(panelRect.X + panelRect.Width - 10, listTop, 4, listHeight);
+ var thumbHeight = MathF.Max(24f, listHeight * (visibleRows / (float)items.Count));
+ var maxOffset = items.Count - visibleRows;
+ var thumbTravel = MathF.Max(0f, listHeight - thumbHeight);
+ var thumbY = listTop + ((state.SettingsScrollOffset / (float)maxOffset) * thumbTravel);
+ Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255));
+ Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255));
+ }
+
+ var speedDownRect = new Rectangle(panelRect.X + 12, panelRect.Y + panelRect.Height - 36, 48, 24);
+ var speedUpRect = new Rectangle(panelRect.X + 72, panelRect.Y + panelRect.Height - 36, 48, 24);
+ Raylib.DrawRectangleRounded(speedDownRect, 0.15f, 6, new Color(63, 69, 78, 255));
+ Raylib.DrawRectangleRounded(speedUpRect, 0.15f, 6, new Color(63, 69, 78, 255));
+ Raylib.DrawText("-", (int)speedDownRect.X + 18, (int)speedDownRect.Y + 4, 20, Color.White);
+ Raylib.DrawText("+", (int)speedUpRect.X + 17, (int)speedUpRect.Y + 3, 20, Color.White);
+ }
+
+ private static void DrawStatusBar(AppState state)
+ {
+ var y = Raylib.GetScreenHeight() - 28;
+ Raylib.DrawRectangle(SidebarWidth, y, Raylib.GetScreenWidth() - SidebarWidth, 28, new Color(12, 14, 18, 220));
+ var stats = state.Simulation.FrameStats;
+ var status = $"Current {state.CurrentParticle} | Brush {state.BrushRadius} | Speed {state.Settings.TimeScale:0.0}x @ {state.Settings.SimulationStepsPerSecond:0}Hz | Cells {stats.ProcessedCells} | Bounds {stats.MinActiveX},{stats.MinActiveY} - {stats.MaxActiveX},{stats.MaxActiveY}";
+ if (stats.LoadedChunkCount > 0 || stats.ActiveChunkCount > 0)
+ {
+ status += $" | Chunks {stats.ActiveChunkCount}/{stats.LoadedChunkCount}";
+ status += $" step {stats.SteppedChunkCount} sleep {stats.SleepingChunkCount}";
+ }
+ if (state.Settings.WrapParticles)
+ {
+ status += " | Wrap";
+ }
+ if (state.Settings.OuterWall)
+ {
+ status += " | OuterWall";
+ }
+
+ Raylib.DrawText(status, SidebarWidth + 10, y + 6, 16, Color.LightGray);
+ }
+
+ private static void DrawBrushCursor(Vector2 mouse, int brushRadius)
+ {
+ if (mouse.X < SidebarWidth)
+ {
+ return;
+ }
+
+ Raylib.DrawCircleLines((int)mouse.X, (int)mouse.Y, brushRadius * ParticleSize, new Color(255, 255, 255, 180));
+ }
+
+ private static void DrawZoom(Texture2D frameTexture, Vector2 mouse, int simWidth, int simHeight)
+ {
+ var simMouseX = Math.Clamp((int)((mouse.X - SidebarWidth) / ParticleSize), 0, simWidth - 1);
+ var simMouseY = Math.Clamp((int)(mouse.Y / ParticleSize), 0, simHeight - 1);
+ var srcX = Math.Clamp(simMouseX - 8, 0, simWidth - 16);
+ var srcY = Math.Clamp(simMouseY - 8, 0, simHeight - 16);
+ var source = new Rectangle(srcX, srcY, 16, 16);
+ var target = new Rectangle(Raylib.GetScreenWidth() - 220, 20, 180, 180);
+ Raylib.DrawRectangleRounded(target, 0.1f, 6, new Color(22, 22, 30, 230));
+ Raylib.DrawTexturePro(frameTexture, source, target, Vector2.Zero, 0f, Color.White);
+ Raylib.DrawRectangleLinesEx(target, 2f, new Color(255, 255, 255, 160));
+ }
+
+ private static void DrawDebug(AppState state, float dt)
+ {
+ var rect = new Rectangle(236, 12, 430, 256);
+ Raylib.DrawRectangleRounded(rect, 0.15f, 6, new Color(16, 16, 16, 190));
+ var y = 24;
+ if (state.Settings.EnableFps)
+ {
+ Raylib.DrawText($"FPS {Raylib.GetFPS()}", 248, y, 18, Color.White);
+ y += 20;
+ }
+
+ if (state.Settings.EnableDebug)
+ {
+ var stats = state.Simulation.FrameStats;
+ Raylib.DrawText($"Frame {state.Simulation.Frame}", 248, y, 18, Color.White);
+ Raylib.DrawText($"Particle {state.CurrentParticle}", 248, y + 20, 18, Color.White);
+ Raylib.DrawText($"Brush {state.BrushRadius} dt {dt:0.000}", 248, y + 40, 18, Color.White);
+ Raylib.DrawText($"Processed {stats.ProcessedCells} particles {stats.ParticleCount}", 248, y + 60, 18, Color.White);
+ Raylib.DrawText($"Bounds {stats.MinActiveX},{stats.MinActiveY} - {stats.MaxActiveX},{stats.MaxActiveY}", 248, y + 80, 18, Color.White);
+ if (stats.LoadedChunkCount > 0 || stats.ActiveChunkCount > 0)
+ {
+ Raylib.DrawText($"Chunks loaded {stats.LoadedChunkCount} active {stats.ActiveChunkCount} dirty {stats.DirtyChunkCount}", 248, y + 100, 18, Color.White);
+ Raylib.DrawText($"Moves {stats.MovedParticleCount} swaps {stats.SwappedParticleCount} attempts {stats.MoveAttemptCount} stalled {stats.StalledMovableCount}", 248, y + 120, 18, Color.White);
+ Raylib.DrawText($"Att v {stats.VerticalMoveAttemptCount} d {stats.DiagonalMoveAttemptCount} l {stats.LateralMoveAttemptCount} swap {stats.SwapAttemptCount}", 248, y + 140, 18, Color.White);
+ Raylib.DrawText($"Fast {stats.MovementOnlyFastPathCount} full {stats.FullRuntimeStepCount} s {stats.FullRuntimeSolidCount} l {stats.FullRuntimeLiquidCount} g {stats.FullRuntimeGasCount}", 248, y + 160, 18, Color.White);
+ Raylib.DrawText($"ms act {stats.ActivationTimeMicroseconds / 1000f:0.00} move {stats.MovementTimeMicroseconds / 1000f:0.00} run {stats.RuntimeTimeMicroseconds / 1000f:0.00} render {stats.RenderTimeMicroseconds / 1000f:0.00}", 248, y + 180, 18, Color.White);
+ Raylib.DrawText($"app frame {state.LastAppFrameTimeMicroseconds / 1000f:0.00} upd {state.LastUpdateTimeMicroseconds / 1000f:0.00} sim {state.LastSimulationLoopTimeMicroseconds / 1000f:0.00} steps {state.LastSimulationStepCount}", 248, y + 200, 18, Color.White);
+ Raylib.DrawText($"app build {state.LastFrameBuildCallTimeMicroseconds / 1000f:0.00} upload {state.LastTextureUploadTimeMicroseconds / 1000f:0.00} draw {state.LastDrawTimeMicroseconds / 1000f:0.00} other {state.LastAppOtherTimeMicroseconds / 1000f:0.00}", 248, y + 220, 18, Color.White);
+ }
+ else
+ {
+ Raylib.DrawText($"app frame {state.LastAppFrameTimeMicroseconds / 1000f:0.00} upd {state.LastUpdateTimeMicroseconds / 1000f:0.00} sim {state.LastSimulationLoopTimeMicroseconds / 1000f:0.00} steps {state.LastSimulationStepCount}", 248, y + 100, 18, Color.White);
+ Raylib.DrawText($"app build {state.LastFrameBuildCallTimeMicroseconds / 1000f:0.00} upload {state.LastTextureUploadTimeMicroseconds / 1000f:0.00} draw {state.LastDrawTimeMicroseconds / 1000f:0.00} other {state.LastAppOtherTimeMicroseconds / 1000f:0.00}", 248, y + 120, 18, Color.White);
+ }
+ }
+ }
+}
diff --git a/Sand.App/SandApp.Sidebar.cs b/Sand.App/SandApp.Sidebar.cs
new file mode 100644
index 0000000..94aaf01
--- /dev/null
+++ b/Sand.App/SandApp.Sidebar.cs
@@ -0,0 +1,108 @@
+using Raylib_cs;
+using System.Numerics;
+
+namespace Sand.App;
+
+internal static partial class SandApp
+{
+ private static void HandleSidebar(AppState state, Vector2 mouse)
+ {
+ if (mouse.X >= SidebarWidth)
+ {
+ return;
+ }
+
+ var y = 60;
+ foreach (var category in state.Categories.Keys.Skip(state.CategoryScrollOffset).Take(GetVisibleCategoryRows()))
+ {
+ var rect = new Rectangle(12, y, 196, 32);
+ if (Raylib.CheckCollisionPointRec(mouse, rect))
+ {
+ state.CurrentCategory = category;
+ state.ParticleScrollOffset = 0;
+ if (state.Categories[state.CurrentCategory].Count > 0)
+ {
+ state.CurrentParticle = state.Categories[state.CurrentCategory][0].Id;
+ }
+ return;
+ }
+
+ y += 38;
+ }
+
+ y = 240;
+ var visibleRows = GetVisibleParticleRows();
+ foreach (var particle in state.Categories[state.CurrentCategory].Skip(state.ParticleScrollOffset).Take(visibleRows))
+ {
+ var rect = new Rectangle(12, y, 196, 24);
+ if (Raylib.CheckCollisionPointRec(mouse, rect))
+ {
+ state.CurrentParticle = particle.Id;
+ return;
+ }
+
+ y += 28;
+ }
+
+ var clearRect = new Rectangle(12, Raylib.GetScreenHeight() - 96, 196, 32);
+ if (Raylib.CheckCollisionPointRec(mouse, clearRect))
+ {
+ state.Simulation.Clear();
+ return;
+ }
+
+ var settingsRect = new Rectangle(12, Raylib.GetScreenHeight() - 54, 196, 32);
+ if (Raylib.CheckCollisionPointRec(mouse, settingsRect))
+ {
+ state.SettingsVisible = !state.SettingsVisible;
+ state.Settings.EnableDebug = state.SettingsVisible || state.Settings.EnableDebug;
+ }
+ }
+
+ private static void HandleSettingsPanel(AppState state, Rectangle panelRect, Vector2 mouse)
+ {
+ if (!Raylib.CheckCollisionPointRec(mouse, panelRect))
+ {
+ return;
+ }
+
+ var y = panelRect.Y + 18;
+ foreach (var item in state.SettingItems.Skip(state.SettingsScrollOffset).Take(GetVisibleSettingsRows(panelRect)))
+ {
+ var rowRect = new Rectangle(panelRect.X + 12, y, panelRect.Width - 24, 26);
+ if (Raylib.CheckCollisionPointRec(mouse, rowRect))
+ {
+ item.Toggle();
+ state.Simulation.RefreshSettingsState();
+ return;
+ }
+
+ y += 34;
+ }
+
+ var speedDownRect = new Rectangle(panelRect.X + 12, panelRect.Y + panelRect.Height - 36, 48, 24);
+ var speedUpRect = new Rectangle(panelRect.X + 72, panelRect.Y + panelRect.Height - 36, 48, 24);
+ if (Raylib.CheckCollisionPointRec(mouse, speedDownRect))
+ {
+ state.Simulation.Settings.TimeScale = MathF.Max(0.1f, state.Simulation.Settings.TimeScale - 0.1f);
+ state.SimulationAccumulator = 0f;
+ return;
+ }
+
+ if (Raylib.CheckCollisionPointRec(mouse, speedUpRect))
+ {
+ state.Simulation.Settings.TimeScale = MathF.Min(4f, state.Simulation.Settings.TimeScale + 0.1f);
+ state.SimulationAccumulator = 0f;
+ }
+ }
+
+ private static int GetVisibleParticleRows()
+ {
+ return Math.Max(1, (Raylib.GetScreenHeight() - 404) / 28);
+ }
+
+ private static int GetVisibleCategoryRows()
+ {
+ return Math.Max(1, (240 - 60) / 38);
+ }
+}
diff --git a/Sand.App/SandApp.Update.cs b/Sand.App/SandApp.Update.cs
new file mode 100644
index 0000000..a1c7dd1
--- /dev/null
+++ b/Sand.App/SandApp.Update.cs
@@ -0,0 +1,332 @@
+using Raylib_cs;
+using Sand.Core;
+using System.Numerics;
+using System.Diagnostics;
+
+namespace Sand.App;
+
+internal static partial class SandApp
+{
+ private static void Update(AppState state)
+ {
+ var updateStart = Stopwatch.GetTimestamp();
+ var dt = Raylib.GetFrameTime();
+ var mouse = Raylib.GetMousePosition();
+ var worldRect = new Rectangle(SidebarWidth, 0, state.SimWidth * ParticleSize, state.SimHeight * ParticleSize);
+ var overSidebar = mouse.X < SidebarWidth;
+ var settingsRect = GetSettingsRect(state);
+
+ HandleHotkeys(state);
+ HandleMouseWheel(state, mouse, overSidebar);
+ HandleWorldPainting(state, mouse, worldRect, overSidebar, settingsRect);
+ HandleSidebarClick(state, mouse);
+ StepSimulation(state, dt, mouse);
+
+ state.PreviousMouse = mouse;
+ state.LastUpdateTimeMicroseconds = ToMicroseconds(updateStart, Stopwatch.GetTimestamp());
+ }
+
+ private static void HandleHotkeys(AppState state)
+ {
+ if (Raylib.IsKeyPressed(KeyboardKey.Space))
+ {
+ state.Settings.PauseSim = !state.Settings.PauseSim;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.C))
+ {
+ state.Simulation.Clear();
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.Z))
+ {
+ state.ZoomEnabled = !state.ZoomEnabled;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.F3))
+ {
+ state.Settings.EnableDebug = !state.Settings.EnableDebug;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.Tab))
+ {
+ state.SettingsVisible = !state.SettingsVisible;
+ }
+ }
+
+ private static void HandleMouseWheel(AppState state, Vector2 mouse, bool overSidebar)
+ {
+ var wheel = Raylib.GetMouseWheelMove();
+ if (MathF.Abs(wheel) <= 0f)
+ {
+ return;
+ }
+
+ var settingsRect = GetSettingsRect(state);
+ if (state.SettingsVisible && Raylib.CheckCollisionPointRec(mouse, settingsRect))
+ {
+ var maxOffset = Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(settingsRect));
+ state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset - (int)wheel, 0, maxOffset);
+ return;
+ }
+
+ var overCategoryList = overSidebar && mouse.Y >= 60 && mouse.Y < 240;
+ if (overCategoryList)
+ {
+ var maxOffset = Math.Max(0, state.Categories.Count - GetVisibleCategoryRows());
+ state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset - (int)wheel, 0, maxOffset);
+ return;
+ }
+
+ var overParticleList = overSidebar && mouse.Y >= 240 && mouse.Y <= Raylib.GetScreenHeight() - 164;
+ if (overParticleList)
+ {
+ var visibleRows = GetVisibleParticleRows();
+ var maxOffset = Math.Max(0, state.Categories[state.CurrentCategory].Count - visibleRows);
+ state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset - (int)wheel, 0, maxOffset);
+ return;
+ }
+
+ state.BrushRadius = Math.Clamp(state.BrushRadius + (int)wheel, 1, 16);
+ }
+
+ private static void HandleWorldPainting(AppState state, Vector2 mouse, Rectangle worldRect, bool overSidebar, Rectangle settingsRect)
+ {
+ var canPaintWorld =
+ !overSidebar &&
+ (!state.SettingsVisible || !Raylib.CheckCollisionPointRec(mouse, settingsRect)) &&
+ Raylib.CheckCollisionPointRec(mouse, worldRect);
+
+ state.PendingWorldAction = default;
+
+ if (Raylib.IsMouseButtonDown(MouseButton.Left) && canPaintWorld)
+ {
+ var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize;
+ var simY = (int)(mouse.Y / ParticleSize) * ParticleSize;
+ state.PendingWorldAction = new PendingWorldAction(true, false, simX, simY, mouse);
+ if (Raylib.IsMouseButtonPressed(MouseButton.Left))
+ {
+ ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true);
+ }
+ }
+
+ if (Raylib.IsMouseButtonDown(MouseButton.Right) && canPaintWorld)
+ {
+ var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize;
+ var simY = (int)(mouse.Y / ParticleSize) * ParticleSize;
+ state.PendingWorldAction = new PendingWorldAction(true, true, simX, simY, mouse);
+ if (Raylib.IsMouseButtonPressed(MouseButton.Right))
+ {
+ ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: false);
+ }
+ }
+ }
+
+ private static void ApplyWorldAction(AppState state, PendingWorldAction action, bool updateWindDirection, bool continuous = false)
+ {
+ if (!action.Active)
+ {
+ return;
+ }
+
+ if (action.IsErase)
+ {
+ state.Simulation.ClearParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, GetContinuousEraseCount(state), state.Simulation.Frame);
+ return;
+ }
+
+ if (updateWindDirection && state.CurrentParticle is "wind" or "air")
+ {
+ var drag = action.Mouse - state.PreviousMouse;
+ if (drag.LengthSquared() > 0.25f)
+ {
+ state.LastWindDirection = Vector2.Normalize(drag);
+ }
+ }
+
+ switch (state.CurrentParticle)
+ {
+ case "wind":
+ state.Simulation.ApplyWindBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y);
+ break;
+ case "air":
+ state.Simulation.ApplyAirBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y);
+ break;
+ case "gravity_well":
+ state.Simulation.ApplyGravityBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3.5f);
+ break;
+ case "repulsor":
+ state.Simulation.ApplyRepulsorBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3f);
+ break;
+ default:
+ if (continuous)
+ {
+ var pourCount = GetContinuousPaintCount(state);
+ state.Simulation.CreateParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle, pourCount, state.Simulation.Frame);
+ }
+ else
+ {
+ state.Simulation.CreateParticleCircle(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle);
+ }
+ break;
+ }
+ }
+
+ private static void HandleSidebarClick(AppState state, Vector2 mouse)
+ {
+ if (!Raylib.IsMouseButtonPressed(MouseButton.Left))
+ {
+ return;
+ }
+
+ HandleSidebar(state, mouse);
+ if (state.SettingsVisible)
+ {
+ HandleSettingsPanel(state, GetSettingsRect(state), mouse);
+ }
+ }
+
+ private static void StepSimulation(AppState state, float dt, Vector2 mouse)
+ {
+ var simStart = Stopwatch.GetTimestamp();
+ state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset, 0, Math.Max(0, state.Categories.Count - GetVisibleCategoryRows()));
+ state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset, 0, Math.Max(0, state.Categories[state.CurrentCategory].Count - GetVisibleParticleRows()));
+ state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset, 0, Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(GetSettingsRect(state))));
+ state.LastSimulationStepCount = 0;
+
+ if (state.Settings.PauseSim)
+ {
+ if (state.PendingWorldAction.Active)
+ {
+ ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state));
+ }
+
+ state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp());
+ return;
+ }
+
+ state.SimulationAccumulator += dt;
+ var fixedStep = 1f / MathF.Max(1f, state.Settings.SimulationStepsPerSecond);
+ var overload = IsInteractiveOverloaded(state, fixedStep);
+ var maxSteps = overload ? 2 : 4;
+ while (state.SimulationAccumulator >= fixedStep && maxSteps-- > 0)
+ {
+ if (state.PendingWorldAction.Active)
+ {
+ ApplyWorldAction(state, state.PendingWorldAction with { Mouse = mouse }, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state));
+ }
+
+ state.Simulation.Step(fixedStep * state.Settings.TimeScale);
+ state.LastSimulationStepCount++;
+ state.SimulationAccumulator -= fixedStep;
+ }
+
+ if (state.SimulationAccumulator > fixedStep * (overload ? 1.5f : 3f))
+ {
+ state.SimulationAccumulator = fixedStep;
+ }
+
+ state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp());
+ }
+
+ private static Rectangle GetSettingsRect(AppState state)
+ {
+ return new Rectangle(
+ Raylib.GetScreenWidth() - SettingsWidth - 12,
+ 100,
+ SettingsWidth,
+ MathF.Min(32 + (state.SettingItems.Length * 34), Raylib.GetScreenHeight() - 120));
+ }
+
+ private static int GetVisibleSettingsRows(Rectangle panelRect)
+ {
+ return Math.Max(1, (int)((panelRect.Height - 68f) / 34f));
+ }
+
+ private static bool ShouldUseContinuousPaint(AppState state)
+ {
+ if (state.CurrentParticle is "wind" or "air" or "gravity_well" or "repulsor")
+ {
+ return true;
+ }
+
+ var typeId = state.Library.GetTypeId(state.CurrentParticle);
+ var particle = state.Library.GetDefinition(typeId);
+ return particle.Kind == ParticleKind.Gas
+ || particle.Kind == ParticleKind.Liquid
+ || (particle.Kind == ParticleKind.Solid && !particle.IsStatic);
+ }
+
+ private static int GetContinuousPaintCount(AppState state)
+ {
+ var typeId = state.Library.GetTypeId(state.CurrentParticle);
+ var particle = state.Library.GetDefinition(typeId);
+ var baseCount = GetContinuousPaintCount(particle, state.BrushRadius);
+ return ApplyInteractiveLoadScaling(state, baseCount, particle.Kind == ParticleKind.Gas);
+ }
+
+ internal static int GetContinuousPaintCount(ParticleDef particle, int brushRadius)
+ {
+ return particle.Kind switch
+ {
+ ParticleKind.Gas => Math.Clamp(brushRadius + 1, 1, 8),
+ ParticleKind.Liquid => Math.Clamp((brushRadius * 2) + 1, 1, 24),
+ ParticleKind.Solid when !particle.IsStatic => Math.Clamp((brushRadius * 2) + 1, 1, 24),
+ _ => 0,
+ };
+ }
+
+ private static int GetContinuousEraseCount(AppState state)
+ {
+ var baseCount = Math.Clamp((state.BrushRadius * 2) + 2, 1, 18);
+ return ApplyInteractiveLoadScaling(state, baseCount, gasBiased: true);
+ }
+
+ private static bool IsInteractiveOverloaded(AppState state, float fixedStep)
+ {
+ var stepBudgetMicros = fixedStep * 1_000_000f;
+ if (state.LastSimulationStepCount >= 4)
+ {
+ return true;
+ }
+
+ if (state.LastSimulationLoopTimeMicroseconds > stepBudgetMicros * 2.25f)
+ {
+ return true;
+ }
+
+ return state.LastAppFrameTimeMicroseconds > 40_000;
+ }
+
+ private static int ApplyInteractiveLoadScaling(AppState state, int baseCount, bool gasBiased)
+ {
+ if (baseCount <= 1)
+ {
+ return baseCount;
+ }
+
+ var severeOverload =
+ state.LastSimulationStepCount >= 4 ||
+ state.LastSimulationLoopTimeMicroseconds > 45_000 ||
+ state.LastAppFrameTimeMicroseconds > 60_000;
+ if (severeOverload)
+ {
+ return gasBiased
+ ? Math.Max(1, baseCount / 4)
+ : Math.Max(1, baseCount / 3);
+ }
+
+ var overload =
+ state.LastSimulationStepCount >= 2 ||
+ state.LastSimulationLoopTimeMicroseconds > 25_000 ||
+ state.LastAppFrameTimeMicroseconds > 33_000;
+ if (overload)
+ {
+ return gasBiased
+ ? Math.Max(1, baseCount / 2)
+ : Math.Max(1, (baseCount * 2) / 3);
+ }
+
+ return baseCount;
+ }
+}
diff --git a/Sand.App/SandApp.cs b/Sand.App/SandApp.cs
new file mode 100644
index 0000000..c4ada10
--- /dev/null
+++ b/Sand.App/SandApp.cs
@@ -0,0 +1,179 @@
+using Raylib_cs;
+using Sand.Core;
+using System.Numerics;
+using System.Diagnostics;
+
+namespace Sand.App;
+
+internal static partial class SandApp
+{
+ private const int ScreenWidth = 1280;
+ private const int ScreenHeight = 800;
+ private const int SidebarWidth = 220;
+ private const int SettingsWidth = 320;
+ private const int ParticleSize = 4;
+
+ public static void Run()
+ {
+ var state = CreateState();
+
+ Raylib.SetConfigFlags(ConfigFlags.ResizableWindow);
+ Raylib.InitWindow(ScreenWidth, ScreenHeight, $"Sand - C# Port ({state.BackendName})");
+ Raylib.SetTargetFPS(120);
+
+ var frameImage = Raylib.GenImageColor(state.SimWidth, state.SimHeight, Color.Black);
+ state.FrameTexture = Raylib.LoadTextureFromImage(frameImage);
+ Raylib.UnloadImage(frameImage);
+
+ while (!Raylib.WindowShouldClose())
+ {
+ var frameStart = Stopwatch.GetTimestamp();
+ Update(state);
+ Draw(state);
+ var frameEnd = Stopwatch.GetTimestamp();
+ state.LastAppFrameTimeMicroseconds = ToMicroseconds(frameStart, frameEnd);
+ state.LastAppOtherTimeMicroseconds = Math.Max(
+ 0,
+ state.LastAppFrameTimeMicroseconds -
+ state.LastUpdateTimeMicroseconds -
+ state.LastFrameBuildCallTimeMicroseconds -
+ state.LastTextureUploadTimeMicroseconds -
+ state.LastDrawTimeMicroseconds);
+ }
+
+ Raylib.UnloadTexture(state.FrameTexture);
+ Raylib.CloseWindow();
+ }
+
+ private static AppState CreateState()
+ {
+ var contentRoot = Path.Combine(AppContext.BaseDirectory, "Content", "part");
+ var library = ParticleLibraryLoader.LoadFromDirectory(contentRoot);
+ var settings = new SimulationSettings
+ {
+ StorageMode = SimulationStorageMode.Dense,
+ PauseSim = false,
+ EnableDebug = true,
+ EnableFps = true,
+ EnableCursor = true,
+ EnableGasEffect = true,
+ };
+
+ var simWidth = (ScreenWidth - SidebarWidth) / ParticleSize;
+ var simHeight = ScreenHeight / ParticleSize;
+ var simulation = CreateSimulationBackend(simWidth, simHeight, library, settings);
+ var categories = BuildCategories(library, simulation.BackendName);
+ var currentCategory = "Solids";
+
+ return new AppState
+ {
+ Library = library,
+ Settings = settings,
+ Simulation = simulation,
+ Categories = categories,
+ CurrentCategory = currentCategory,
+ CurrentParticle = categories[currentCategory][0].Id,
+ BackendName = simulation.BackendName,
+ BrushRadius = 3,
+ SimWidth = simWidth,
+ SimHeight = simHeight,
+ UploadBuffer = new byte[simWidth * simHeight * 4],
+ LastWindDirection = new Vector2(1f, 0f),
+ SettingItems =
+ [
+ new SettingItem("Cursor", () => settings.EnableCursor, () => settings.EnableCursor = !settings.EnableCursor),
+ new SettingItem("Glow", () => settings.EnableGlow, () => settings.EnableGlow = !settings.EnableGlow),
+ new SettingItem("Gas FX", () => settings.EnableGasEffect, () => settings.EnableGasEffect = !settings.EnableGasEffect),
+ new SettingItem("Wind FX", () => settings.EnableWindVisuals, () => settings.EnableWindVisuals = !settings.EnableWindVisuals),
+ new SettingItem("Pressure FX", () => settings.EnablePressureVisuals, () => settings.EnablePressureVisuals = !settings.EnablePressureVisuals),
+ new SettingItem("Debug", () => settings.EnableDebug, () => settings.EnableDebug = !settings.EnableDebug),
+ new SettingItem("FPS", () => settings.EnableFps, () => settings.EnableFps = !settings.EnableFps),
+ new SettingItem("Temp FX", () => settings.EnableTempVisuals, () => settings.EnableTempVisuals = !settings.EnableTempVisuals),
+ new SettingItem("Wrap", () => settings.WrapParticles, () => settings.WrapParticles = !settings.WrapParticles),
+ new SettingItem("Outer Wall", () => settings.OuterWall, () => settings.OuterWall = !settings.OuterWall),
+ ],
+ };
+ }
+
+ private static ISimulationBackend CreateSimulationBackend(int simWidth, int simHeight, IParticleLibrary library, SimulationSettings settings)
+ {
+ var backend = Environment.GetEnvironmentVariable("SAND_BACKEND");
+ var storageMode = settings.StorageMode;
+ if (string.Equals(backend, "chunk", StringComparison.OrdinalIgnoreCase))
+ {
+ storageMode = SimulationStorageMode.ChunkPrototype;
+ }
+ else if (string.Equals(backend, "dense", StringComparison.OrdinalIgnoreCase))
+ {
+ storageMode = SimulationStorageMode.Dense;
+ }
+
+ if (storageMode == SimulationStorageMode.ChunkPrototype)
+ {
+ return new ChunkPrototypeSimulationBackend(simWidth, simHeight, ParticleSize, library, settings);
+ }
+
+ return new CoreSimulationBackend(new SandSimulation(simWidth, simHeight, ParticleSize, library, settings));
+ }
+
+ private static Dictionary> BuildCategories(IParticleLibrary library, string backendName)
+ {
+ if (string.Equals(backendName, "chunk", StringComparison.OrdinalIgnoreCase))
+ {
+ return BuildChunkPrototypeCategories(library);
+ }
+
+ var special = library.Definitions.Where(static d => d.IsSpecial).OrderBy(static d => d.Name).ToList();
+ if (!special.Any(static d => d.Id == "air"))
+ {
+ special.Add(new ParticleDef
+ {
+ Id = "air",
+ Name = "Air",
+ Kind = ParticleKind.Gas,
+ IsSpecial = true,
+ Color = new Rgb24(160, 210, 255),
+ });
+ }
+
+ special = special.OrderBy(static d => d.Name).ToList();
+
+ return new Dictionary>
+ {
+ ["Solids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Solid && !d.IsSpecial).OrderBy(static d => d.Name).ToList(),
+ ["Liquids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Liquid).OrderBy(static d => d.Name).ToList(),
+ ["Gases"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Gas).OrderBy(static d => d.Name).ToList(),
+ ["Special"] = special,
+ };
+ }
+
+ private static Dictionary> BuildChunkPrototypeCategories(IParticleLibrary library)
+ {
+ var special = library.Definitions.Where(static d => d.IsSpecial).OrderBy(static d => d.Name).ToList();
+ if (!special.Any(static d => d.Id == "air"))
+ {
+ special.Add(new ParticleDef
+ {
+ Id = "air",
+ Name = "Air",
+ Kind = ParticleKind.Gas,
+ IsSpecial = true,
+ Color = new Rgb24(160, 210, 255),
+ });
+ }
+
+ special = special.OrderBy(static d => d.Name).ToList();
+ return new Dictionary>
+ {
+ ["Solids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Solid && !d.IsSpecial).OrderBy(static d => d.Name).ToList(),
+ ["Liquids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Liquid).OrderBy(static d => d.Name).ToList(),
+ ["Gases"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Gas).OrderBy(static d => d.Name).ToList(),
+ ["Special"] = special,
+ };
+ }
+
+ private static long ToMicroseconds(long startTimestamp, long endTimestamp)
+ {
+ return (long)((endTimestamp - startTimestamp) * 1_000_000.0 / Stopwatch.Frequency);
+ }
+}
diff --git a/Sand.Benchmarks.slnx b/Sand.Benchmarks.slnx
new file mode 100644
index 0000000..add9dff
--- /dev/null
+++ b/Sand.Benchmarks.slnx
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Sand.Benchmarks/Program.cs b/Sand.Benchmarks/Program.cs
new file mode 100644
index 0000000..2756ba0
--- /dev/null
+++ b/Sand.Benchmarks/Program.cs
@@ -0,0 +1,869 @@
+using System.Diagnostics;
+using Sand.ChunkPrototype;
+using Sand.Core;
+
+var mode = ParseMode(args);
+var headlessGasParticleId = ParseOptionalArgument(args, "--gas-particle", "--particle");
+var contentRoot = Path.Combine(AppContext.BaseDirectory, "Content", "part");
+var library = ParticleLibraryLoader.LoadFromDirectory(contentRoot);
+var denseSizes = new[] { 512, 1024, 2048 };
+var appWidth = 265;
+var appHeight = 200;
+var appWorkloads = BuildAppWorkloads();
+
+Console.WriteLine($"Sand benchmarks | mode={mode}");
+Console.WriteLine($"Definitions={library.Definitions.Count}");
+Console.WriteLine("Columns: scenario | backend | world | particles | avg_fps | avg_step_ms | avg_frame_ms | step_core_ms | frame_build_ms | activation_ms | movement_ms | runtime_ms | field_decay_ms | loaded_chunks | active_chunks | stepped_chunks | dirty_chunks | field_cells | move_attempts | successful_moves | swap_attempts | stalled_movable | fast_path | full_runtime | estimated_mb");
+Console.WriteLine();
+
+switch (mode)
+{
+ case "dense-baseline":
+ RunDenseBaseline(library, denseSizes);
+ break;
+ case "chunk-baseline":
+ RunChunkBaseline(library, denseSizes);
+ break;
+ case "app-sized":
+ RunAppSizedComparisons(library, appWidth, appHeight, appWorkloads);
+ break;
+ case "app-headless":
+ RunHeadlessAppComparisons(library, appWidth, appHeight, headlessGasParticleId);
+ break;
+ case "snapshot-scenes":
+ RunSnapshotScenes(library, appWidth, appHeight, appWorkloads);
+ break;
+ default:
+ RunDenseBaseline(library, denseSizes);
+ Console.WriteLine();
+ RunChunkBaseline(library, denseSizes);
+ Console.WriteLine();
+ RunAppSizedComparisons(library, appWidth, appHeight, appWorkloads);
+ Console.WriteLine();
+ RunHeadlessAppComparisons(library, appWidth, appHeight, headlessGasParticleId);
+ break;
+}
+
+static string ParseMode(string[] args)
+{
+ for (var i = 0; i < args.Length; i++)
+ {
+ if ((args[i] == "--mode" || args[i] == "-m") && i + 1 < args.Length)
+ {
+ return args[i + 1];
+ }
+ }
+
+ return "all";
+}
+
+static string? ParseOptionalArgument(string[] args, params string[] optionNames)
+{
+ for (var i = 0; i < args.Length; i++)
+ {
+ foreach (var optionName in optionNames)
+ {
+ if (args[i] == optionName && i + 1 < args.Length)
+ {
+ return args[i + 1];
+ }
+ }
+ }
+
+ return null;
+}
+
+void RunDenseBaseline(ParticleLibrary library, IReadOnlyList sizes)
+{
+ Console.WriteLine("Dense baseline");
+ foreach (var size in sizes)
+ {
+ foreach (var workload in BuildDenseScaleWorkloads())
+ {
+ var result = MeasureDense(library, size, size, workload, includePerStepAction: false);
+ PrintResult(result);
+ }
+ }
+}
+
+void RunChunkBaseline(ParticleLibrary library, IReadOnlyList sizes)
+{
+ Console.WriteLine("Chunk baseline");
+ foreach (var size in sizes)
+ {
+ foreach (var workload in BuildChunkScaleWorkloads())
+ {
+ var result = MeasureChunk(library, size, size, workload, includePerStepAction: false);
+ PrintResult(result);
+ }
+ }
+}
+
+void RunAppSizedComparisons(ParticleLibrary library, int width, int height, IReadOnlyList workloads)
+{
+ Console.WriteLine("App-sized comparisons");
+ foreach (var workload in workloads)
+ {
+ PrintResult(MeasureDense(library, width, height, workload, includePerStepAction: true));
+ PrintResult(MeasureChunk(library, width, height, workload, includePerStepAction: true));
+ Console.WriteLine();
+ }
+}
+
+void RunHeadlessAppComparisons(ParticleLibrary library, int width, int height, string? preferredGasParticleId)
+{
+ Console.WriteLine("Headless app-style comparisons");
+ var resolvedGasParticleId = ResolveHeadlessGasParticleId(library, preferredGasParticleId);
+ Console.WriteLine($"Headless gas particle={resolvedGasParticleId}");
+ foreach (var workload in BuildHeadlessAppWorkloads(library, resolvedGasParticleId))
+ {
+ PrintResult(MeasureDense(library, width, height, workload, includePerStepAction: true));
+ PrintResult(MeasureChunk(library, width, height, workload, includePerStepAction: true));
+ Console.WriteLine();
+ }
+}
+
+void RunSnapshotScenes(ParticleLibrary library, int width, int height, IReadOnlyList workloads)
+{
+ Console.WriteLine("Snapshot scenes");
+ foreach (var workload in workloads.Where(static workload => workload.IncludeInSnapshots))
+ {
+ var dense120 = BuildDenseSnapshot(library, width, height, workload, 120);
+ var dense300 = BuildDenseSnapshot(library, width, height, workload, 300);
+ var chunk120 = BuildChunkSnapshot(library, width, height, workload, 120);
+ var chunk300 = BuildChunkSnapshot(library, width, height, workload, 300);
+ Console.WriteLine($"{workload.Name} | dense | step120={dense120} | step300={dense300}");
+ Console.WriteLine($"{workload.Name} | chunk | step120={chunk120} | step300={chunk300}");
+ }
+}
+
+static BenchmarkResult MeasureDense(ParticleLibrary library, int width, int height, AppWorkload workload, bool includePerStepAction)
+{
+ var simulation = new SandSimulation(width, height, 1, library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ workload.SetupDense(simulation);
+ WarmupDense(simulation, workload, includePerStepAction);
+ var iterations = GetIterationCount(width, height);
+ var frameBuffer = new byte[width * height * 4];
+ var stepAverage = MeasureAverageMilliseconds(() =>
+ {
+ if (includePerStepAction)
+ {
+ workload.TickDense(simulation);
+ }
+
+ simulation.Step(1f / 60f);
+ }, iterations);
+ var frameAverage = MeasureAverageMilliseconds(() => simulation.BuildRgbaFrame(frameBuffer), GetFrameIterationCount(width, height));
+ return new BenchmarkResult(
+ workload.Name,
+ "dense",
+ $"{width}x{height}",
+ simulation.ParticleCount,
+ stepAverage + frameAverage <= 0.001 ? 0d : 1000d / (stepAverage + frameAverage),
+ stepAverage,
+ frameAverage,
+ stepAverage,
+ frameAverage,
+ 0d,
+ 0d,
+ 0d,
+ 0d,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0d,
+ 0d,
+ 0d,
+ 0d,
+ 0d,
+ 0d,
+ EstimateDenseBytes(simulation) / (1024d * 1024d));
+}
+
+static BenchmarkResult MeasureChunk(ParticleLibrary library, int width, int height, AppWorkload workload, bool includePerStepAction)
+{
+ var adapter = new PrototypeSparseSandAdapter(width, height);
+ workload.SetupChunk(adapter, library);
+ WarmupChunk(adapter, library, workload, includePerStepAction);
+ var iterations = GetIterationCount(width, height);
+ var totalStepMs = 0d;
+ long totalLoadedChunks = 0;
+ long totalActiveChunks = 0;
+ long totalSteppedChunks = 0;
+ long totalDirtyChunks = 0;
+ long totalFieldCells = 0;
+ long totalMoveAttempts = 0;
+ long totalSuccessfulMoves = 0;
+ long totalSwapAttempts = 0;
+ long totalStalledMovable = 0;
+ long totalFastPath = 0;
+ long totalFullRuntime = 0;
+ long totalActivationMicros = 0;
+ long totalMovementMicros = 0;
+ long totalRuntimeMicros = 0;
+ long totalFieldDecayMicros = 0;
+ for (var i = 0; i < iterations; i++)
+ {
+ if (includePerStepAction)
+ {
+ workload.TickChunk(adapter, library);
+ }
+
+ adapter.World.ClearDirtyChunks();
+ var stepStart = Stopwatch.GetTimestamp();
+ adapter.Step();
+ totalStepMs += Stopwatch.GetElapsedTime(stepStart).TotalMilliseconds;
+ var stats = adapter.LastStepStats;
+ totalLoadedChunks += adapter.World.LoadedChunkCount;
+ totalActiveChunks += adapter.World.ActiveChunkCount;
+ totalSteppedChunks += stats.SteppedChunks;
+ totalDirtyChunks += adapter.World.DirtyChunkCount;
+ totalFieldCells += adapter.FieldCellCount;
+ totalMoveAttempts += stats.MoveAttempts;
+ totalSuccessfulMoves += stats.SuccessfulMoves;
+ totalSwapAttempts += stats.SwapAttempts;
+ totalStalledMovable += stats.StalledMovableCells;
+ totalFastPath += stats.MovementOnlyFastPathCount;
+ totalFullRuntime += stats.FullRuntimeStepCount;
+ totalActivationMicros += stats.ActivationTimeMicroseconds;
+ totalMovementMicros += stats.MovementTimeMicroseconds;
+ totalRuntimeMicros += stats.RuntimeTimeMicroseconds;
+ totalFieldDecayMicros += stats.FieldDecayTimeMicroseconds;
+ }
+
+ var stepAverage = totalStepMs / iterations;
+ var frameAverage = MeasureAverageMilliseconds(() => adapter.BuildRgbaFrame(), GetFrameIterationCount(width, height));
+ return new BenchmarkResult(
+ workload.Name,
+ "chunk",
+ $"{width}x{height}",
+ adapter.ParticleCount,
+ stepAverage + frameAverage <= 0.001 ? 0d : 1000d / (stepAverage + frameAverage),
+ stepAverage,
+ frameAverage,
+ stepAverage,
+ frameAverage,
+ totalActivationMicros / 1000d / iterations,
+ totalMovementMicros / 1000d / iterations,
+ totalRuntimeMicros / 1000d / iterations,
+ totalFieldDecayMicros / 1000d / iterations,
+ (int)Math.Round(totalLoadedChunks / (double)iterations),
+ (int)Math.Round(totalActiveChunks / (double)iterations),
+ (int)Math.Round(totalSteppedChunks / (double)iterations),
+ (int)Math.Round(totalDirtyChunks / (double)iterations),
+ (int)Math.Round(totalFieldCells / (double)iterations),
+ totalMoveAttempts / (double)iterations,
+ totalSuccessfulMoves / (double)iterations,
+ totalSwapAttempts / (double)iterations,
+ totalStalledMovable / (double)iterations,
+ totalFastPath / (double)iterations,
+ totalFullRuntime / (double)iterations,
+ adapter.EstimatedStorageBytes / (1024d * 1024d));
+}
+
+static void WarmupDense(SandSimulation simulation, AppWorkload workload, bool includePerStepAction)
+{
+ for (var i = 0; i < 6; i++)
+ {
+ if (includePerStepAction)
+ {
+ workload.TickDense(simulation);
+ }
+
+ simulation.Step(1f / 60f);
+ }
+}
+
+static void WarmupChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, AppWorkload workload, bool includePerStepAction)
+{
+ for (var i = 0; i < 6; i++)
+ {
+ if (includePerStepAction)
+ {
+ workload.TickChunk(adapter, library);
+ }
+
+ adapter.Step();
+ }
+}
+
+static double MeasureAverageMilliseconds(Action action, int iterations)
+{
+ var stopwatch = Stopwatch.StartNew();
+ for (var i = 0; i < iterations; i++)
+ {
+ action();
+ }
+
+ stopwatch.Stop();
+ return stopwatch.Elapsed.TotalMilliseconds / iterations;
+}
+
+static int GetIterationCount(int width, int height)
+{
+ var area = width * height;
+ return area switch
+ {
+ <= 53000 => 120,
+ <= 262144 => 24,
+ <= 1048576 => 10,
+ _ => 5,
+ };
+}
+
+static int GetFrameIterationCount(int width, int height)
+{
+ var area = width * height;
+ return area switch
+ {
+ <= 53000 => 120,
+ <= 262144 => 18,
+ <= 1048576 => 8,
+ _ => 4,
+ };
+}
+
+static void PrintResult(BenchmarkResult result)
+{
+ Console.WriteLine($"{result.Scenario} | {result.Backend} | {result.World} | {result.Particles} | {result.AvgFpsEquivalent:F2} | {result.AvgStepMs:F3} | {result.AvgFrameMs:F3} | {result.StepCoreMs:F3} | {result.FrameBuildMs:F3} | {result.ActivationMs:F3} | {result.MovementMs:F3} | {result.RuntimeMs:F3} | {result.FieldDecayMs:F3} | {result.LoadedChunks} | {result.ActiveChunks} | {result.SteppedChunks} | {result.DirtyChunks} | {result.FieldCells} | {result.MoveAttempts:F1} | {result.SuccessfulMoves:F1} | {result.SwapAttempts:F1} | {result.StalledMovable:F1} | {result.MovementOnlyFastPathCount:F1} | {result.FullRuntimeStepCount:F1} | {result.EstimatedMb:F3}");
+}
+
+static string BuildDenseSnapshot(ParticleLibrary library, int width, int height, AppWorkload workload, int steps)
+{
+ var simulation = new SandSimulation(width, height, 1, library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ workload.SetupDense(simulation);
+ for (var i = 0; i < steps; i++)
+ {
+ workload.TickDense(simulation);
+ simulation.Step(1f / 60f);
+ }
+
+ unchecked
+ {
+ uint hash = 2166136261;
+ for (var y = 0; y < simulation.Height; y++)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ var typeId = simulation.TypeId[x, y];
+ if (typeId == 0)
+ {
+ continue;
+ }
+
+ hash = (hash ^ (uint)((x * 397) ^ (y * 911) ^ typeId)) * 16777619;
+ }
+ }
+
+ return $"{hash:X8}";
+ }
+}
+
+static string BuildChunkSnapshot(ParticleLibrary library, int width, int height, AppWorkload workload, int steps)
+{
+ var adapter = new PrototypeSparseSandAdapter(width, height);
+ workload.SetupChunk(adapter, library);
+ for (var i = 0; i < steps; i++)
+ {
+ workload.TickChunk(adapter, library);
+ adapter.Step();
+ }
+
+ unchecked
+ {
+ uint hash = 2166136261;
+ foreach (var ((x, y), particle) in adapter.ParticleEntries.OrderBy(static entry => entry.Key.Item2).ThenBy(static entry => entry.Key.Item1))
+ {
+ hash = (hash ^ (uint)((x * 397) ^ (y * 911) ^ particle.TypeId)) * 16777619;
+ }
+
+ return $"{hash:X8}";
+ }
+}
+
+static long EstimateDenseBytes(SandSimulation simulation)
+{
+ var cells = (long)simulation.Width * simulation.Height;
+ const int typeBytes = sizeof(ushort);
+ const int floatBytes = sizeof(float);
+ const int byteBytes = sizeof(byte);
+ const int shortBytes = sizeof(short);
+ const int intBytes = sizeof(int);
+ var perCellBytes = typeBytes + (12 * floatBytes) + byteBytes + shortBytes + (2 * intBytes) + 3 + 4;
+ return cells * perCellBytes;
+}
+
+IReadOnlyList BuildDenseScaleWorkloads() =>
+ [
+ new AppWorkload("empty", _ => { }, (_, _) => { }, _ => { }, (_, _) => { }, IncludeInSnapshots: false),
+ new AppWorkload("sparse_rain", CreateSparseRainSceneDense, CreateSparseRainSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true),
+ new AppWorkload("dense_pile", CreateDensePileSceneDense, CreateDensePileSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: false),
+ new AppWorkload("gas_pocket", CreateGasPocketSceneDense, CreateGasPocketSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true),
+ new AppWorkload("tool_stress", CreateToolStressSceneDense, CreateToolStressSceneChunk, TickToolStressDense, TickToolStressChunk, IncludeInSnapshots: true),
+ ];
+
+IReadOnlyList BuildChunkScaleWorkloads() => BuildDenseScaleWorkloads();
+
+IReadOnlyList BuildAppWorkloads() =>
+ [
+ new AppWorkload("active_sand_flood", _ => { }, (_, _) => { }, TickActiveSandFloodDense, TickActiveSandFloodChunk, IncludeInSnapshots: false),
+ new AppWorkload("mixed_pile", CreateMixedPileSceneDense, CreateMixedPileSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true),
+ new AppWorkload("active_gas_burst", CreateGasPocketSceneDense, CreateGasPocketSceneChunk, TickActiveGasBurstDense, TickActiveGasBurstChunk, IncludeInSnapshots: false),
+ new AppWorkload("continuous_mixed_paint", _ => { }, (_, _) => { }, TickContinuousMixedPaintDense, TickContinuousMixedPaintChunk, IncludeInSnapshots: false),
+ new AppWorkload("tool_stress", CreateToolStressSceneDense, CreateToolStressSceneChunk, TickToolStressDense, TickToolStressChunk, IncludeInSnapshots: true),
+ ];
+
+IReadOnlyList BuildHeadlessAppWorkloads(ParticleLibrary library, string gasParticleId)
+{
+ const int brushRadius = 16;
+ return
+ [
+ new AppWorkload(
+ "headless_gas_hold_paint",
+ _ => { },
+ (_, _) => { },
+ simulation => TickHeadlessHeldPaintDense(simulation, library, gasParticleId, brushRadius),
+ (adapter, particleLibrary) => TickHeadlessHeldPaintChunk(adapter, particleLibrary ?? library, gasParticleId, brushRadius),
+ IncludeInSnapshots: false),
+ new AppWorkload(
+ "headless_gas_paint_erase",
+ _ => { },
+ (_, _) => { },
+ simulation => TickHeadlessGasPaintEraseDense(simulation, library, gasParticleId, brushRadius),
+ (adapter, particleLibrary) => TickHeadlessGasPaintEraseChunk(adapter, particleLibrary ?? library, gasParticleId, brushRadius),
+ IncludeInSnapshots: false),
+ ];
+}
+
+static void CreateSparseRainSceneDense(SandSimulation simulation)
+{
+ foreach (var (x, y) in BuildSparseRainCells(simulation.Width, simulation.Height))
+ {
+ simulation.CreateParticleAtPixel(x, y, "sand");
+ }
+}
+
+static void CreateSparseRainSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ foreach (var (x, y) in BuildSparseRainCells(adapter.Width, adapter.Height))
+ {
+ adapter.AddParticle(x, y, PrototypeParticleType.Sand);
+ }
+}
+
+static void CreateDensePileSceneDense(SandSimulation simulation)
+{
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ simulation.CreateParticleAtPixel(x, simulation.Height - 2, "wall");
+ }
+
+ for (var y = simulation.Height / 2; y < simulation.Height - 2; y++)
+ {
+ for (var x = simulation.Width / 4; x < (simulation.Width * 3) / 4; x++)
+ {
+ simulation.CreateParticleAtPixel(x, y, ((x + y) & 1) == 0 ? "sand" : "water");
+ }
+ }
+}
+
+static void CreateDensePileSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ for (var x = 0; x < adapter.Width; x++)
+ {
+ adapter.AddParticle(x, adapter.Height - 2, PrototypeParticleType.Wall);
+ }
+
+ for (var y = adapter.Height / 2; y < adapter.Height - 2; y++)
+ {
+ for (var x = adapter.Width / 4; x < (adapter.Width * 3) / 4; x++)
+ {
+ adapter.AddParticle(x, y, ((x + y) & 1) == 0 ? PrototypeParticleType.Sand : PrototypeParticleType.Water);
+ }
+ }
+}
+
+static void CreateMixedPileSceneDense(SandSimulation simulation)
+{
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ simulation.CreateParticleAtPixel(x, simulation.Height - 2, "wall");
+ }
+
+ for (var x = simulation.Width / 4; x < (simulation.Width * 3) / 4; x++)
+ {
+ simulation.CreateParticleAtPixel(x, simulation.Height / 2, "sand");
+ simulation.CreateParticleAtPixel(x, simulation.Height / 2 - 18, "water");
+ }
+}
+
+static void CreateMixedPileSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ for (var x = 0; x < adapter.Width; x++)
+ {
+ adapter.AddParticle(x, adapter.Height - 2, PrototypeParticleType.Wall);
+ }
+
+ for (var x = adapter.Width / 4; x < (adapter.Width * 3) / 4; x++)
+ {
+ adapter.AddParticle(x, adapter.Height / 2, PrototypeParticleType.Sand);
+ adapter.AddParticle(x, adapter.Height / 2 - 18, PrototypeParticleType.Water);
+ }
+}
+
+static void CreateGasPocketSceneDense(SandSimulation simulation)
+{
+ var left = simulation.Width / 3;
+ var right = (simulation.Width * 2) / 3;
+ var top = simulation.Height / 3;
+ var bottom = simulation.Height - 16;
+ for (var x = left; x <= right; x++)
+ {
+ simulation.CreateParticleAtPixel(x, bottom, "wall");
+ }
+
+ for (var y = top; y <= bottom; y++)
+ {
+ simulation.CreateParticleAtPixel(left, y, "wall");
+ simulation.CreateParticleAtPixel(right, y, "wall");
+ }
+
+ for (var x = left + 2; x < right - 1; x += 2)
+ {
+ for (var y = bottom - 22; y < bottom - 6; y += 2)
+ {
+ simulation.CreateParticleAtPixel(x, y, "steam");
+ }
+ }
+}
+
+static void CreateGasPocketSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ var left = adapter.Width / 3;
+ var right = (adapter.Width * 2) / 3;
+ var top = adapter.Height / 3;
+ var bottom = adapter.Height - 16;
+ for (var x = left; x <= right; x++)
+ {
+ adapter.AddParticle(x, bottom, PrototypeParticleType.Wall);
+ }
+
+ for (var y = top; y <= bottom; y++)
+ {
+ adapter.AddParticle(left, y, PrototypeParticleType.Wall);
+ adapter.AddParticle(right, y, PrototypeParticleType.Wall);
+ }
+
+ for (var x = left + 2; x < right - 1; x += 2)
+ {
+ for (var y = bottom - 22; y < bottom - 6; y += 2)
+ {
+ adapter.AddParticle(x, y, PrototypeParticleType.Steam);
+ }
+ }
+}
+
+static void CreateToolStressSceneDense(SandSimulation simulation)
+{
+ CreateMixedPileSceneDense(simulation);
+ TickToolStressDense(simulation);
+}
+
+static void CreateToolStressSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ CreateMixedPileSceneChunk(adapter, null);
+ TickToolStressChunk(adapter, null);
+}
+
+static void TickActiveSandFloodDense(SandSimulation simulation)
+{
+ var x = 8 + ((simulation.Frame * 5) % Math.Max(1, simulation.Width - 40));
+ for (var dx = 0; dx < 20; dx += 2)
+ {
+ simulation.CreateParticleAtPixel(x + dx, 4, "sand");
+ simulation.CreateParticleAtPixel(x + dx, 6, "sand");
+ }
+}
+
+static void TickActiveSandFloodChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ var x = 8 + (((adapter.ParticleCount * 5) + (adapter.LastStepStats.MoveAttempts * 3)) % Math.Max(1, adapter.Width - 40));
+ for (var dx = 0; dx < 20; dx += 2)
+ {
+ adapter.AddParticle(x + dx, 4, PrototypeParticleType.Sand);
+ adapter.AddParticle(x + dx, 6, PrototypeParticleType.Sand);
+ }
+}
+
+static void TickContinuousMixedPaintDense(SandSimulation simulation)
+{
+ var x = 8 + ((simulation.Frame * 7) % Math.Max(1, simulation.Width - 36));
+ for (var y = 4; y < 22; y += 3)
+ {
+ simulation.CreateParticleAtPixel(x, y, "sand");
+ simulation.CreateParticleAtPixel(x + 8, y, "water");
+ simulation.CreateParticleAtPixel(x + 16, y, "steam");
+ }
+}
+
+static void TickContinuousMixedPaintChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ var x = 8 + (((adapter.ParticleCount * 7) + (adapter.LastStepStats.MoveAttempts * 5)) % Math.Max(1, adapter.Width - 36));
+ for (var y = 4; y < 22; y += 3)
+ {
+ adapter.AddParticle(x, y, PrototypeParticleType.Sand);
+ adapter.AddParticle(x + 8, y, PrototypeParticleType.Water);
+ adapter.AddParticle(x + 16, y, PrototypeParticleType.Steam);
+ }
+}
+
+static void TickActiveGasBurstDense(SandSimulation simulation)
+{
+ var centerX = simulation.Width / 2;
+ var sourceY = simulation.Height - 26;
+ for (var dx = -10; dx <= 10; dx += 2)
+ {
+ simulation.CreateParticleAtPixel(centerX + dx, sourceY, "steam");
+ simulation.CreateParticleAtPixel(centerX + dx, sourceY - 2, "steam");
+ }
+}
+
+static void TickActiveGasBurstChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ var centerX = adapter.Width / 2;
+ var sourceY = adapter.Height - 26;
+ for (var dx = -10; dx <= 10; dx += 2)
+ {
+ adapter.AddParticle(centerX + dx, sourceY, PrototypeParticleType.Steam);
+ adapter.AddParticle(centerX + dx, sourceY - 2, PrototypeParticleType.Steam);
+ }
+}
+
+static void TickHeadlessHeldPaintDense(SandSimulation simulation, ParticleLibrary library, string particleId, int brushRadius)
+{
+ var particle = library.GetDefinition(library.GetTypeId(particleId));
+ var centerX = (simulation.Width / 2) * 4;
+ var centerY = (simulation.Height / 4) * 4;
+ PaintLikeAppDense(simulation, particleId, particle, centerX, centerY, brushRadius);
+}
+
+static void TickHeadlessHeldPaintChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, int brushRadius)
+{
+ var particle = library.GetDefinition(library.GetTypeId(particleId));
+ var centerX = (adapter.Width / 2) * 4;
+ var centerY = (adapter.Height / 4) * 4;
+ PaintLikeAppChunk(adapter, library, particleId, particle, centerX, centerY, brushRadius);
+}
+
+static void TickHeadlessGasPaintEraseDense(SandSimulation simulation, ParticleLibrary library, string particleId, int brushRadius)
+{
+ TickHeadlessHeldPaintDense(simulation, library, particleId, brushRadius);
+ if ((simulation.Frame & 1) == 0)
+ {
+ simulation.ClearParticleCircle((simulation.Width / 2) * 4, (simulation.Height / 2) * 4, Math.Max(4, brushRadius / 2));
+ }
+}
+
+static void TickHeadlessGasPaintEraseChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, int brushRadius)
+{
+ TickHeadlessHeldPaintChunk(adapter, library, particleId, brushRadius);
+ if ((adapter.LastStepStats.SteppedChunks & 1) == 0)
+ {
+ ClearCircleChunk(adapter, adapter.Width / 2, adapter.Height / 2, Math.Max(4, brushRadius / 2));
+ }
+}
+
+static void PaintLikeAppDense(SandSimulation simulation, string particleId, ParticleDef particle, int centerX, int centerY, int brushRadius)
+{
+ if (ShouldUseContinuousPaint(particle))
+ {
+ simulation.CreateParticlePourAtPixel(centerX, centerY, brushRadius, particleId, GetContinuousPaintCount(particle, brushRadius), simulation.Frame);
+ return;
+ }
+
+ simulation.CreateParticleCircle(centerX, centerY, brushRadius, particleId);
+}
+
+static void PaintLikeAppChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, ParticleDef particle, int centerX, int centerY, int brushRadius)
+{
+ if (ShouldUseContinuousPaint(particle))
+ {
+ CreateParticlePourChunk(adapter, library, centerX, centerY, brushRadius, particleId, GetContinuousPaintCount(particle, brushRadius), adapter.LastStepStats.SteppedChunks + adapter.ParticleCount);
+ return;
+ }
+
+ CreateParticleCircleChunk(adapter, library, centerX, centerY, brushRadius, particleId);
+}
+
+static bool ShouldUseContinuousPaint(ParticleDef particle) =>
+ particle.Kind == ParticleKind.Gas ||
+ particle.Kind == ParticleKind.Liquid ||
+ (particle.Kind == ParticleKind.Solid && !particle.IsStatic);
+
+static int GetContinuousPaintCount(ParticleDef particle, int brushRadius) => particle.Kind switch
+{
+ ParticleKind.Gas => Math.Clamp(brushRadius + 1, 1, 8),
+ ParticleKind.Liquid => Math.Clamp((brushRadius * 2) + 1, 1, 24),
+ ParticleKind.Solid when !particle.IsStatic => Math.Clamp((brushRadius * 2) + 1, 1, 24),
+ _ => 0,
+};
+
+static string ResolveHeadlessGasParticleId(ParticleLibrary library, string? preferredParticleId)
+{
+ if (!string.IsNullOrWhiteSpace(preferredParticleId) && library.TryGetTypeId(preferredParticleId, out var preferredId) && preferredId != 0)
+ {
+ return preferredParticleId;
+ }
+
+ return library.TryGetTypeId("steam", out var steamId) && steamId != 0
+ ? "steam"
+ : library.TryGetTypeId("ultratanium", out var ultraId) && ultraId != 0
+ ? "ultratanium"
+ : library.Definitions.First(static d => d.Kind == ParticleKind.Gas).Id;
+}
+
+static void CreateParticleCircleChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, int centerX, int centerY, int brushRadius, string particleId)
+{
+ var gridCenterX = centerX / 4;
+ var gridCenterY = centerY / 4;
+ var particle = ResolveChunkParticle(library, adapter, particleId);
+ for (var dx = -brushRadius; dx <= brushRadius; dx++)
+ {
+ for (var dy = -brushRadius; dy <= brushRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ adapter.AddParticle(gridCenterX + dx, gridCenterY + dy, particle);
+ }
+ }
+}
+
+static void CreateParticlePourChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed)
+{
+ var gridCenterX = centerX / 4;
+ var gridCenterY = centerY / 4;
+ var diameter = (brushRadius * 2) + 1;
+ var placed = 0;
+ var particle = ResolveChunkParticle(library, adapter, particleId);
+ for (var i = 0; i < Math.Max(maxParticles * 6, diameter * diameter) && placed < maxParticles; i++)
+ {
+ var dx = ((seed + (i * 17)) % diameter) - brushRadius;
+ var dy = ((seed + (i * 31)) % diameter) - brushRadius;
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ if (adapter.AddParticle(gridCenterX + dx, gridCenterY + dy, particle))
+ {
+ placed++;
+ }
+ }
+}
+
+static void ClearCircleChunk(PrototypeSparseSandAdapter adapter, int centerX, int centerY, int brushRadius)
+{
+ for (var dx = -brushRadius; dx <= brushRadius; dx++)
+ {
+ for (var dy = -brushRadius; dy <= brushRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ adapter.RemoveParticle(centerX + dx, centerY + dy);
+ }
+ }
+}
+
+static PrototypeParticle ResolveChunkParticle(ParticleLibrary library, PrototypeSparseSandAdapter adapter, string particleId)
+{
+ var typeId = library.GetTypeId(particleId);
+ var def = library.GetDefinition(typeId);
+ var color = def.Color;
+ var motionType = def.Kind switch
+ {
+ ParticleKind.Solid => def.IsStatic ? PrototypeParticleType.Wall : PrototypeParticleType.Sand,
+ ParticleKind.Liquid => PrototypeParticleType.Water,
+ ParticleKind.Gas => PrototypeParticleType.Steam,
+ _ => PrototypeParticleType.Sand,
+ };
+ var particle = new PrototypeParticle(typeId, def.Id, motionType, def.Kind, ParticleBehaviorKind.None, color.R, color.G, color.B, MathF.Max(0.1f, def.Mass), MathF.Max(0.01f, def.Velocity), MathF.Max(0f, def.Friction), MathF.Max(0f, def.Viscosity), IsStatic: def.IsStatic);
+ adapter.RegisterParticleProfile(particle);
+ return particle;
+}
+
+static void TickToolStressDense(SandSimulation simulation)
+{
+ var centerX = simulation.Width / 2;
+ var centerY = simulation.Height / 2;
+ simulation.ApplyWindBrushAtPixel(centerX, centerY, 8, 1f, 0.2f);
+ simulation.ApplyAirBrushAtPixel(centerX + 12, centerY - 6, 7, 0.75f, 0f);
+ simulation.ApplyGravityBrushAtPixel(centerX - 10, centerY + 4, 6, 2.5f);
+}
+
+static void TickToolStressChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _)
+{
+ var centerX = adapter.Width / 2;
+ var centerY = adapter.Height / 2;
+ adapter.ApplyWindBrush(centerX, centerY, 8, 1f, 0.2f);
+ adapter.ApplyAirBrush(centerX + 12, centerY - 6, 7, 0.75f, 0f);
+ adapter.ApplyGravityBrush(centerX - 10, centerY + 4, 6, 2.5f);
+}
+
+static List<(int X, int Y)> BuildSparseRainCells(int width, int height)
+{
+ var cells = new List<(int X, int Y)>();
+ for (var x = 4; x < width - 4; x += Math.Max(10, width / 24))
+ {
+ cells.Add((x, 4 + ((x / 7) % 5)));
+ cells.Add((x, 12 + ((x / 11) % 7)));
+ if (height > 40)
+ {
+ cells.Add((x, 24 + ((x / 5) % 9)));
+ }
+ }
+
+ return cells;
+}
+
+internal readonly record struct AppWorkload(
+ string Name,
+ Action SetupDense,
+ Action SetupChunk,
+ Action TickDense,
+ Action TickChunk,
+ bool IncludeInSnapshots);
+
+internal readonly record struct BenchmarkResult(
+ string Scenario,
+ string Backend,
+ string World,
+ int Particles,
+ double AvgFpsEquivalent,
+ double AvgStepMs,
+ double AvgFrameMs,
+ double StepCoreMs,
+ double FrameBuildMs,
+ double ActivationMs,
+ double MovementMs,
+ double RuntimeMs,
+ double FieldDecayMs,
+ int LoadedChunks,
+ int ActiveChunks,
+ int SteppedChunks,
+ int DirtyChunks,
+ int FieldCells,
+ double MoveAttempts,
+ double SuccessfulMoves,
+ double SwapAttempts,
+ double StalledMovable,
+ double MovementOnlyFastPathCount,
+ double FullRuntimeStepCount,
+ double EstimatedMb);
diff --git a/Sand.Benchmarks/README.md b/Sand.Benchmarks/README.md
new file mode 100644
index 0000000..f3e4e44
--- /dev/null
+++ b/Sand.Benchmarks/README.md
@@ -0,0 +1,46 @@
+# Sand Benchmarks
+
+This project measures both the dense reference backend and the experimental chunk backend.
+
+## Modes
+
+- `dense-baseline`
+- `chunk-baseline`
+- `app-sized`
+- `snapshot-scenes`
+
+## Main workloads
+
+- `empty`
+- `sparse_rain`
+- `dense_pile`
+- `gas_pocket`
+- `tool_stress`
+- app-sized `sand_fill`
+- app-sized `mixed_pile`
+- app-sized `paint_stress`
+
+## Metrics
+
+- average `Step` time
+- average `BuildRgbaFrame` time
+- app-sized FPS-equivalent comparison numbers
+- chunk workload counters (`loaded`, `active`, `stepped`, `dirty`, `field_cells`)
+- estimated storage footprint
+
+## Run
+
+```powershell
+dotnet run --project Sand.Benchmarks/Sand.Benchmarks.csproj -c Release -- --mode app-sized
+```
+
+```powershell
+dotnet run --project Sand.Benchmarks/Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes
+```
+
+## Promotion gate
+
+- sparse step speedup must be `>= 25%`
+- sparse memory reduction at `1024x1024+` must be `>= 40%`
+- dense `512x512` regression must stay within `10%`
+- deterministic snapshots must be reviewed after `120` and `300` steps before storage promotion
diff --git a/Sand.Benchmarks/Sand.Benchmarks.csproj b/Sand.Benchmarks/Sand.Benchmarks.csproj
new file mode 100644
index 0000000..4d2386b
--- /dev/null
+++ b/Sand.Benchmarks/Sand.Benchmarks.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sand.ChunkPrototype.App/Program.cs b/Sand.ChunkPrototype.App/Program.cs
new file mode 100644
index 0000000..3a54eae
--- /dev/null
+++ b/Sand.ChunkPrototype.App/Program.cs
@@ -0,0 +1,168 @@
+using Raylib_cs;
+using Sand.ChunkPrototype;
+
+const int screenWidth = 1280;
+const int screenHeight = 800;
+const int sidebarWidth = 220;
+const int particleSize = 4;
+const int simWidth = (screenWidth - sidebarWidth) / particleSize;
+const int simHeight = screenHeight / particleSize;
+const int brushRadius = 3;
+
+var adapter = new PrototypeSparseSandAdapter(simWidth, simHeight);
+var accumulator = 0f;
+var fixedStep = 1f / 60f;
+var stepCounter = 0;
+var currentParticle = PrototypeParticleType.Sand;
+var frameBuffer = new byte[simWidth * simHeight * 4];
+
+Raylib.SetConfigFlags(ConfigFlags.ResizableWindow);
+Raylib.InitWindow(screenWidth, screenHeight, "Sand Chunk Prototype");
+Raylib.SetTargetFPS(120);
+var frameImage = Raylib.GenImageColor(simWidth, simHeight, Color.Black);
+var frameTexture = Raylib.LoadTextureFromImage(frameImage);
+Raylib.UnloadImage(frameImage);
+
+while (!Raylib.WindowShouldClose())
+{
+ var dt = Raylib.GetFrameTime();
+ var mouse = Raylib.GetMousePosition();
+ var simMouseX = Math.Clamp((int)((mouse.X - sidebarWidth) / particleSize), 0, simWidth - 1);
+ var simMouseY = Math.Clamp((int)(mouse.Y / particleSize), 0, simHeight - 1);
+ var overWorld = mouse.X >= sidebarWidth;
+
+ if (Raylib.IsKeyPressed(KeyboardKey.C))
+ {
+ adapter = new PrototypeSparseSandAdapter(simWidth, simHeight);
+ accumulator = 0f;
+ stepCounter = 0;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.One))
+ {
+ currentParticle = PrototypeParticleType.Sand;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.Two))
+ {
+ currentParticle = PrototypeParticleType.Water;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.Three))
+ {
+ currentParticle = PrototypeParticleType.Steam;
+ }
+
+ if (Raylib.IsKeyPressed(KeyboardKey.Four))
+ {
+ currentParticle = PrototypeParticleType.Wall;
+ }
+
+ if (Raylib.IsMouseButtonDown(MouseButton.Left) && overWorld)
+ {
+ PaintCircle(adapter, simMouseX, simMouseY, brushRadius, currentParticle, add: true);
+ }
+
+ if (Raylib.IsMouseButtonDown(MouseButton.Right) && overWorld)
+ {
+ PaintCircle(adapter, simMouseX, simMouseY, brushRadius, currentParticle, add: false);
+ }
+
+ accumulator += dt;
+ var maxSteps = 8;
+ while (accumulator >= fixedStep && maxSteps-- > 0)
+ {
+ adapter.World.ClearDirtyChunks();
+ adapter.Step();
+ stepCounter++;
+ if ((stepCounter % 30) == 0)
+ {
+ adapter.TrimResidency(marginChunks: 1);
+ }
+
+ accumulator -= fixedStep;
+ }
+
+ if (accumulator > fixedStep * 4f)
+ {
+ accumulator = fixedStep;
+ }
+
+ var rgbaFrame = adapter.BuildRgbaFrame();
+ rgbaFrame.CopyTo(frameBuffer);
+ unsafe
+ {
+ fixed (byte* pixels = frameBuffer)
+ {
+ Raylib.UpdateTexture(frameTexture, pixels);
+ }
+ }
+
+ Raylib.BeginDrawing();
+ Raylib.ClearBackground(new Color(8, 10, 14, 255));
+ Raylib.DrawRectangle(0, 0, sidebarWidth, screenHeight, new Color(20, 24, 30, 255));
+ Raylib.DrawTextureEx(frameTexture, new System.Numerics.Vector2(sidebarWidth, 0), 0f, particleSize, Color.White);
+ if (overWorld)
+ {
+ Raylib.DrawCircleLines((int)mouse.X, (int)mouse.Y, brushRadius * particleSize, new Color(255, 255, 255, 180));
+ }
+
+ DrawPanel(adapter, currentParticle);
+ Raylib.EndDrawing();
+}
+
+Raylib.UnloadTexture(frameTexture);
+Raylib.CloseWindow();
+
+static void PaintCircle(PrototypeSparseSandAdapter adapter, int centerX, int centerY, int radius, PrototypeParticleType type, bool add)
+{
+ for (var dx = -radius; dx <= radius; dx++)
+ {
+ for (var dy = -radius; dy <= radius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > radius * radius)
+ {
+ continue;
+ }
+
+ var x = centerX + dx;
+ var y = centerY + dy;
+ if (x < 0 || y < 0 || x >= adapter.Width || y >= adapter.Height)
+ {
+ continue;
+ }
+
+ if (add)
+ {
+ adapter.AddParticle(x, y, type);
+ }
+ else
+ {
+ adapter.RemoveParticle(x, y);
+ }
+ }
+ }
+}
+
+static void DrawPanel(PrototypeSparseSandAdapter adapter, PrototypeParticleType currentParticle)
+{
+ Raylib.DrawText("Chunk Proto", 12, 14, 28, new Color(110, 172, 228, 255));
+ DrawParticleButton(12, 60, "1 Sand", currentParticle == PrototypeParticleType.Sand, new Color(214, 188, 96, 255));
+ DrawParticleButton(12, 96, "2 Water", currentParticle == PrototypeParticleType.Water, new Color(72, 132, 232, 255));
+ DrawParticleButton(12, 132, "3 Steam", currentParticle == PrototypeParticleType.Steam, new Color(182, 196, 214, 255));
+ DrawParticleButton(12, 168, "4 Wall", currentParticle == PrototypeParticleType.Wall, new Color(96, 96, 104, 255));
+ Raylib.DrawText($"FPS {Raylib.GetFPS()}", 12, 228, 20, Color.White);
+ Raylib.DrawText($"Particles {adapter.ParticleCount}", 12, 252, 20, Color.White);
+ Raylib.DrawText($"Loaded Chunks {adapter.World.LoadedChunkCount}", 12, 276, 20, Color.White);
+ Raylib.DrawText($"Active Chunks {adapter.World.ActiveChunkCount}", 12, 300, 20, Color.White);
+ Raylib.DrawText($"Dirty Chunks {adapter.World.DirtyChunkCount}", 12, 324, 20, Color.White);
+ Raylib.DrawText("LMB paint", 12, 372, 18, Color.LightGray);
+ Raylib.DrawText("RMB erase", 12, 394, 18, Color.LightGray);
+ Raylib.DrawText("C clear", 12, 416, 18, Color.LightGray);
+}
+
+static void DrawParticleButton(int x, int y, string label, bool selected, Color color)
+{
+ Raylib.DrawRectangleRounded(new Rectangle(x, y, 188, 28), 0.2f, 6, selected ? new Color(236, 236, 242, 255) : color);
+ Raylib.DrawText(label, x + 10, y + 5, 18, selected ? Color.Black : Color.Black);
+}
diff --git a/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj b/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj
new file mode 100644
index 0000000..d011af3
--- /dev/null
+++ b/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs b/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs
new file mode 100644
index 0000000..890edeb
--- /dev/null
+++ b/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs
@@ -0,0 +1,72 @@
+using FluentAssertions;
+using Sand.ChunkPrototype;
+
+namespace Sand.ChunkPrototype.Tests;
+
+public sealed class PrototypeChunkResidencyWorldTests
+{
+ [Fact]
+ public void MoveOccupiedAcrossChunkBoundaryUpdatesCountsAndDirtyChunks()
+ {
+ var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 16));
+ world.SetOccupied(3, 1);
+ world.ClearDirtyChunks();
+
+ var moved = world.MoveOccupied(3, 1, 4, 1);
+
+ moved.Should().BeTrue();
+ world.IsOccupied(3, 1).Should().BeFalse();
+ world.IsOccupied(4, 1).Should().BeTrue();
+ world.GetChunkOccupancyCount(0, 0).Should().Be(0);
+ world.GetChunkOccupancyCount(1, 0).Should().Be(1);
+ world.DirtyChunks.Should().BeEquivalentTo([(0, 0), (1, 0)]);
+ }
+
+ [Fact]
+ public void UnloadEmptyChunksRemovesChunksAfterClearingOccupancy()
+ {
+ var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 16));
+ world.SetOccupied(1, 1);
+ world.SetOccupied(9, 1);
+
+ world.ClearOccupied(1, 1);
+
+ var unloaded = world.UnloadEmptyChunks();
+
+ unloaded.Should().Be(1);
+ world.IsChunkLoaded(0, 0).Should().BeFalse();
+ world.IsChunkLoaded(2, 0).Should().BeTrue();
+ world.ActiveChunkCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void UnloadInactiveChunksKeepsActiveNeighborsWithinMargin()
+ {
+ var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 32));
+ world.SetOccupied(4, 4);
+ world.SetOccupied(20, 20);
+ world.ClearOccupied(20, 20);
+ world.SetOccupied(8, 4);
+
+ var unloaded = world.UnloadInactiveChunks(marginChunks: 1);
+
+ unloaded.Should().BeGreaterThanOrEqualTo(1);
+ world.IsChunkLoaded(1, 1).Should().BeTrue();
+ world.IsChunkLoaded(2, 1).Should().BeTrue();
+ world.IsChunkLoaded(5, 5).Should().BeFalse();
+ }
+
+ [Fact]
+ public void ClearDirtyChunksResetsDirtyChunkTracking()
+ {
+ var world = new PrototypeChunkResidencyWorld();
+ world.SetOccupied(2, 2);
+ world.SetOccupied(40, 2);
+ world.DirtyChunkCount.Should().Be(2);
+
+ world.ClearDirtyChunks();
+
+ world.DirtyChunkCount.Should().Be(0);
+ world.DirtyChunks.Should().BeEmpty();
+ }
+}
diff --git a/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs b/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs
new file mode 100644
index 0000000..ca9d35f
--- /dev/null
+++ b/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs
@@ -0,0 +1,525 @@
+using FluentAssertions;
+using Sand.ChunkPrototype;
+using Sand.Core;
+
+namespace Sand.ChunkPrototype.Tests;
+
+public sealed class PrototypeSparseSandAdapterTests
+{
+ [Fact]
+ public void StepDownMovesParticlesIntoEmptySpace()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(3, 2);
+
+ var moves = adapter.StepDown();
+
+ moves.Should().Be(1);
+ adapter.HasParticle(3, 2).Should().BeFalse();
+ adapter.HasParticle(3, 3).Should().BeTrue();
+ adapter.World.IsOccupied(3, 3).Should().BeTrue();
+ adapter.LastStepStats.MovementOnlyFastPathCount.Should().Be(1);
+ adapter.LastStepStats.FullRuntimeStepCount.Should().Be(0);
+ adapter.LastStepStats.MoveAttempts.Should().BeGreaterThan(0);
+ adapter.LastStepStats.SuccessfulMoves.Should().Be(1);
+ }
+
+ [Fact]
+ public void StepDownStopsAtBottomBoundary()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(3, 15);
+
+ var moves = adapter.StepDown();
+
+ moves.Should().Be(0);
+ adapter.HasParticle(3, 15).Should().BeTrue();
+ }
+
+ [Fact]
+ public void WaterFlowsSidewaysWhenBlockedBelow()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 8, PrototypeParticleType.Water);
+ adapter.AddParticle(8, 9, PrototypeParticleType.Wall);
+ adapter.AddParticle(7, 9, PrototypeParticleType.Wall);
+ adapter.AddParticle(9, 9, PrototypeParticleType.Wall);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(1);
+ (adapter.GetParticleTypeAt(7, 8) == PrototypeParticleType.Water || adapter.GetParticleTypeAt(9, 8) == PrototypeParticleType.Water).Should().BeTrue();
+ }
+
+ [Fact]
+ public void SteamRisesWhenSpaceAboveIsEmpty()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 8, PrototypeParticleType.Steam);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(1);
+ adapter.GetParticleTypeAt(8, 7).Should().Be(PrototypeParticleType.Steam);
+ }
+
+ [Fact]
+ public void SteamCrossingHorizontalChunkBoundaryDoesNotPauseBehindUpperChunkGas()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(2, 3, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 4, PrototypeParticleType.Steam);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 4, PrototypeParticleType.Wall);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(2);
+ adapter.GetParticleTypeAt(2, 2).Should().Be(PrototypeParticleType.Steam);
+ adapter.GetParticleTypeAt(2, 3).Should().Be(PrototypeParticleType.Steam);
+ adapter.GetParticleTypeAt(2, 4).Should().Be(PrototypeParticleType.Empty);
+ }
+
+ [Fact]
+ public void DenseSteamColumnAcrossHorizontalChunkBoundaryDoesNotLeaveGapRow()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(2, 3, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 4, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 5, PrototypeParticleType.Steam);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 5, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 5, PrototypeParticleType.Wall);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(3);
+ adapter.GetParticleTypeAt(2, 2).Should().Be(PrototypeParticleType.Steam);
+ adapter.GetParticleTypeAt(2, 3).Should().Be(PrototypeParticleType.Steam);
+ adapter.GetParticleTypeAt(2, 4).Should().Be(PrototypeParticleType.Steam);
+ }
+
+ [Fact]
+ public void GasSeamFrameClearsOldBorderPixelAfterColumnCompaction()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(2, 3, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 4, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 5, PrototypeParticleType.Steam);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 5, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 5, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ var frame = adapter.BuildRgbaFrame().ToArray();
+ var clearedIndex = ((5 * 8) + 2) * 4;
+ frame[clearedIndex].Should().Be(0);
+ frame[clearedIndex + 1].Should().Be(0);
+ frame[clearedIndex + 2].Should().Be(0);
+ }
+
+ [Fact]
+ public void GasRenderFillsChunkRowSeamWhenCloudExistsAboveAndBelow()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(2, 2, PrototypeParticleType.Steam);
+ adapter.AddParticle(2, 4, PrototypeParticleType.Steam);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Steam);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Steam);
+
+ var frame = adapter.BuildRgbaFrame().ToArray();
+
+ var seamIndex = ((3 * 8) + 2) * 4;
+ frame[seamIndex].Should().Be(182);
+ frame[seamIndex + 1].Should().Be(196);
+ frame[seamIndex + 2].Should().Be(214);
+ }
+
+ [Fact]
+ public void WallRemainsStaticDuringStep()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 8, PrototypeParticleType.Wall);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(0);
+ adapter.GetParticleTypeAt(8, 8).Should().Be(PrototypeParticleType.Wall);
+ }
+
+ [Fact]
+ public void StepDownCrossesChunkBoundaryAndKeepsCountsConsistent()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(3, 3);
+ adapter.World.ClearDirtyChunks();
+
+ var moves = adapter.StepDown();
+
+ moves.Should().Be(1);
+ adapter.HasParticle(3, 4).Should().BeTrue();
+ adapter.World.GetChunkOccupancyCount(0, 0).Should().Be(0);
+ adapter.World.GetChunkOccupancyCount(0, 1).Should().Be(1);
+ adapter.World.DirtyChunks.Should().BeEquivalentTo([(0, 0), (0, 1)]);
+ adapter.LastStepStats.SuccessfulMoves.Should().Be(1);
+ adapter.LastStepStats.SwapAttempts.Should().Be(0);
+ adapter.LastStepStats.MoveAttempts.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void TrimResidencyUnloadsDistantEmptyChunksAfterMovement()
+ {
+ var adapter = new PrototypeSparseSandAdapter(64, 64, new ChunkResidencyConfig(4, 4, Capacity: 64));
+ adapter.AddParticle(3, 3);
+ adapter.AddParticle(40, 3);
+ adapter.RemoveParticle(40, 3);
+
+ var unloaded = adapter.TrimResidency(marginChunks: 0);
+
+ unloaded.Should().BeGreaterThanOrEqualTo(1);
+ adapter.World.IsChunkLoaded(10, 0).Should().BeFalse();
+ adapter.World.IsChunkLoaded(0, 0).Should().BeTrue();
+ }
+
+ [Fact]
+ public void BuildRgbaFrameIncludesDistinctColorsForParticleTypes()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8);
+ adapter.AddParticle(1, 1, PrototypeParticleType.Sand);
+ adapter.AddParticle(2, 1, PrototypeParticleType.Water);
+ adapter.AddParticle(3, 1, PrototypeParticleType.Steam);
+ adapter.AddParticle(4, 1, PrototypeParticleType.Wall);
+
+ var frame = adapter.BuildRgbaFrame().ToArray();
+
+ frame[((1 * 8) + 1) * 4].Should().Be(214);
+ frame[((1 * 8) + 2) * 4].Should().Be(72);
+ frame[((1 * 8) + 3) * 4].Should().Be(182);
+ frame[((1 * 8) + 4) * 4].Should().Be(96);
+ }
+
+ [Fact]
+ public void CustomParticleProfilePreservesColorAndTypeId()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8);
+ var particle = new PrototypeParticle(99, "custom_water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 12, 34, 56, 1.2f, 0.4f, 0.1f, 0.2f);
+
+ adapter.AddParticle(2, 3, particle).Should().BeTrue();
+
+ adapter.GetTypeIdAt(2, 3).Should().Be(99);
+ var frame = adapter.BuildRgbaFrame().ToArray();
+ var index = ((3 * 8) + 2) * 4;
+ frame[index].Should().Be(12);
+ frame[index + 1].Should().Be(34);
+ frame[index + 2].Should().Be(56);
+ }
+
+ [Fact]
+ public void WindBrushPushesGasSidewaysWhenUpwardPathIsBlocked()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 8, PrototypeParticleType.Steam);
+ adapter.AddParticle(8, 7, PrototypeParticleType.Wall);
+ adapter.AddParticle(7, 7, PrototypeParticleType.Wall);
+ adapter.AddParticle(9, 7, PrototypeParticleType.Wall);
+ adapter.ApplyWindBrush(8, 8, 3, 1f, 0f);
+ adapter.ApplyWindBrush(8, 8, 3, 1f, 0f);
+
+ var moves = adapter.Step();
+
+ moves.Should().Be(1);
+ adapter.GetParticleTypeAt(9, 8).Should().Be(PrototypeParticleType.Steam);
+ }
+
+ [Fact]
+ public void SolidContinuesSettlingAcrossIdleStepsWithoutExternalWorldChanges()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 6, PrototypeParticleType.Sand);
+ adapter.AddParticle(8, 7, PrototypeParticleType.Wall);
+
+ var moved = false;
+ for (var i = 0; i < 64; i++)
+ {
+ if (adapter.Step() > 0)
+ {
+ moved = true;
+ break;
+ }
+ }
+
+ moved.Should().BeTrue();
+ adapter.HasParticle(8, 6).Should().BeFalse();
+ }
+
+ [Fact]
+ public void WaterKeepsSurfaceDirectionInsteadOfImmediateBacktracking()
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(8, 8, PrototypeParticleType.Water);
+ adapter.AddParticle(7, 9, PrototypeParticleType.Wall);
+ adapter.AddParticle(8, 9, PrototypeParticleType.Wall);
+ adapter.AddParticle(9, 9, PrototypeParticleType.Wall);
+
+ adapter.Step().Should().Be(1);
+ for (var i = 0; i < 4; i++)
+ {
+ adapter.Step();
+ }
+
+ adapter.GetParticleTypeAt(8, 8).Should().Be(PrototypeParticleType.Empty);
+ }
+
+ [Fact]
+ public void WaterHydratesSandIntoWetSand()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var wetSand = new PrototypeParticle(101, "wet_sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 120, 96, 64, 1.6f, 0.45f, 0.24f, 0.18f);
+ var sand = new PrototypeParticle(100, "sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 214, 188, 96, 1.4f, 0.65f, 0.18f, 0.08f, HydrateTargetTypeId: 101);
+ var water = new PrototypeParticle(102, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike);
+ adapter.RegisterParticleProfile(wetSand);
+
+ adapter.AddParticle(2, 2, sand);
+ adapter.AddParticle(1, 2, water);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(2, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(0, 2, PrototypeParticleType.Wall);
+ adapter.AddParticle(0, 3, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ adapter.GetTypeIdAt(2, 2).Should().Be(101);
+ adapter.GetTypeIdAt(1, 2).Should().Be(0);
+ }
+
+ [Fact]
+ public void FullRuntimeParticleIncrementsFullRuntimeCounter()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var runtimeParticle = new PrototypeParticle(150, "runtime_sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 180, 150, 90, 1.2f, 0.4f, 0.15f, 0.05f, DefaultLifetime: 20f);
+ adapter.RegisterParticleProfile(runtimeParticle);
+ adapter.AddParticle(3, 2, runtimeParticle);
+
+ adapter.Step();
+
+ adapter.LastStepStats.FullRuntimeStepCount.Should().Be(1);
+ adapter.LastStepStats.MovementOnlyFastPathCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void StalledMovableCounterIncrementsWhenOpenPathExistsButSolidDoesNotSlip()
+ {
+ var foundStall = false;
+ for (var x = 2; x < 14 && !foundStall; x++)
+ {
+ var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(x, 6, PrototypeParticleType.Sand);
+ adapter.AddParticle(x, 7, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ if (adapter.LastStepStats.StalledMovableCells > 0)
+ {
+ foundStall = true;
+ }
+ }
+
+ foundStall.Should().BeTrue();
+ }
+
+ [Fact]
+ public void LavaAndWaterReactIntoStoneAndSteam()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var stone = new PrototypeParticle(201, "stone", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 96, 96, 96, 1.8f, 0.25f, 0.5f, 0.4f, IsStatic: true);
+ var steam = new PrototypeParticle(202, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 203, PressureThreshold: 1.2f, InitialTemperature: 110f);
+ var water = new PrototypeParticle(203, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike, EvaporateTypeId: 202);
+ var lava = new PrototypeParticle(204, "lava", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 255, 96, 24, 2f, 0.35f, 0.18f, 0.45f, Flags: PrototypeParticleFlags.HotSource, IsMolten: true, SolidifyTypeId: 201);
+ adapter.RegisterParticleProfile(stone);
+ adapter.RegisterParticleProfile(steam);
+
+ adapter.AddParticle(2, 2, lava);
+ adapter.AddParticle(3, 2, water);
+ adapter.AddParticle(1, 2, PrototypeParticleType.Wall);
+ adapter.AddParticle(4, 2, PrototypeParticleType.Wall);
+ adapter.AddParticle(2, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(4, 3, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ adapter.GetTypeIdAt(2, 2).Should().Be(201);
+ adapter.GetTypeIdAt(3, 2).Should().Be(202);
+ }
+
+ [Fact]
+ public void HotNeighborEvaporatesWaterIntoSteam()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var steam = new PrototypeParticle(301, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, InitialTemperature: 110f);
+ var water = new PrototypeParticle(302, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike, EvaporateTypeId: 301);
+ var plasma = new PrototypeParticle(303, "plasma", PrototypeParticleType.Wall, ParticleKind.Solid, ParticleBehaviorKind.Plasma, 255, 64, 180, 10f, 0f, 1f, 1f, IsStatic: true, Flags: PrototypeParticleFlags.FireLike | PrototypeParticleFlags.HotSource);
+ adapter.RegisterParticleProfile(steam);
+
+ adapter.AddParticle(2, 2, water);
+ adapter.AddParticle(3, 2, plasma);
+ adapter.AddParticle(1, 2, PrototypeParticleType.Wall);
+ adapter.AddParticle(2, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(1, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ (adapter.GetTypeIdAt(2, 2) == 301 || adapter.GetTypeIdAt(2, 1) == 301).Should().BeTrue();
+ }
+
+ [Fact]
+ public void PressureCanBreakStaticParticleIntoConfiguredTarget()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var rubble = new PrototypeParticle(401, "rubble", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 160, 140, 120, 1.2f, 0.4f, 0.22f, 0.2f);
+ var brittle = new PrototypeParticle(402, "brittle", PrototypeParticleType.Wall, ParticleKind.Solid, ParticleBehaviorKind.None, 210, 210, 210, 5f, 0f, 1f, 1f, IsStatic: true, BrokenTypeId: 401, PressureThreshold: 0.35f, PressureThresholdDuration: 1);
+ adapter.RegisterParticleProfile(rubble);
+
+ adapter.AddParticle(4, 4, brittle);
+ adapter.ApplyAirBrush(4, 4, 2, 0f, 12f);
+ adapter.ApplyAirBrush(4, 4, 2, 0f, 12f);
+
+ adapter.Step();
+
+ adapter.GetTypeIdAt(4, 4).Should().Be(401);
+ }
+
+ [Fact]
+ public void PressurizedSteamCanCondenseIntoWater()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var water = new PrototypeParticle(501, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike);
+ var steam = new PrototypeParticle(502, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 501, PressureThreshold: 0.6f, InitialTemperature: 110f);
+ adapter.RegisterParticleProfile(water);
+
+ adapter.AddParticle(4, 4, steam);
+ adapter.AddParticle(4, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(5, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(5, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(4, 5, PrototypeParticleType.Wall);
+ adapter.ApplyAirBrush(4, 4, 1, 0f, 4f);
+
+ adapter.Step();
+
+ adapter.GetTypeIdAt(4, 4).Should().Be(501);
+ }
+
+ [Fact]
+ public void BlockedGasRiseDoesNotKeepRetryingVerticalProbeWhenCeilingIsUnchanged()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(4, 4, PrototypeParticleType.Steam);
+ adapter.AddParticle(4, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(5, 3, PrototypeParticleType.Wall);
+ adapter.AddParticle(3, 4, PrototypeParticleType.Wall);
+ adapter.AddParticle(5, 4, PrototypeParticleType.Wall);
+
+ adapter.Step();
+ adapter.Step();
+
+ adapter.LastStepStats.VerticalMoveAttempts.Should().Be(0);
+ }
+
+ [Fact]
+ public void StableHotGasCanSkipFullRuntimeOnSomeSteps()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var hotGas = new PrototypeParticle(550, "hot_gas", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 220, 180, 220, 0.25f, 0.45f, 0.02f, 0.03f, InitialTemperature: 1000f, Conductivity: 1f);
+ adapter.RegisterParticleProfile(hotGas);
+
+ adapter.AddParticle(2, 5, hotGas);
+ adapter.AddParticle(3, 5, hotGas);
+ adapter.AddParticle(2, 6, hotGas);
+ adapter.AddParticle(3, 6, hotGas);
+
+ var observedReducedRuntime = false;
+ for (var i = 0; i < 4; i++)
+ {
+ adapter.Step();
+ if (adapter.LastStepStats.FullRuntimeGasCount < 4)
+ {
+ observedReducedRuntime = true;
+ break;
+ }
+ }
+
+ observedReducedRuntime.Should().BeTrue();
+ }
+
+ [Fact]
+ public void StableHotGasAdjacentToWallCanStillSkipFullRuntime()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var hotGas = new PrototypeParticle(551, "hot_wall_gas", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 220, 180, 220, 0.25f, 0.45f, 0.02f, 0.03f, InitialTemperature: 1000f, Conductivity: 1f);
+ adapter.RegisterParticleProfile(hotGas);
+
+ adapter.AddParticle(3, 5, hotGas);
+ adapter.AddParticle(4, 5, hotGas);
+ adapter.AddParticle(2, 5, PrototypeParticleType.Wall);
+ adapter.AddParticle(5, 5, PrototypeParticleType.Wall);
+
+ var observedReducedRuntime = false;
+ for (var i = 0; i < 4; i++)
+ {
+ adapter.Step();
+ if (adapter.LastStepStats.FullRuntimeGasCount < 2)
+ {
+ observedReducedRuntime = true;
+ break;
+ }
+ }
+
+ observedReducedRuntime.Should().BeTrue();
+ }
+
+ [Fact]
+ public void AmbientSolidDoesNotInstantlyMeltJustBecauseItHasMeltTarget()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ var molten = new PrototypeParticle(601, "molten_test", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 255, 96, 24, 2f, 0.35f, 0.18f, 0.45f, IsMolten: true, InitialTemperature: 140f);
+ var solid = new PrototypeParticle(602, "solid_test", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 140, 140, 140, 1.8f, 0.25f, 0.45f, 0.35f, MeltTypeId: 601, MeltTemperature: 900f, InitialTemperature: 22f);
+ adapter.RegisterParticleProfile(molten);
+
+ adapter.AddParticle(4, 4, solid);
+ adapter.AddParticle(4, 5, PrototypeParticleType.Wall);
+
+ adapter.Step();
+
+ adapter.GetTypeIdAt(4, 4).Should().Be(602);
+ }
+
+ [Fact]
+ public void SteamContinuesRisingAcrossIdleStepsWithoutExternalWake()
+ {
+ var adapter = new PrototypeSparseSandAdapter(8, 12, new ChunkResidencyConfig(4, 4, Capacity: 16));
+ adapter.AddParticle(4, 9, PrototypeParticleType.Steam);
+
+ for (var i = 0; i < 4; i++)
+ {
+ adapter.Step();
+ }
+
+ adapter.ParticleEntries.Should().Contain(entry =>
+ entry.Value.MotionType == PrototypeParticleType.Steam &&
+ entry.Key.Y <= 6);
+ }
+}
diff --git a/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj b/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj
new file mode 100644
index 0000000..ce77982
--- /dev/null
+++ b/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sand.ChunkPrototype/ChunkActivityTracker.cs b/Sand.ChunkPrototype/ChunkActivityTracker.cs
new file mode 100644
index 0000000..068ca20
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkActivityTracker.cs
@@ -0,0 +1,31 @@
+namespace Sand.ChunkPrototype;
+
+internal sealed class ChunkActivityTracker
+{
+ private readonly List _activeChunks = new();
+ private readonly List _sleepingChunks = new();
+
+ public (IReadOnlyList ActiveChunks, int SleepingChunks) Build(IReadOnlyDictionary pages)
+ {
+ _activeChunks.Clear();
+ _sleepingChunks.Clear();
+ foreach (var (coord, page) in pages)
+ {
+ if (page.IsActive || page.HasFieldActivity || page.PendingActiveSteps > 0)
+ {
+ _activeChunks.Add(coord);
+ }
+ else
+ {
+ _sleepingChunks.Add(coord);
+ }
+ }
+
+ _activeChunks.Sort(static (left, right) =>
+ {
+ var yCompare = right.Y.CompareTo(left.Y);
+ return yCompare != 0 ? yCompare : left.X.CompareTo(right.X);
+ });
+ return (_activeChunks, _sleepingChunks.Count);
+ }
+}
diff --git a/Sand.ChunkPrototype/ChunkCellPage.cs b/Sand.ChunkPrototype/ChunkCellPage.cs
new file mode 100644
index 0000000..9507d8b
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkCellPage.cs
@@ -0,0 +1,346 @@
+namespace Sand.ChunkPrototype;
+
+internal sealed class ChunkCellPage
+{
+ private readonly PrototypeParticle[] _particles;
+ private readonly int[] _processedStep;
+ private readonly int[] _occupiedRowCounts;
+ private readonly int[] _gasRowCounts;
+ private readonly int[] _rowRevision;
+ private readonly int[] _blockedSolidSignature;
+ private readonly int[] _blockedGasRiseSignature;
+ private readonly int[] _gasRetrySignature;
+ private readonly int[] _gasRetryUntilStep;
+ private readonly int[] _gasRowCooldownSignature;
+ private readonly int[] _gasRowCooldownUntilStep;
+ private readonly sbyte[] _driftState;
+ private readonly float[] _pressureDuration;
+ private readonly float[] _temperature;
+ private readonly float[] _burnTime;
+ private readonly byte[] _burning;
+ private readonly short[] _sparkTime;
+ private readonly float[] _lifetime;
+ private readonly float[] _cellAge;
+ private readonly float[] _integrity;
+
+ public ChunkCellPage(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ _particles = new PrototypeParticle[width * height];
+ _processedStep = new int[width * height];
+ _occupiedRowCounts = new int[height];
+ _gasRowCounts = new int[height];
+ _rowRevision = new int[height];
+ _blockedSolidSignature = new int[width * height];
+ _blockedGasRiseSignature = new int[width * height];
+ _gasRetrySignature = new int[width * height];
+ _gasRetryUntilStep = new int[width * height];
+ _gasRowCooldownSignature = new int[height];
+ _gasRowCooldownUntilStep = new int[height];
+ _driftState = new sbyte[width * height];
+ _pressureDuration = new float[width * height];
+ _temperature = new float[width * height];
+ _burnTime = new float[width * height];
+ _burning = new byte[width * height];
+ _sparkTime = new short[width * height];
+ _lifetime = new float[width * height];
+ _cellAge = new float[width * height];
+ _integrity = new float[width * height];
+ ClearDirtyBands();
+ ClearVisualDirty();
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public int OccupancyCount { get; private set; }
+ public int GasCellCount { get; private set; }
+ public int DynamicCellCount { get; private set; }
+ public int RuntimeCellCount { get; private set; }
+ public int PendingActiveSteps { get; private set; }
+ public bool IsActive { get; set; } = true;
+ public bool HasFieldActivity { get; set; }
+ public int LastTouchedFrame { get; set; }
+ public int DirtyMinRow { get; private set; }
+ public int DirtyMaxRow { get; private set; }
+ public int DirtyMinCol { get; private set; }
+ public int DirtyMaxCol { get; private set; }
+ public bool HasDirtyBands { get; private set; }
+ public int VisualDirtyMinRow { get; private set; }
+ public int VisualDirtyMaxRow { get; private set; }
+ public int VisualDirtyMinCol { get; private set; }
+ public int VisualDirtyMaxCol { get; private set; }
+ public bool HasVisualDirty { get; private set; }
+
+ public PrototypeParticle this[int localX, int localY]
+ {
+ get => _particles[GetIndex(localX, localY)];
+ set => _particles[GetIndex(localX, localY)] = value;
+ }
+
+ public bool IsProcessed(int localX, int localY, int stepToken) => _processedStep[GetIndex(localX, localY)] == stepToken;
+
+ public void MarkProcessed(int localX, int localY, int stepToken) => _processedStep[GetIndex(localX, localY)] = stepToken;
+
+ public bool IsOccupied(int localX, int localY) => _particles[GetIndex(localX, localY)].TypeId != 0;
+
+ public int GetOccupiedRowCount(int localY) => _occupiedRowCounts[localY];
+
+ public int GetGasRowCount(int localY) => _gasRowCounts[localY];
+
+ public int GetRowRevision(int localY) => _rowRevision[localY];
+
+ public int GetBlockedSolidSignature(int localX, int localY) => _blockedSolidSignature[GetIndex(localX, localY)];
+
+ public void SetBlockedSolidSignature(int localX, int localY, int signature) => _blockedSolidSignature[GetIndex(localX, localY)] = signature;
+
+ public int GetBlockedGasRiseSignature(int localX, int localY) => _blockedGasRiseSignature[GetIndex(localX, localY)];
+
+ public void SetBlockedGasRiseSignature(int localX, int localY, int signature) => _blockedGasRiseSignature[GetIndex(localX, localY)] = signature;
+
+ public int GetGasRetrySignature(int localX, int localY) => _gasRetrySignature[GetIndex(localX, localY)];
+
+ public int GetGasRetryUntilStep(int localX, int localY) => _gasRetryUntilStep[GetIndex(localX, localY)];
+
+ public void SetGasRetryState(int localX, int localY, int signature, int untilStep)
+ {
+ var index = GetIndex(localX, localY);
+ _gasRetrySignature[index] = signature;
+ _gasRetryUntilStep[index] = untilStep;
+ }
+
+ public void ClearGasRetryState(int localX, int localY)
+ {
+ var index = GetIndex(localX, localY);
+ _gasRetrySignature[index] = 0;
+ _gasRetryUntilStep[index] = 0;
+ }
+
+ public int GetGasRowCooldownSignature(int localY) => _gasRowCooldownSignature[localY];
+
+ public int GetGasRowCooldownUntilStep(int localY) => _gasRowCooldownUntilStep[localY];
+
+ public void SetGasRowCooldown(int localY, int signature, int untilStep)
+ {
+ _gasRowCooldownSignature[localY] = signature;
+ _gasRowCooldownUntilStep[localY] = untilStep;
+ }
+
+ public void ClearGasRowCooldown(int localY)
+ {
+ _gasRowCooldownSignature[localY] = 0;
+ _gasRowCooldownUntilStep[localY] = 0;
+ }
+
+ public sbyte GetDriftState(int localX, int localY) => _driftState[GetIndex(localX, localY)];
+
+ public void SetDriftState(int localX, int localY, sbyte driftState) => _driftState[GetIndex(localX, localY)] = driftState;
+
+ public float GetPressureDuration(int localX, int localY) => _pressureDuration[GetIndex(localX, localY)];
+
+ public void SetPressureDuration(int localX, int localY, float value) => _pressureDuration[GetIndex(localX, localY)] = value;
+
+ public float GetTemperature(int localX, int localY) => _temperature[GetIndex(localX, localY)];
+
+ public void SetTemperature(int localX, int localY, float value) => _temperature[GetIndex(localX, localY)] = value;
+
+ public float GetBurnTime(int localX, int localY) => _burnTime[GetIndex(localX, localY)];
+
+ public void SetBurnTime(int localX, int localY, float value) => _burnTime[GetIndex(localX, localY)] = value;
+
+ public byte GetBurning(int localX, int localY) => _burning[GetIndex(localX, localY)];
+
+ public void SetBurning(int localX, int localY, byte value) => _burning[GetIndex(localX, localY)] = value;
+
+ public short GetSparkTime(int localX, int localY) => _sparkTime[GetIndex(localX, localY)];
+
+ public void SetSparkTime(int localX, int localY, short value) => _sparkTime[GetIndex(localX, localY)] = value;
+
+ public float GetLifetime(int localX, int localY) => _lifetime[GetIndex(localX, localY)];
+
+ public void SetLifetime(int localX, int localY, float value) => _lifetime[GetIndex(localX, localY)] = value;
+
+ public float GetCellAge(int localX, int localY) => _cellAge[GetIndex(localX, localY)];
+
+ public void SetCellAge(int localX, int localY, float value) => _cellAge[GetIndex(localX, localY)] = value;
+
+ public float GetIntegrity(int localX, int localY) => _integrity[GetIndex(localX, localY)];
+
+ public void SetIntegrity(int localX, int localY, float value) => _integrity[GetIndex(localX, localY)] = value;
+
+ public void SetCell(int localX, int localY, PrototypeParticle particle)
+ {
+ var index = GetIndex(localX, localY);
+ var previous = _particles[index];
+ if (previous.TypeId == 0 && particle.TypeId != 0)
+ {
+ OccupancyCount++;
+ _occupiedRowCounts[localY]++;
+ _rowRevision[localY]++;
+ }
+ else if (previous.TypeId != 0 && particle.TypeId == 0)
+ {
+ OccupancyCount--;
+ _occupiedRowCounts[localY]--;
+ _rowRevision[localY]++;
+ }
+ else if (previous.TypeId != particle.TypeId)
+ {
+ _rowRevision[localY]++;
+ }
+
+ if (previous.MotionType == PrototypeParticleType.Steam)
+ {
+ GasCellCount--;
+ _gasRowCounts[localY]--;
+ }
+
+ if (previous.TypeId != 0 && !previous.IsStatic)
+ {
+ DynamicCellCount--;
+ }
+
+ if (previous.TypeId != 0 && previous.RequiresFullRuntimeStep)
+ {
+ RuntimeCellCount--;
+ }
+
+ _particles[index] = particle;
+ if (particle.MotionType == PrototypeParticleType.Steam)
+ {
+ GasCellCount++;
+ _gasRowCounts[localY]++;
+ }
+
+ if (particle.TypeId != 0 && !particle.IsStatic)
+ {
+ DynamicCellCount++;
+ }
+
+ if (particle.TypeId != 0 && particle.RequiresFullRuntimeStep)
+ {
+ RuntimeCellCount++;
+ }
+
+ if (particle.TypeId == 0)
+ {
+ _driftState[index] = 0;
+ _pressureDuration[index] = 0f;
+ _temperature[index] = 0f;
+ _burnTime[index] = 0f;
+ _burning[index] = 0;
+ _sparkTime[index] = 0;
+ _lifetime[index] = 0f;
+ _cellAge[index] = 0f;
+ _integrity[index] = 0f;
+ }
+
+ _blockedSolidSignature[index] = 0;
+ _blockedGasRiseSignature[index] = 0;
+ _gasRetrySignature[index] = 0;
+ _gasRetryUntilStep[index] = 0;
+ _gasRowCooldownSignature[localY] = 0;
+ _gasRowCooldownUntilStep[localY] = 0;
+ MarkTouched(localX, localY);
+ }
+
+ public void MarkTouched(int localX, int localY)
+ {
+ MarkDirtyBand(localX, localY);
+ MarkVisualDirty(localX, localY);
+ IsActive = true;
+ PendingActiveSteps = Math.Max(PendingActiveSteps, 1);
+ }
+
+ public void RequestFutureSteps(int steps)
+ {
+ if (steps > PendingActiveSteps)
+ {
+ PendingActiveSteps = steps;
+ }
+ }
+
+ public void DecayPendingSteps()
+ {
+ if (PendingActiveSteps > 0)
+ {
+ PendingActiveSteps--;
+ }
+ }
+
+ public void MarkDirtyBand(int localX, int localY)
+ {
+ if (!HasDirtyBands)
+ {
+ DirtyMinRow = DirtyMaxRow = localY;
+ DirtyMinCol = DirtyMaxCol = localX;
+ HasDirtyBands = true;
+ return;
+ }
+
+ DirtyMinRow = Math.Min(DirtyMinRow, localY);
+ DirtyMaxRow = Math.Max(DirtyMaxRow, localY);
+ DirtyMinCol = Math.Min(DirtyMinCol, localX);
+ DirtyMaxCol = Math.Max(DirtyMaxCol, localX);
+ }
+
+ public void MarkVisualDirty(int localX, int localY)
+ {
+ if (!HasVisualDirty)
+ {
+ VisualDirtyMinRow = VisualDirtyMaxRow = localY;
+ VisualDirtyMinCol = VisualDirtyMaxCol = localX;
+ HasVisualDirty = true;
+ return;
+ }
+
+ VisualDirtyMinRow = Math.Min(VisualDirtyMinRow, localY);
+ VisualDirtyMaxRow = Math.Max(VisualDirtyMaxRow, localY);
+ VisualDirtyMinCol = Math.Min(VisualDirtyMinCol, localX);
+ VisualDirtyMaxCol = Math.Max(VisualDirtyMaxCol, localX);
+ }
+
+ public void MarkFullVisualDirty()
+ {
+ HasVisualDirty = true;
+ VisualDirtyMinRow = 0;
+ VisualDirtyMaxRow = Height - 1;
+ VisualDirtyMinCol = 0;
+ VisualDirtyMaxCol = Width - 1;
+ }
+
+ public void ClearDirtyBands()
+ {
+ HasDirtyBands = false;
+ DirtyMinRow = Height;
+ DirtyMaxRow = -1;
+ DirtyMinCol = Width;
+ DirtyMaxCol = -1;
+ }
+
+ public void ClearVisualDirty()
+ {
+ HasVisualDirty = false;
+ VisualDirtyMinRow = Height;
+ VisualDirtyMaxRow = -1;
+ VisualDirtyMinCol = Width;
+ VisualDirtyMaxCol = -1;
+ }
+
+ public IEnumerable<(int LocalX, int LocalY, PrototypeParticle Particle)> EnumerateOccupied()
+ {
+ for (var y = 0; y < Height; y++)
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ var particle = _particles[GetIndex(x, y)];
+ if (particle.TypeId != 0)
+ {
+ yield return (x, y, particle);
+ }
+ }
+ }
+ }
+
+ private int GetIndex(int localX, int localY) => (localY * Width) + localX;
+}
diff --git a/Sand.ChunkPrototype/ChunkCoord.cs b/Sand.ChunkPrototype/ChunkCoord.cs
new file mode 100644
index 0000000..1582b0b
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkCoord.cs
@@ -0,0 +1,3 @@
+namespace Sand.ChunkPrototype;
+
+internal readonly record struct ChunkCoord(int X, int Y);
diff --git a/Sand.ChunkPrototype/ChunkFieldPage.cs b/Sand.ChunkPrototype/ChunkFieldPage.cs
new file mode 100644
index 0000000..283a1be
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkFieldPage.cs
@@ -0,0 +1,96 @@
+namespace Sand.ChunkPrototype;
+
+internal sealed class ChunkFieldPage
+{
+ public ChunkFieldPage(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ WindX = new float[width * height];
+ WindY = new float[width * height];
+ ForceX = new float[width * height];
+ ForceY = new float[width * height];
+ Pressure = new float[width * height];
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public float[] WindX { get; }
+ public float[] WindY { get; }
+ public float[] ForceX { get; }
+ public float[] ForceY { get; }
+ public float[] Pressure { get; }
+ public int ActiveCellCount { get; private set; }
+ public int LastDecayedFrame { get; set; }
+
+ public bool TryGetCell(int localX, int localY, out FieldCellData cell)
+ {
+ var index = GetIndex(localX, localY);
+ cell = new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index]);
+ return !cell.IsEffectivelyZero;
+ }
+
+ public FieldCellData GetCell(int localX, int localY)
+ {
+ var index = GetIndex(localX, localY);
+ return new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index]);
+ }
+
+ public void SetCell(int localX, int localY, FieldCellData value)
+ {
+ var index = GetIndex(localX, localY);
+ var wasZero = IsEffectivelyZero(index);
+ var nowZero = value.IsEffectivelyZero;
+ WindX[index] = value.WindX;
+ WindY[index] = value.WindY;
+ ForceX[index] = value.ForceX;
+ ForceY[index] = value.ForceY;
+ Pressure[index] = value.Pressure;
+ if (wasZero && !nowZero)
+ {
+ ActiveCellCount++;
+ }
+ else if (!wasZero && nowZero)
+ {
+ ActiveCellCount--;
+ }
+ }
+
+ public bool IsEmpty() => ActiveCellCount == 0;
+
+ public IEnumerable<(int LocalX, int LocalY, FieldCellData Cell)> EnumerateActiveCells()
+ {
+ for (var y = 0; y < Height; y++)
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ var index = GetIndex(x, y);
+ if (IsEffectivelyZero(index))
+ {
+ continue;
+ }
+
+ yield return (x, y, new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index]));
+ }
+ }
+ }
+
+ private bool IsEffectivelyZero(int index) =>
+ MathF.Abs(WindX[index]) < 0.01f &&
+ MathF.Abs(WindY[index]) < 0.01f &&
+ MathF.Abs(ForceX[index]) < 0.01f &&
+ MathF.Abs(ForceY[index]) < 0.01f &&
+ MathF.Abs(Pressure[index]) < 0.01f;
+
+ private int GetIndex(int localX, int localY) => (localY * Width) + localX;
+}
+
+internal readonly record struct FieldCellData(float WindX, float WindY, float ForceX, float ForceY, float Pressure)
+{
+ public bool IsEffectivelyZero =>
+ MathF.Abs(WindX) < 0.01f &&
+ MathF.Abs(WindY) < 0.01f &&
+ MathF.Abs(ForceX) < 0.01f &&
+ MathF.Abs(ForceY) < 0.01f &&
+ MathF.Abs(Pressure) < 0.01f;
+}
diff --git a/Sand.ChunkPrototype/ChunkResidencyConfig.cs b/Sand.ChunkPrototype/ChunkResidencyConfig.cs
new file mode 100644
index 0000000..311f0b0
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkResidencyConfig.cs
@@ -0,0 +1,9 @@
+namespace Sand.ChunkPrototype;
+
+public sealed record ChunkResidencyConfig(
+ int ChunkWidth,
+ int ChunkHeight,
+ int Capacity = 4096)
+{
+ public static ChunkResidencyConfig Default { get; } = new(32, 32);
+}
diff --git a/Sand.ChunkPrototype/ChunkStepScheduler.cs b/Sand.ChunkPrototype/ChunkStepScheduler.cs
new file mode 100644
index 0000000..a16870a
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkStepScheduler.cs
@@ -0,0 +1,9 @@
+namespace Sand.ChunkPrototype;
+
+internal sealed class ChunkStepScheduler
+{
+ private readonly ChunkActivityTracker _activityTracker = new();
+
+ public (IReadOnlyList ActiveChunks, int SleepingChunks) BuildSchedule(IReadOnlyDictionary pages) =>
+ _activityTracker.Build(pages);
+}
diff --git a/Sand.ChunkPrototype/ChunkStepStats.cs b/Sand.ChunkPrototype/ChunkStepStats.cs
new file mode 100644
index 0000000..c606fd3
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkStepStats.cs
@@ -0,0 +1,27 @@
+namespace Sand.ChunkPrototype;
+
+public readonly record struct ChunkStepStats(
+ int SteppedChunks,
+ int SleepingChunks,
+ int FieldPages,
+ int MoveAttempts,
+ int VerticalMoveAttempts,
+ int DiagonalMoveAttempts,
+ int LateralMoveAttempts,
+ int SuccessfulMoves,
+ int SwapAttempts,
+ int StalledMovableCells,
+ int MovementOnlyFastPathCount,
+ int FullRuntimeStepCount,
+ int FullRuntimeSolidCount,
+ int FullRuntimeLiquidCount,
+ int FullRuntimeGasCount,
+ int MovedParticles,
+ int SwappedParticles,
+ int VisualDirtyPages,
+ long FrameBuildBytesTouched,
+ long ActivationTimeMicroseconds,
+ long MovementTimeMicroseconds,
+ long RuntimeTimeMicroseconds,
+ long FieldDecayTimeMicroseconds,
+ long RenderTimeMicroseconds);
diff --git a/Sand.ChunkPrototype/ChunkVisualTracker.cs b/Sand.ChunkPrototype/ChunkVisualTracker.cs
new file mode 100644
index 0000000..18694b6
--- /dev/null
+++ b/Sand.ChunkPrototype/ChunkVisualTracker.cs
@@ -0,0 +1,160 @@
+namespace Sand.ChunkPrototype;
+
+internal static class ChunkVisualTracker
+{
+ public static long RenderDirtyPages(
+ byte[] destination,
+ int worldWidth,
+ IReadOnlyDictionary cellPages,
+ IReadOnlyDictionary fieldPages,
+ bool enableWindVisuals,
+ bool enablePressureVisuals)
+ {
+ long bytesTouched = 0;
+ foreach (var (coord, page) in cellPages)
+ {
+ if (!page.HasVisualDirty)
+ {
+ continue;
+ }
+
+ bytesTouched += RenderPage(destination, worldWidth, coord, page, fieldPages.GetValueOrDefault(coord), enableWindVisuals, enablePressureVisuals);
+ page.ClearVisualDirty();
+ }
+
+ foreach (var (coord, fieldPage) in fieldPages)
+ {
+ if (cellPages.ContainsKey(coord))
+ {
+ continue;
+ }
+
+ bytesTouched += RenderFieldOnlyPage(destination, worldWidth, coord, fieldPage, enableWindVisuals, enablePressureVisuals);
+ }
+
+ return bytesTouched;
+ }
+
+ private static long RenderPage(
+ byte[] destination,
+ int worldWidth,
+ ChunkCoord coord,
+ ChunkCellPage page,
+ ChunkFieldPage? fieldPage,
+ bool enableWindVisuals,
+ bool enablePressureVisuals)
+ {
+ long bytesTouched = 0;
+ for (var localY = page.VisualDirtyMinRow; localY <= page.VisualDirtyMaxRow; localY++)
+ {
+ for (var localX = page.VisualDirtyMinCol; localX <= page.VisualDirtyMaxCol; localX++)
+ {
+ var particle = page[localX, localY];
+ var worldX = (coord.X * page.Width) + localX;
+ var worldY = (coord.Y * page.Height) + localY;
+ var rgbaIndex = ((worldY * worldWidth) + worldX) * 4;
+ if (particle.TypeId != 0)
+ {
+ destination[rgbaIndex] = particle.R;
+ destination[rgbaIndex + 1] = particle.G;
+ destination[rgbaIndex + 2] = particle.B;
+ destination[rgbaIndex + 3] = 255;
+ bytesTouched += 4;
+ continue;
+ }
+
+ var color = ResolveFieldColor(fieldPage, localX, localY, enableWindVisuals, enablePressureVisuals);
+ destination[rgbaIndex] = color.R;
+ destination[rgbaIndex + 1] = color.G;
+ destination[rgbaIndex + 2] = color.B;
+ destination[rgbaIndex + 3] = 255;
+ bytesTouched += 4;
+ }
+ }
+
+ return bytesTouched;
+ }
+
+ private static long RenderFieldOnlyPage(
+ byte[] destination,
+ int worldWidth,
+ ChunkCoord coord,
+ ChunkFieldPage page,
+ bool enableWindVisuals,
+ bool enablePressureVisuals)
+ {
+ if (!enableWindVisuals && !enablePressureVisuals)
+ {
+ return 0;
+ }
+
+ long bytesTouched = 0;
+ foreach (var (localX, localY, cell) in page.EnumerateActiveCells())
+ {
+ var color = ResolveFieldColor(cell, enableWindVisuals, enablePressureVisuals);
+ var worldX = (coord.X * page.Width) + localX;
+ var worldY = (coord.Y * page.Height) + localY;
+ var rgbaIndex = ((worldY * worldWidth) + worldX) * 4;
+ destination[rgbaIndex] = color.R;
+ destination[rgbaIndex + 1] = color.G;
+ destination[rgbaIndex + 2] = color.B;
+ destination[rgbaIndex + 3] = 255;
+ bytesTouched += 4;
+ }
+
+ return bytesTouched;
+ }
+
+ private static (byte R, byte G, byte B) ResolveFieldColor(ChunkFieldPage? fieldPage, int localX, int localY, bool enableWindVisuals, bool enablePressureVisuals)
+ {
+ if (fieldPage is null || !fieldPage.TryGetCell(localX, localY, out var cell))
+ {
+ return (0, 0, 0);
+ }
+
+ return ResolveFieldColor(cell, enableWindVisuals, enablePressureVisuals);
+ }
+
+ private static (byte R, byte G, byte B) ResolveFieldColor(FieldCellData cell, bool enableWindVisuals, bool enablePressureVisuals)
+ {
+ byte r = 0;
+ byte g = 0;
+ byte b = 0;
+ if (enablePressureVisuals)
+ {
+ var pressure = MathF.Abs(cell.Pressure);
+ if (pressure > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140);
+ if (cell.Pressure >= 0f)
+ {
+ r = (byte)Math.Max((int)r, 26 + tint);
+ g = (byte)Math.Max((int)g, 18 + (tint / 3));
+ b = (byte)Math.Max((int)b, 24);
+ }
+ else
+ {
+ r = (byte)Math.Max((int)r, 18);
+ g = (byte)Math.Max((int)g, 24 + (tint / 4));
+ b = (byte)Math.Max((int)b, 30 + tint);
+ }
+ }
+ }
+
+ if (enableWindVisuals)
+ {
+ var totalFieldX = cell.WindX + cell.ForceX;
+ var totalFieldY = cell.WindY + cell.ForceY;
+ var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY));
+ if (windStrength > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120);
+ r = (byte)Math.Max((int)r, 16 + (tint / 3));
+ g = (byte)Math.Max((int)g, 24 + (tint / 2));
+ b = (byte)Math.Max((int)b, 32 + tint);
+ }
+ }
+
+ return (r, g, b);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs b/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs
new file mode 100644
index 0000000..b66fa8e
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs
@@ -0,0 +1,254 @@
+using AdvChkSys.Chunk;
+using AdvChkSys.Manager;
+
+namespace Sand.ChunkPrototype;
+
+public sealed class PrototypeChunkResidencyWorld
+{
+ private readonly ChunkManager2D _manager;
+ private readonly Dictionary<(int ChunkX, int ChunkY), int> _occupancyCounts = new();
+ private readonly HashSet<(int ChunkX, int ChunkY)> _dirtyChunks = new();
+ private readonly ChunkResidencyConfig _config;
+
+ public PrototypeChunkResidencyWorld(ChunkResidencyConfig? config = null)
+ {
+ _config = config ?? ChunkResidencyConfig.Default;
+ _manager = new ChunkManager2D(
+ capacity: _config.Capacity,
+ chunkWidth: _config.ChunkWidth,
+ chunkHeight: _config.ChunkHeight);
+ }
+
+ public int LoadedChunkCount => _manager.GetAllChunks().Count();
+ public int ChunkWidth => _config.ChunkWidth;
+ public int ChunkHeight => _config.ChunkHeight;
+
+ public int ActiveChunkCount => _occupancyCounts.Count;
+
+ public int DirtyChunkCount => _dirtyChunks.Count;
+
+ public long EstimatedLoadedBytes => (long)LoadedChunkCount * _config.ChunkWidth * _config.ChunkHeight * sizeof(byte);
+
+ public IReadOnlyCollection<(int ChunkX, int ChunkY)> ActiveChunks => _occupancyCounts.Keys.ToArray();
+
+ public IReadOnlyCollection<(int ChunkX, int ChunkY)> DirtyChunks => _dirtyChunks.ToArray();
+
+ public void SetOccupied(int cellX, int cellY, byte value = 1)
+ {
+ var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out var key);
+ if (chunk[localX, localY] == 0)
+ {
+ _occupancyCounts[key] = _occupancyCounts.TryGetValue(key, out var count) ? count + 1 : 1;
+ }
+
+ chunk[localX, localY] = value;
+ _dirtyChunks.Add(key);
+ }
+
+ public void ClearOccupied(int cellX, int cellY)
+ {
+ var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out var key);
+ if (chunk[localX, localY] == 0)
+ {
+ return;
+ }
+
+ chunk[localX, localY] = 0;
+ if (!_occupancyCounts.TryGetValue(key, out var count))
+ {
+ return;
+ }
+
+ if (count <= 1)
+ {
+ _occupancyCounts.Remove(key);
+ _dirtyChunks.Add(key);
+ return;
+ }
+
+ _occupancyCounts[key] = count - 1;
+ _dirtyChunks.Add(key);
+ }
+
+ public bool IsOccupied(int cellX, int cellY)
+ {
+ var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out _);
+ return chunk[localX, localY] != 0;
+ }
+
+ public bool IsChunkLoaded(int chunkX, int chunkY) => _manager.GetChunk(chunkX, chunkY) is not null;
+
+ public int GetChunkOccupancyCount(int chunkX, int chunkY) =>
+ _occupancyCounts.TryGetValue((chunkX, chunkY), out var count) ? count : 0;
+
+ public void ClearDirtyChunks() => _dirtyChunks.Clear();
+
+ public bool MoveOccupied(int fromCellX, int fromCellY, int toCellX, int toCellY, byte value = 1)
+ {
+ var sourceChunk = GetChunk(fromCellX, fromCellY, out var sourceLocalX, out var sourceLocalY, out var sourceKey);
+ if (sourceChunk[sourceLocalX, sourceLocalY] == 0)
+ {
+ return false;
+ }
+
+ if (fromCellX == toCellX && fromCellY == toCellY)
+ {
+ sourceChunk[sourceLocalX, sourceLocalY] = value;
+ _dirtyChunks.Add(sourceKey);
+ return true;
+ }
+
+ var destinationChunk = GetChunk(toCellX, toCellY, out var destinationLocalX, out var destinationLocalY, out var destinationKey);
+ if (destinationChunk[destinationLocalX, destinationLocalY] != 0)
+ {
+ return false;
+ }
+
+ sourceChunk[sourceLocalX, sourceLocalY] = 0;
+ destinationChunk[destinationLocalX, destinationLocalY] = value;
+
+ DecrementChunkOccupancy(sourceKey);
+ _occupancyCounts[destinationKey] = _occupancyCounts.TryGetValue(destinationKey, out var destinationCount) ? destinationCount + 1 : 1;
+
+ _dirtyChunks.Add(sourceKey);
+ _dirtyChunks.Add(destinationKey);
+ return true;
+ }
+
+ public bool MoveOccupiedWithinChunk(int chunkX, int chunkY, int fromLocalX, int fromLocalY, int toLocalX, int toLocalY, byte value = 1)
+ {
+ var chunk = _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight);
+ if (chunk[fromLocalX, fromLocalY] == 0 || chunk[toLocalX, toLocalY] != 0)
+ {
+ return false;
+ }
+
+ chunk[fromLocalX, fromLocalY] = 0;
+ chunk[toLocalX, toLocalY] = value;
+ _dirtyChunks.Add((chunkX, chunkY));
+ return true;
+ }
+
+ public bool SwapOccupied(int firstCellX, int firstCellY, int secondCellX, int secondCellY, byte firstValue, byte secondValue)
+ {
+ var firstChunk = GetChunk(firstCellX, firstCellY, out var firstLocalX, out var firstLocalY, out var firstKey);
+ var secondChunk = GetChunk(secondCellX, secondCellY, out var secondLocalX, out var secondLocalY, out var secondKey);
+ if (firstChunk[firstLocalX, firstLocalY] == 0 || secondChunk[secondLocalX, secondLocalY] == 0)
+ {
+ return false;
+ }
+
+ firstChunk[firstLocalX, firstLocalY] = secondValue;
+ secondChunk[secondLocalX, secondLocalY] = firstValue;
+ _dirtyChunks.Add(firstKey);
+ _dirtyChunks.Add(secondKey);
+ return true;
+ }
+
+ public bool SwapOccupiedWithinChunk(int chunkX, int chunkY, int firstLocalX, int firstLocalY, int secondLocalX, int secondLocalY, byte firstValue, byte secondValue)
+ {
+ var chunk = _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight);
+ if (chunk[firstLocalX, firstLocalY] == 0 || chunk[secondLocalX, secondLocalY] == 0)
+ {
+ return false;
+ }
+
+ chunk[firstLocalX, firstLocalY] = secondValue;
+ chunk[secondLocalX, secondLocalY] = firstValue;
+ _dirtyChunks.Add((chunkX, chunkY));
+ return true;
+ }
+
+ public int UnloadEmptyChunks()
+ {
+ var unloaded = 0;
+ foreach (var chunk in _manager.GetAllChunks().ToArray())
+ {
+ var key = (chunk.X, chunk.Y);
+ if (_occupancyCounts.ContainsKey(key))
+ {
+ continue;
+ }
+
+ if (_manager.UnloadChunk(chunk.X, chunk.Y))
+ {
+ unloaded++;
+ }
+ }
+
+ return unloaded;
+ }
+
+ public int UnloadInactiveChunks(int marginChunks = 0)
+ {
+ if (_occupancyCounts.Count == 0)
+ {
+ return UnloadEmptyChunks();
+ }
+
+ var keep = new HashSet<(int ChunkX, int ChunkY)>();
+ foreach (var (chunkX, chunkY) in _occupancyCounts.Keys)
+ {
+ for (var offsetX = -marginChunks; offsetX <= marginChunks; offsetX++)
+ {
+ for (var offsetY = -marginChunks; offsetY <= marginChunks; offsetY++)
+ {
+ keep.Add((chunkX + offsetX, chunkY + offsetY));
+ }
+ }
+ }
+
+ var unloaded = 0;
+ foreach (var chunk in _manager.GetAllChunks().ToArray())
+ {
+ var key = (chunk.X, chunk.Y);
+ if (keep.Contains(key))
+ {
+ continue;
+ }
+
+ if (_manager.UnloadChunk(chunk.X, chunk.Y))
+ {
+ unloaded++;
+ }
+ }
+
+ return unloaded;
+ }
+
+ private void DecrementChunkOccupancy((int ChunkX, int ChunkY) key)
+ {
+ if (!_occupancyCounts.TryGetValue(key, out var count))
+ {
+ return;
+ }
+
+ if (count <= 1)
+ {
+ _occupancyCounts.Remove(key);
+ return;
+ }
+
+ _occupancyCounts[key] = count - 1;
+ }
+
+ private Chunk2D GetChunk(int cellX, int cellY, out int localX, out int localY, out (int ChunkX, int ChunkY) key)
+ {
+ var chunkX = Math.DivRem(cellX, _config.ChunkWidth, out localX);
+ var chunkY = Math.DivRem(cellY, _config.ChunkHeight, out localY);
+ if (localX < 0)
+ {
+ localX += _config.ChunkWidth;
+ chunkX--;
+ }
+
+ if (localY < 0)
+ {
+ localY += _config.ChunkHeight;
+ chunkY--;
+ }
+
+ key = (chunkX, chunkY);
+ return _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeParticle.cs b/Sand.ChunkPrototype/PrototypeParticle.cs
new file mode 100644
index 0000000..c1492aa
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeParticle.cs
@@ -0,0 +1,122 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+[Flags]
+public enum PrototypeParticleFlags : ushort
+{
+ None = 0,
+ WaterLike = 1 << 0,
+ FireLike = 1 << 1,
+ Acidic = 1 << 2,
+ HotSource = 1 << 3,
+}
+
+public readonly record struct PrototypeParticle(
+ ushort TypeId,
+ string Id,
+ PrototypeParticleType MotionType,
+ ParticleKind Kind,
+ ParticleBehaviorKind BehaviorKind,
+ byte R,
+ byte G,
+ byte B,
+ float Mass,
+ float Velocity,
+ float Friction,
+ float Viscosity,
+ bool IsStatic = false,
+ PrototypeParticleFlags Flags = PrototypeParticleFlags.None,
+ bool IsMolten = false,
+ ushort HydrateTargetTypeId = 0,
+ ushort MeltTypeId = 0,
+ ushort EvaporateTypeId = 0,
+ ushort SolidifyTypeId = 0,
+ ushort FreezeTypeId = 0,
+ ushort BrokenTypeId = 0,
+ ushort ProduceTypeId = 0,
+ ushort ProducesOnDeathTypeId = 0,
+ float PressureThreshold = 0f,
+ float PressureResistance = 0f,
+ float PressureTolerance = 0f,
+ short PressureThresholdDuration = 0,
+ float PressureResponse = 1f,
+ float ForceResponseMultiplier = 1f,
+ float LateralFlowMultiplier = 1f,
+ float DiagonalFlowMultiplier = 1f,
+ float UpwardBias = 0f,
+ float SideDriftBias = 0f,
+ float InitialTemperature = 22f,
+ float MeltTemperature = float.PositiveInfinity,
+ float EvaporateTemperature = float.PositiveInfinity,
+ float SolidifyTemperature = float.NegativeInfinity,
+ float FreezeTemperature = float.NegativeInfinity,
+ float BurnDuration = 0f,
+ float BurnTemperature = 0f,
+ float BurnRate = 1f,
+ bool BurningInit = false,
+ float DefaultLifetime = 0f,
+ float HeatEmission = 0f,
+ float SmokeSpawnChance = 0f,
+ float EmberSpawnChance = 0f,
+ float Hardness = 0.5f,
+ float Durability = 100f,
+ float Flamability = 0f,
+ float Conductivity = 0f,
+ bool Conductive = false,
+ float AmbientCoolingMultiplier = 1f,
+ float NeighborHeatTransferMultiplier = 1f,
+ float PhaseTransitionHysteresis = 0f)
+{
+ public bool IsEmpty => TypeId == 0 || MotionType == PrototypeParticleType.Empty;
+
+ public bool HasFlag(PrototypeParticleFlags flag) => (Flags & flag) != 0;
+
+ public bool HasLifetimeBehavior => DefaultLifetime > 0f;
+
+ public bool HasBurnBehavior => BurningInit || BurnTemperature > 0f || BurnDuration > 0f || Flamability > 0f;
+
+ public bool HasEmissionBehavior => HeatEmission > 0f || SmokeSpawnChance > 0f || EmberSpawnChance > 0f || ProduceTypeId != 0 || ProducesOnDeathTypeId != 0;
+
+ public bool HasSpecialBehavior => BehaviorKind != ParticleBehaviorKind.None;
+
+ public bool HasReactionBehavior =>
+ HydrateTargetTypeId != 0 ||
+ HasFlag(PrototypeParticleFlags.WaterLike) ||
+ HasFlag(PrototypeParticleFlags.FireLike) ||
+ HasFlag(PrototypeParticleFlags.Acidic) ||
+ HasFlag(PrototypeParticleFlags.HotSource);
+
+ public bool HasPhaseBehavior =>
+ (MeltTypeId != 0 && !float.IsPositiveInfinity(MeltTemperature)) ||
+ (EvaporateTypeId != 0 && !float.IsPositiveInfinity(EvaporateTemperature)) ||
+ (SolidifyTypeId != 0 && !float.IsNegativeInfinity(SolidifyTemperature)) ||
+ (FreezeTypeId != 0 && !float.IsNegativeInfinity(FreezeTemperature)) ||
+ (MotionType == PrototypeParticleType.Steam && SolidifyTypeId != 0);
+
+ public bool HasPressureBehavior =>
+ BrokenTypeId != 0 ||
+ PressureThreshold > 0f ||
+ PressureResistance > 0f ||
+ PressureTolerance > 0f ||
+ PressureThresholdDuration > 0;
+
+ public bool HasThermalBehavior =>
+ HasBurnBehavior ||
+ HasEmissionBehavior ||
+ HasPhaseBehavior ||
+ HasSpecialBehavior ||
+ Conductivity > 0f ||
+ AmbientCoolingMultiplier != 1f ||
+ NeighborHeatTransferMultiplier != 1f;
+
+ public bool RequiresFullRuntimeStep =>
+ HasLifetimeBehavior ||
+ HasBurnBehavior ||
+ HasEmissionBehavior ||
+ HasSpecialBehavior ||
+ HasReactionBehavior ||
+ HasPhaseBehavior ||
+ HasPressureBehavior ||
+ HasThermalBehavior;
+}
diff --git a/Sand.ChunkPrototype/PrototypeParticleType.cs b/Sand.ChunkPrototype/PrototypeParticleType.cs
new file mode 100644
index 0000000..a375e2d
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeParticleType.cs
@@ -0,0 +1,10 @@
+namespace Sand.ChunkPrototype;
+
+public enum PrototypeParticleType : byte
+{
+ Empty = 0,
+ Sand = 1,
+ Water = 2,
+ Steam = 3,
+ Wall = 4,
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs
new file mode 100644
index 0000000..3cef8bb
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs
@@ -0,0 +1,401 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ public bool AddParticle(int x, int y, PrototypeParticle particle)
+ {
+ if (!InBounds(x, y) || particle.IsEmpty)
+ {
+ return false;
+ }
+
+ RegisterParticleProfile(particle);
+
+ var coord = GetChunkCoord(x, y);
+ var (localX, localY) = GetLocalCoord(x, y);
+ var page = GetOrCreateCellPage(coord);
+ if (page.IsOccupied(localX, localY))
+ {
+ if (particle.MotionType == PrototypeParticleType.Wall)
+ {
+ page.SetCell(localX, localY, particle);
+ page.SetDriftState(localX, localY, 0);
+ InitializeRuntimeState(page, localX, localY, particle);
+ page.LastTouchedFrame = _stepCounter;
+ MarkChunkActive(coord, page);
+ World.SetOccupied(x, y, (byte)particle.MotionType);
+ return true;
+ }
+
+ return false;
+ }
+
+ page.SetCell(localX, localY, particle);
+ page.SetDriftState(localX, localY, GetDefaultDriftDirection(x, y));
+ InitializeRuntimeState(page, localX, localY, particle);
+ page.LastTouchedFrame = _stepCounter;
+ _particleCount++;
+ if (_fieldPages.TryGetValue(coord, out var fieldPage))
+ {
+ fieldPage.SetCell(localX, localY, default);
+ page.HasFieldActivity = !fieldPage.IsEmpty();
+ }
+
+ World.SetOccupied(x, y, (byte)particle.MotionType);
+ MarkChunkActive(coord, page);
+ WakeNeighborsForBorderTouch(coord, localX, localY);
+ return true;
+ }
+
+ public bool HasParticle(int x, int y) => TryGetParticle(x, y, out _);
+
+ public bool TryGetParticle(int x, int y, out PrototypeParticle particle)
+ {
+ particle = default;
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ particle = page[localX, localY];
+ return particle.TypeId != 0;
+ }
+
+ public PrototypeParticleType GetParticleTypeAt(int x, int y) => TryGetParticle(x, y, out var particle) ? particle.MotionType : PrototypeParticleType.Empty;
+
+ public ushort GetTypeIdAt(int x, int y) => TryGetParticle(x, y, out var particle) ? particle.TypeId : (ushort)0;
+
+ public float GetTemperatureAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return _ambientTemperature;
+ }
+
+ return page.GetTemperature(localX, localY);
+ }
+
+ public bool RemoveParticle(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out var coord, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ var particle = page[localX, localY];
+ if (particle.TypeId == 0)
+ {
+ return false;
+ }
+
+ page.SetCell(localX, localY, default);
+ page.SetDriftState(localX, localY, 0);
+ page.LastTouchedFrame = _stepCounter;
+ _particleCount--;
+ World.ClearOccupied(x, y);
+ MarkChunkActive(coord, page);
+ WakeNeighborsForBorderTouch(coord, localX, localY);
+ RemoveEmptyCellPageIfUnused(coord, page);
+ return true;
+ }
+
+ private bool MoveParticle(int fromX, int fromY, int toX, int toY)
+ {
+ if (!TryGetCellPage(fromX, fromY, out var sourceCoord, out var sourcePage, out var sourceLocalX, out var sourceLocalY))
+ {
+ return false;
+ }
+
+ var particle = sourcePage[sourceLocalX, sourceLocalY];
+ if (particle.TypeId == 0)
+ {
+ return false;
+ }
+
+ var destinationCoord = GetChunkCoord(toX, toY);
+ var (destinationLocalX, destinationLocalY) = GetLocalCoord(toX, toY);
+ var destinationPage = GetOrCreateCellPage(destinationCoord);
+ if (destinationPage.IsOccupied(destinationLocalX, destinationLocalY))
+ {
+ return false;
+ }
+
+ var driftState = sourcePage.GetDriftState(sourceLocalX, sourceLocalY);
+ var pressureDuration = sourcePage.GetPressureDuration(sourceLocalX, sourceLocalY);
+ var temperature = sourcePage.GetTemperature(sourceLocalX, sourceLocalY);
+ var burnTime = sourcePage.GetBurnTime(sourceLocalX, sourceLocalY);
+ var burning = sourcePage.GetBurning(sourceLocalX, sourceLocalY);
+ var sparkTime = sourcePage.GetSparkTime(sourceLocalX, sourceLocalY);
+ var lifetime = sourcePage.GetLifetime(sourceLocalX, sourceLocalY);
+ var cellAge = sourcePage.GetCellAge(sourceLocalX, sourceLocalY);
+ var integrity = sourcePage.GetIntegrity(sourceLocalX, sourceLocalY);
+ sourcePage.SetCell(sourceLocalX, sourceLocalY, default);
+ sourcePage.SetDriftState(sourceLocalX, sourceLocalY, 0);
+ sourcePage.SetPressureDuration(sourceLocalX, sourceLocalY, 0f);
+ destinationPage.SetCell(destinationLocalX, destinationLocalY, particle);
+ destinationPage.SetDriftState(destinationLocalX, destinationLocalY, ResolveDriftAfterMove(fromX, toX, driftState));
+ destinationPage.SetPressureDuration(destinationLocalX, destinationLocalY, pressureDuration);
+ destinationPage.SetTemperature(destinationLocalX, destinationLocalY, temperature);
+ destinationPage.SetBurnTime(destinationLocalX, destinationLocalY, burnTime);
+ destinationPage.SetBurning(destinationLocalX, destinationLocalY, burning);
+ destinationPage.SetSparkTime(destinationLocalX, destinationLocalY, sparkTime);
+ destinationPage.SetLifetime(destinationLocalX, destinationLocalY, lifetime);
+ destinationPage.SetCellAge(destinationLocalX, destinationLocalY, cellAge);
+ destinationPage.SetIntegrity(destinationLocalX, destinationLocalY, integrity);
+ destinationPage.MarkProcessed(destinationLocalX, destinationLocalY, _stepCounter);
+ sourcePage.LastTouchedFrame = _stepCounter;
+ destinationPage.LastTouchedFrame = _stepCounter;
+ MarkChunkActive(sourceCoord, sourcePage);
+ MarkChunkActive(destinationCoord, destinationPage);
+ World.MoveOccupied(fromX, fromY, toX, toY, (byte)particle.MotionType);
+ WakeNeighborsForBorderTouch(sourceCoord, sourceLocalX, sourceLocalY);
+ WakeNeighborsForBorderTouch(destinationCoord, destinationLocalX, destinationLocalY);
+ RemoveEmptyCellPageIfUnused(sourceCoord, sourcePage);
+ _movedParticles++;
+ return true;
+ }
+
+ private bool MoveParticleWithinPage(ChunkCoord coord, ChunkCellPage page, int fromLocalX, int fromLocalY, int toLocalX, int toLocalY, int fromX, int fromY, int toX, int toY)
+ {
+ var particle = page[fromLocalX, fromLocalY];
+ if (particle.TypeId == 0 || page.IsOccupied(toLocalX, toLocalY))
+ {
+ return false;
+ }
+
+ var driftState = page.GetDriftState(fromLocalX, fromLocalY);
+ var pressureDuration = page.GetPressureDuration(fromLocalX, fromLocalY);
+ var temperature = page.GetTemperature(fromLocalX, fromLocalY);
+ var burnTime = page.GetBurnTime(fromLocalX, fromLocalY);
+ var burning = page.GetBurning(fromLocalX, fromLocalY);
+ var sparkTime = page.GetSparkTime(fromLocalX, fromLocalY);
+ var lifetime = page.GetLifetime(fromLocalX, fromLocalY);
+ var cellAge = page.GetCellAge(fromLocalX, fromLocalY);
+ var integrity = page.GetIntegrity(fromLocalX, fromLocalY);
+ page.SetCell(fromLocalX, fromLocalY, default);
+ page.SetDriftState(fromLocalX, fromLocalY, 0);
+ page.SetPressureDuration(fromLocalX, fromLocalY, 0f);
+ page.SetCell(toLocalX, toLocalY, particle);
+ page.SetDriftState(toLocalX, toLocalY, ResolveDriftAfterMove(fromX, toX, driftState));
+ page.SetPressureDuration(toLocalX, toLocalY, pressureDuration);
+ page.SetTemperature(toLocalX, toLocalY, temperature);
+ page.SetBurnTime(toLocalX, toLocalY, burnTime);
+ page.SetBurning(toLocalX, toLocalY, burning);
+ page.SetSparkTime(toLocalX, toLocalY, sparkTime);
+ page.SetLifetime(toLocalX, toLocalY, lifetime);
+ page.SetCellAge(toLocalX, toLocalY, cellAge);
+ page.SetIntegrity(toLocalX, toLocalY, integrity);
+ page.MarkProcessed(toLocalX, toLocalY, _stepCounter);
+ page.LastTouchedFrame = _stepCounter;
+ MarkChunkActive(coord, page);
+ World.MoveOccupiedWithinChunk(coord.X, coord.Y, fromLocalX, fromLocalY, toLocalX, toLocalY, (byte)particle.MotionType);
+ WakeNeighborsForBorderTouch(coord, fromLocalX, fromLocalY);
+ WakeNeighborsForBorderTouch(coord, toLocalX, toLocalY);
+ _movedParticles++;
+ return true;
+ }
+
+ private bool SwapParticles(int firstX, int firstY, int secondX, int secondY)
+ {
+ if (!TryGetCellPage(firstX, firstY, out var firstCoord, out var firstPage, out var firstLocalX, out var firstLocalY) ||
+ !TryGetCellPage(secondX, secondY, out var secondCoord, out var secondPage, out var secondLocalX, out var secondLocalY))
+ {
+ return false;
+ }
+
+ var firstParticle = firstPage[firstLocalX, firstLocalY];
+ var secondParticle = secondPage[secondLocalX, secondLocalY];
+ if (firstParticle.TypeId == 0 || secondParticle.TypeId == 0)
+ {
+ return false;
+ }
+
+ var firstDrift = firstPage.GetDriftState(firstLocalX, firstLocalY);
+ var secondDrift = secondPage.GetDriftState(secondLocalX, secondLocalY);
+ var firstPressureDuration = firstPage.GetPressureDuration(firstLocalX, firstLocalY);
+ var secondPressureDuration = secondPage.GetPressureDuration(secondLocalX, secondLocalY);
+ var firstTemperature = firstPage.GetTemperature(firstLocalX, firstLocalY);
+ var secondTemperature = secondPage.GetTemperature(secondLocalX, secondLocalY);
+ var firstBurnTime = firstPage.GetBurnTime(firstLocalX, firstLocalY);
+ var secondBurnTime = secondPage.GetBurnTime(secondLocalX, secondLocalY);
+ var firstBurning = firstPage.GetBurning(firstLocalX, firstLocalY);
+ var secondBurning = secondPage.GetBurning(secondLocalX, secondLocalY);
+ var firstSparkTime = firstPage.GetSparkTime(firstLocalX, firstLocalY);
+ var secondSparkTime = secondPage.GetSparkTime(secondLocalX, secondLocalY);
+ var firstLifetime = firstPage.GetLifetime(firstLocalX, firstLocalY);
+ var secondLifetime = secondPage.GetLifetime(secondLocalX, secondLocalY);
+ var firstAge = firstPage.GetCellAge(firstLocalX, firstLocalY);
+ var secondAge = secondPage.GetCellAge(secondLocalX, secondLocalY);
+ var firstIntegrity = firstPage.GetIntegrity(firstLocalX, firstLocalY);
+ var secondIntegrity = secondPage.GetIntegrity(secondLocalX, secondLocalY);
+ firstPage.SetCell(firstLocalX, firstLocalY, secondParticle);
+ firstPage.SetDriftState(firstLocalX, firstLocalY, ResolveDriftAfterMove(secondX, firstX, secondDrift));
+ firstPage.SetPressureDuration(firstLocalX, firstLocalY, secondPressureDuration);
+ firstPage.SetTemperature(firstLocalX, firstLocalY, secondTemperature);
+ firstPage.SetBurnTime(firstLocalX, firstLocalY, secondBurnTime);
+ firstPage.SetBurning(firstLocalX, firstLocalY, secondBurning);
+ firstPage.SetSparkTime(firstLocalX, firstLocalY, secondSparkTime);
+ firstPage.SetLifetime(firstLocalX, firstLocalY, secondLifetime);
+ firstPage.SetCellAge(firstLocalX, firstLocalY, secondAge);
+ firstPage.SetIntegrity(firstLocalX, firstLocalY, secondIntegrity);
+ secondPage.SetCell(secondLocalX, secondLocalY, firstParticle);
+ secondPage.SetDriftState(secondLocalX, secondLocalY, ResolveDriftAfterMove(firstX, secondX, firstDrift));
+ secondPage.SetPressureDuration(secondLocalX, secondLocalY, firstPressureDuration);
+ secondPage.SetTemperature(secondLocalX, secondLocalY, firstTemperature);
+ secondPage.SetBurnTime(secondLocalX, secondLocalY, firstBurnTime);
+ secondPage.SetBurning(secondLocalX, secondLocalY, firstBurning);
+ secondPage.SetSparkTime(secondLocalX, secondLocalY, firstSparkTime);
+ secondPage.SetLifetime(secondLocalX, secondLocalY, firstLifetime);
+ secondPage.SetCellAge(secondLocalX, secondLocalY, firstAge);
+ secondPage.SetIntegrity(secondLocalX, secondLocalY, firstIntegrity);
+ firstPage.MarkProcessed(firstLocalX, firstLocalY, _stepCounter);
+ secondPage.MarkProcessed(secondLocalX, secondLocalY, _stepCounter);
+ firstPage.LastTouchedFrame = _stepCounter;
+ secondPage.LastTouchedFrame = _stepCounter;
+ MarkChunkActive(firstCoord, firstPage);
+ MarkChunkActive(secondCoord, secondPage);
+ if (firstCoord == secondCoord)
+ {
+ World.SwapOccupiedWithinChunk(firstCoord.X, firstCoord.Y, firstLocalX, firstLocalY, secondLocalX, secondLocalY, (byte)firstParticle.MotionType, (byte)secondParticle.MotionType);
+ }
+ else
+ {
+ World.SwapOccupied(firstX, firstY, secondX, secondY, (byte)firstParticle.MotionType, (byte)secondParticle.MotionType);
+ }
+ WakeNeighborsForBorderTouch(firstCoord, firstLocalX, firstLocalY);
+ WakeNeighborsForBorderTouch(secondCoord, secondLocalX, secondLocalY);
+ _swappedParticles++;
+ return true;
+ }
+
+ private bool ReplaceParticle(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out var coord, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ if (particle.IsEmpty)
+ {
+ return RemoveParticle(x, y);
+ }
+
+ RegisterParticleProfile(particle);
+ page.SetCell(localX, localY, particle);
+ page.SetDriftState(localX, localY, GetDefaultDriftDirection(x, y));
+ InitializeRuntimeState(page, localX, localY, particle);
+ page.LastTouchedFrame = _stepCounter;
+ page.MarkProcessed(localX, localY, _stepCounter);
+ MarkChunkActive(coord, page);
+ World.SetOccupied(x, y, (byte)particle.MotionType);
+ WakeNeighborsForBorderTouch(coord, localX, localY);
+ return true;
+ }
+
+ private void InitializeRuntimeState(ChunkCellPage page, int localX, int localY, PrototypeParticle particle)
+ {
+ page.SetPressureDuration(localX, localY, 0f);
+ page.SetTemperature(localX, localY, particle.InitialTemperature);
+ page.SetBurnTime(localX, localY, particle.BurnDuration);
+ page.SetBurning(localX, localY, particle.BurningInit ? (byte)1 : (byte)0);
+ page.SetSparkTime(localX, localY, 0);
+ page.SetLifetime(localX, localY, particle.DefaultLifetime);
+ page.SetCellAge(localX, localY, 0f);
+ page.SetIntegrity(localX, localY, particle.Durability);
+ }
+
+ private bool TryResolveProfile(ushort typeId, out PrototypeParticle particle) => _particleProfiles.TryGetValue(typeId, out particle);
+
+ private bool TryFindProfileById(string particleId, out PrototypeParticle particle)
+ {
+ foreach (var profile in _particleProfiles.Values)
+ {
+ if (profile.TypeId != 0 && string.Equals(profile.Id, particleId, StringComparison.OrdinalIgnoreCase))
+ {
+ particle = profile;
+ return true;
+ }
+ }
+
+ particle = default;
+ return false;
+ }
+
+ private ChunkCoord GetChunkCoord(int x, int y) => new(x / _config.ChunkWidth, y / _config.ChunkHeight);
+
+ private (int LocalX, int LocalY) GetLocalCoord(int x, int y) => (x % _config.ChunkWidth, y % _config.ChunkHeight);
+
+ private bool TryGetCellPage(int x, int y, out ChunkCoord coord, out ChunkCellPage page, out int localX, out int localY)
+ {
+ coord = default;
+ page = null!;
+ localX = 0;
+ localY = 0;
+ if (!InBounds(x, y))
+ {
+ return false;
+ }
+
+ coord = GetChunkCoord(x, y);
+ if (!_cellPages.TryGetValue(coord, out page!))
+ {
+ return false;
+ }
+
+ (localX, localY) = GetLocalCoord(x, y);
+ return true;
+ }
+
+ private bool TryGetFieldPage(int x, int y, out ChunkCoord coord, out ChunkFieldPage page, out int localX, out int localY)
+ {
+ coord = default;
+ page = null!;
+ localX = 0;
+ localY = 0;
+ if (!InBounds(x, y))
+ {
+ return false;
+ }
+
+ coord = GetChunkCoord(x, y);
+ if (!_fieldPages.TryGetValue(coord, out page!))
+ {
+ return false;
+ }
+
+ (localX, localY) = GetLocalCoord(x, y);
+ return true;
+ }
+
+ private ChunkCellPage GetOrCreateCellPage(ChunkCoord coord)
+ {
+ if (_cellPages.TryGetValue(coord, out var page))
+ {
+ return page;
+ }
+
+ page = new ChunkCellPage(_config.ChunkWidth, _config.ChunkHeight);
+ _cellPages[coord] = page;
+ return page;
+ }
+
+ private ChunkFieldPage GetOrCreateFieldPage(ChunkCoord coord)
+ {
+ if (_fieldPages.TryGetValue(coord, out var page))
+ {
+ return page;
+ }
+
+ page = new ChunkFieldPage(_config.ChunkWidth, _config.ChunkHeight);
+ _fieldPages[coord] = page;
+ return page;
+ }
+
+ private int ToWorldX(ChunkCoord coord, int localX) => (coord.X * _config.ChunkWidth) + localX;
+
+ private int ToWorldY(ChunkCoord coord, int localY) => (coord.Y * _config.ChunkHeight) + localY;
+
+ private bool InBounds(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs
new file mode 100644
index 0000000..6176923
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs
@@ -0,0 +1,528 @@
+using Sand.Core;
+using System.Diagnostics;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ public void ClearFields()
+ {
+ _fieldPages.Clear();
+ foreach (var page in _cellPages.Values)
+ {
+ page.HasFieldActivity = false;
+ }
+
+ _fullFrameDirty = true;
+ _dirtyVisualChunks.Clear();
+ foreach (var coord in _cellPages.Keys)
+ {
+ _dirtyVisualChunks.Add(coord);
+ }
+ }
+
+ public (float X, float Y) GetWindAtCell(int x, int y)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return (0f, 0f);
+ }
+
+ var cell = page.GetCell(localX, localY);
+ return (cell.WindX, cell.WindY);
+ }
+
+ public (float X, float Y) GetForceAtCell(int x, int y)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return (0f, 0f);
+ }
+
+ var cell = page.GetCell(localX, localY);
+ return (cell.ForceX, cell.ForceY);
+ }
+
+ public float GetAirPressureAtCell(int x, int y)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return 0f;
+ }
+
+ return page.GetCell(localX, localY).Pressure;
+ }
+
+ public ReadOnlySpan BuildRgbaFrame(bool enableWindVisuals = false, bool enablePressureVisuals = false)
+ {
+ var renderStart = Stopwatch.GetTimestamp();
+ long bytesTouched;
+ if (_fullFrameDirty)
+ {
+ Array.Clear(_rgbaBuffer);
+ for (var i = 3; i < _rgbaBuffer.Length; i += 4)
+ {
+ _rgbaBuffer[i] = 255;
+ }
+
+ bytesTouched = _rgbaBuffer.Length;
+ var rendered = new HashSet();
+ foreach (var coord in _cellPages.Keys)
+ {
+ RenderChunk(coord, enableWindVisuals, enablePressureVisuals);
+ rendered.Add(coord);
+ }
+
+ foreach (var coord in _fieldPages.Keys)
+ {
+ if (rendered.Add(coord))
+ {
+ RenderChunk(coord, enableWindVisuals, enablePressureVisuals);
+ }
+ }
+
+ _dirtyVisualChunks.Clear();
+ _fullFrameDirty = false;
+ }
+ else
+ {
+ bytesTouched = 0;
+ foreach (var coord in _dirtyVisualChunks)
+ {
+ bytesTouched += ClearChunkVisual(coord);
+ RenderChunk(coord, enableWindVisuals, enablePressureVisuals);
+ }
+
+ _dirtyVisualChunks.Clear();
+ }
+
+ var renderMicros = ToMicroseconds(renderStart, Stopwatch.GetTimestamp());
+ _lastStepStats = _lastStepStats with
+ {
+ FrameBuildBytesTouched = bytesTouched,
+ RenderTimeMicroseconds = renderMicros,
+ };
+ return _rgbaBuffer;
+ }
+
+ public ReadOnlySpan BuildRgbFrame()
+ {
+ var rgba = BuildRgbaFrame();
+ for (int sourceIndex = 0, targetIndex = 0; sourceIndex < rgba.Length; sourceIndex += 4, targetIndex += 3)
+ {
+ _rgbBuffer[targetIndex] = rgba[sourceIndex];
+ _rgbBuffer[targetIndex + 1] = rgba[sourceIndex + 1];
+ _rgbBuffer[targetIndex + 2] = rgba[sourceIndex + 2];
+ }
+
+ return _rgbBuffer;
+ }
+
+ public int TrimResidency(int marginChunks = 1)
+ {
+ var unloaded = World.UnloadEmptyChunks();
+ unloaded += World.UnloadInactiveChunks(marginChunks);
+
+ foreach (var coord in _cellPages.Keys.ToArray())
+ {
+ if (_cellPages[coord].OccupancyCount == 0 && !_fieldPages.ContainsKey(coord))
+ {
+ _cellPages.Remove(coord);
+ }
+ }
+
+ foreach (var coord in _fieldPages.Keys.ToArray())
+ {
+ if (_fieldPages[coord].IsEmpty())
+ {
+ _fieldPages.Remove(coord);
+ }
+ }
+
+ return unloaded;
+ }
+
+ public void ApplyWindBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY) =>
+ ApplyDirectionalField(centerX, centerY, brushRadius, forceX, forceY, writeWind: true, writePressure: false);
+
+ public void ApplyAirBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY) =>
+ ApplyDirectionalField(centerX, centerY, brushRadius, forceX, forceY, writeWind: true, writePressure: true);
+
+ public void ApplyGravityBrush(int centerX, int centerY, int brushRadius, float strength) =>
+ ApplyRadialField(centerX, centerY, brushRadius, MathF.Abs(strength), inward: true);
+
+ public void ApplyRepulsorBrush(int centerX, int centerY, int brushRadius, float strength) =>
+ ApplyRadialField(centerX, centerY, brushRadius, MathF.Abs(strength), inward: false);
+
+ private void ApplyDirectionalField(int centerX, int centerY, int brushRadius, float forceX, float forceY, bool writeWind, bool writePressure)
+ {
+ var radiusSquared = brushRadius * brushRadius;
+ for (var offsetY = -brushRadius; offsetY <= brushRadius; offsetY++)
+ {
+ for (var offsetX = -brushRadius; offsetX <= brushRadius; offsetX++)
+ {
+ var distanceSquared = (offsetX * offsetX) + (offsetY * offsetY);
+ if (distanceSquared > radiusSquared)
+ {
+ continue;
+ }
+
+ var x = centerX + offsetX;
+ var y = centerY + offsetY;
+ if (!InBounds(x, y))
+ {
+ continue;
+ }
+
+ var distance = MathF.Sqrt(distanceSquared);
+ var falloff = brushRadius <= 0 ? 1f : MathF.Max(0.1f, 1f - (distance / Math.Max(1f, brushRadius)));
+ var coord = GetChunkCoord(x, y);
+ var (localX, localY) = GetLocalCoord(x, y);
+ var fieldPage = GetOrCreateFieldPage(coord);
+ var cell = fieldPage.GetCell(localX, localY);
+ fieldPage.SetCell(localX, localY, new FieldCellData(
+ writeWind ? cell.WindX + (forceX * falloff) : cell.WindX,
+ writeWind ? cell.WindY + (forceY * falloff) : cell.WindY,
+ writeWind ? cell.ForceX : cell.ForceX + (forceX * 0.25f * falloff),
+ writeWind ? cell.ForceY : cell.ForceY + (forceY * 0.25f * falloff),
+ writePressure ? cell.Pressure + (MathF.Sqrt((forceX * forceX) + (forceY * forceY)) * falloff) : cell.Pressure));
+ fieldPage.LastDecayedFrame = _stepCounter;
+ WakeChunk(coord);
+ }
+ }
+ }
+
+ private void ApplyRadialField(int centerX, int centerY, int brushRadius, float strength, bool inward)
+ {
+ var radiusSquared = brushRadius * brushRadius;
+ for (var offsetY = -brushRadius; offsetY <= brushRadius; offsetY++)
+ {
+ for (var offsetX = -brushRadius; offsetX <= brushRadius; offsetX++)
+ {
+ var distanceSquared = (offsetX * offsetX) + (offsetY * offsetY);
+ if (distanceSquared > radiusSquared)
+ {
+ continue;
+ }
+
+ var x = centerX + offsetX;
+ var y = centerY + offsetY;
+ if (!InBounds(x, y))
+ {
+ continue;
+ }
+
+ var distance = MathF.Sqrt(Math.Max(1f, distanceSquared));
+ var scale = MathF.Max(0.1f, 1f - (distance / Math.Max(1f, brushRadius)));
+ var directionX = offsetX / distance;
+ var directionY = offsetY / distance;
+ if (inward)
+ {
+ directionX = -directionX;
+ directionY = -directionY;
+ }
+
+ var coord = GetChunkCoord(x, y);
+ var (localX, localY) = GetLocalCoord(x, y);
+ var fieldPage = GetOrCreateFieldPage(coord);
+ var cell = fieldPage.GetCell(localX, localY);
+ fieldPage.SetCell(localX, localY, new FieldCellData(
+ cell.WindX,
+ cell.WindY,
+ cell.ForceX + (directionX * strength * scale),
+ cell.ForceY + (directionY * strength * scale),
+ cell.Pressure + ((inward ? 1f : -1f) * strength * 0.2f * scale)));
+ fieldPage.LastDecayedFrame = _stepCounter;
+ WakeChunk(coord);
+ }
+ }
+ }
+
+ private void DecayFields()
+ {
+ foreach (var coord in _fieldPages.Keys.ToArray())
+ {
+ var fieldPage = _fieldPages[coord];
+ var changed = false;
+ foreach (var (localX, localY, cell) in fieldPage.EnumerateActiveCells().ToArray())
+ {
+ var decayed = new FieldCellData(
+ cell.WindX * FieldDecayFactor,
+ cell.WindY * FieldDecayFactor,
+ cell.ForceX * FieldDecayFactor,
+ cell.ForceY * FieldDecayFactor,
+ cell.Pressure * FieldDecayFactor);
+ fieldPage.SetCell(localX, localY, decayed);
+ changed = true;
+ }
+
+ if (fieldPage.IsEmpty())
+ {
+ _fieldPages.Remove(coord);
+ if (_cellPages.TryGetValue(coord, out var cellPage))
+ {
+ cellPage.HasFieldActivity = false;
+ }
+
+ _dirtyVisualChunks.Add(coord);
+ continue;
+ }
+
+ if (changed)
+ {
+ fieldPage.LastDecayedFrame = _stepCounter;
+ _dirtyVisualChunks.Add(coord);
+ if (_cellPages.TryGetValue(coord, out var cellPage))
+ {
+ cellPage.HasFieldActivity = true;
+ cellPage.IsActive = true;
+ }
+ }
+ }
+ }
+
+ private void MarkChunkActive(ChunkCoord coord, ChunkCellPage page)
+ {
+ page.IsActive = true;
+ page.LastTouchedFrame = _stepCounter;
+ _dirtyVisualChunks.Add(coord);
+ }
+
+ private void WakeNeighborsForBorderTouch(ChunkCoord coord, int localX, int localY)
+ {
+ if (localX == 0)
+ {
+ WakeChunk(new ChunkCoord(coord.X - 1, coord.Y), _config.ChunkWidth - 1, localY);
+ }
+
+ if (localX == _config.ChunkWidth - 1)
+ {
+ WakeChunk(new ChunkCoord(coord.X + 1, coord.Y), 0, localY);
+ }
+
+ if (localY == 0)
+ {
+ WakeChunk(new ChunkCoord(coord.X, coord.Y - 1), localX, _config.ChunkHeight - 1);
+ }
+
+ if (localY == _config.ChunkHeight - 1)
+ {
+ WakeChunk(new ChunkCoord(coord.X, coord.Y + 1), localX, 0);
+ }
+ }
+
+ private void WakeChunk(ChunkCoord coord, int localX, int localY)
+ {
+ if (_cellPages.TryGetValue(coord, out var page))
+ {
+ page.IsActive = true;
+ page.LastTouchedFrame = _stepCounter;
+ page.RequestFutureSteps(1);
+ page.MarkDirtyBand(localX, localY);
+ }
+
+ _dirtyVisualChunks.Add(coord);
+ }
+
+ private void WakeChunk(ChunkCoord coord)
+ {
+ if (_cellPages.TryGetValue(coord, out var page))
+ {
+ page.IsActive = true;
+ page.HasFieldActivity = true;
+ page.LastTouchedFrame = _stepCounter;
+ page.RequestFutureSteps(1);
+ }
+
+ _dirtyVisualChunks.Add(coord);
+ }
+
+ private void RemoveEmptyCellPageIfUnused(ChunkCoord coord, ChunkCellPage page)
+ {
+ if (page.OccupancyCount == 0 && !_fieldPages.ContainsKey(coord))
+ {
+ _cellPages.Remove(coord);
+ }
+ }
+
+ private long ClearChunkVisual(ChunkCoord coord)
+ {
+ long bytesTouched = 0;
+ var startX = Math.Max(0, (coord.X * _config.ChunkWidth) - RenderHaloCells);
+ var startY = Math.Max(0, (coord.Y * _config.ChunkHeight) - RenderHaloCells);
+ var endX = Math.Min(Width - 1, ((coord.X + 1) * _config.ChunkWidth) + RenderHaloCells - 1);
+ var endY = Math.Min(Height - 1, ((coord.Y + 1) * _config.ChunkHeight) + RenderHaloCells - 1);
+ for (var worldY = startY; worldY <= endY; worldY++)
+ {
+ for (var worldX = startX; worldX <= endX; worldX++)
+ {
+ var rgbaIndex = ((worldY * Width) + worldX) * 4;
+ _rgbaBuffer[rgbaIndex] = 0;
+ _rgbaBuffer[rgbaIndex + 1] = 0;
+ _rgbaBuffer[rgbaIndex + 2] = 0;
+ _rgbaBuffer[rgbaIndex + 3] = 255;
+ bytesTouched += 4;
+ }
+ }
+
+ return bytesTouched;
+ }
+
+ private void RenderChunk(ChunkCoord coord, bool enableWindVisuals, bool enablePressureVisuals)
+ {
+ var startX = Math.Max(0, (coord.X * _config.ChunkWidth) - RenderHaloCells);
+ var startY = Math.Max(0, (coord.Y * _config.ChunkHeight) - RenderHaloCells);
+ var endX = Math.Min(Width - 1, ((coord.X + 1) * _config.ChunkWidth) + RenderHaloCells - 1);
+ var endY = Math.Min(Height - 1, ((coord.Y + 1) * _config.ChunkHeight) + RenderHaloCells - 1);
+ for (var worldY = startY; worldY <= endY; worldY++)
+ {
+ for (var worldX = startX; worldX <= endX; worldX++)
+ {
+ var rgbaIndex = ((worldY * Width) + worldX) * 4;
+ var particle = TryGetParticle(worldX, worldY, out var currentParticle) ? currentParticle : default;
+ if (particle.TypeId != 0)
+ {
+ _rgbaBuffer[rgbaIndex] = particle.R;
+ _rgbaBuffer[rgbaIndex + 1] = particle.G;
+ _rgbaBuffer[rgbaIndex + 2] = particle.B;
+ _rgbaBuffer[rgbaIndex + 3] = 255;
+ continue;
+ }
+
+ if (TryResolveGasSeamRenderColor(worldX, worldY, out var gasColor))
+ {
+ _rgbaBuffer[rgbaIndex] = gasColor.R;
+ _rgbaBuffer[rgbaIndex + 1] = gasColor.G;
+ _rgbaBuffer[rgbaIndex + 2] = gasColor.B;
+ _rgbaBuffer[rgbaIndex + 3] = 255;
+ continue;
+ }
+
+ var color = ResolveFieldColorAtWorld(worldX, worldY, enableWindVisuals, enablePressureVisuals);
+ _rgbaBuffer[rgbaIndex] = color.R;
+ _rgbaBuffer[rgbaIndex + 1] = color.G;
+ _rgbaBuffer[rgbaIndex + 2] = color.B;
+ _rgbaBuffer[rgbaIndex + 3] = 255;
+ }
+ }
+ }
+
+ private (byte R, byte G, byte B) ResolveFieldColorAtWorld(int x, int y, bool enableWindVisuals, bool enablePressureVisuals)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var fieldPage, out var localX, out var localY))
+ {
+ return (0, 0, 0);
+ }
+
+ return ResolveFieldColor(fieldPage, localX, localY, enableWindVisuals, enablePressureVisuals);
+ }
+
+ private bool TryResolveGasSeamRenderColor(int x, int y, out (byte R, byte G, byte B) color)
+ {
+ color = default;
+ if (!IsNearChunkRowSeam(y))
+ {
+ return false;
+ }
+
+ var gasNeighborCount = 0;
+ var gasAboveCount = 0;
+ var gasBelowCount = 0;
+ var totalR = 0;
+ var totalG = 0;
+ var totalB = 0;
+ for (var offsetY = -1; offsetY <= 1; offsetY++)
+ {
+ for (var offsetX = -1; offsetX <= 1; offsetX++)
+ {
+ if (offsetX == 0 && offsetY == 0)
+ {
+ continue;
+ }
+
+ var neighborX = x + offsetX;
+ var neighborY = y + offsetY;
+ if (!TryGetParticle(neighborX, neighborY, out var particle) || particle.MotionType != PrototypeParticleType.Steam)
+ {
+ continue;
+ }
+
+ gasNeighborCount++;
+ if (offsetY < 0)
+ {
+ gasAboveCount++;
+ }
+ else if (offsetY > 0)
+ {
+ gasBelowCount++;
+ }
+
+ totalR += particle.R;
+ totalG += particle.G;
+ totalB += particle.B;
+ }
+ }
+
+ if (gasNeighborCount < 3 || gasAboveCount == 0 || gasBelowCount == 0)
+ {
+ return false;
+ }
+
+ color = ((byte)(totalR / gasNeighborCount), (byte)(totalG / gasNeighborCount), (byte)(totalB / gasNeighborCount));
+ return true;
+ }
+
+ private bool IsNearChunkRowSeam(int y)
+ {
+ var localY = y % _config.ChunkHeight;
+ return localY == 0 || localY == _config.ChunkHeight - 1;
+ }
+
+ private static (byte R, byte G, byte B) ResolveFieldColor(ChunkFieldPage? fieldPage, int localX, int localY, bool enableWindVisuals, bool enablePressureVisuals)
+ {
+ if (fieldPage is null || !fieldPage.TryGetCell(localX, localY, out var cell))
+ {
+ return (0, 0, 0);
+ }
+
+ byte r = 0;
+ byte g = 0;
+ byte b = 0;
+ if (enablePressureVisuals)
+ {
+ var pressure = MathF.Abs(cell.Pressure);
+ if (pressure > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140);
+ if (cell.Pressure >= 0f)
+ {
+ r = (byte)Math.Max((int)r, 26 + tint);
+ g = (byte)Math.Max((int)g, 18 + (tint / 3));
+ b = (byte)Math.Max((int)b, 24);
+ }
+ else
+ {
+ r = (byte)Math.Max((int)r, 18);
+ g = (byte)Math.Max((int)g, 24 + (tint / 4));
+ b = (byte)Math.Max((int)b, 30 + tint);
+ }
+ }
+ }
+
+ if (enableWindVisuals)
+ {
+ var totalFieldX = cell.WindX + cell.ForceX;
+ var totalFieldY = cell.WindY + cell.ForceY;
+ var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY));
+ if (windStrength > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120);
+ r = (byte)Math.Max((int)r, 16 + (tint / 3));
+ g = (byte)Math.Max((int)g, 24 + (tint / 2));
+ b = (byte)Math.Max((int)b, 32 + tint);
+ }
+ }
+
+ return (r, g, b);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs
new file mode 100644
index 0000000..ea25e62
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs
@@ -0,0 +1,1061 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private void TryStepParticle(ChunkCoord coord, int localX, int localY, PrototypeParticle particle)
+ {
+ var worldX = ToWorldX(coord, localX);
+ var worldY = ToWorldY(coord, localY);
+ var seed = unchecked(_stepCounter + (worldX * 31) + (worldY * 131));
+ if (CanUseMovementFastPath(worldX, worldY, particle))
+ {
+ ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY);
+ return;
+ }
+
+ _fullRuntimeStepCount++;
+ switch (particle.Kind)
+ {
+ case ParticleKind.Solid:
+ _fullRuntimeSolidCount++;
+ break;
+ case ParticleKind.Liquid:
+ _fullRuntimeLiquidCount++;
+ break;
+ case ParticleKind.Gas:
+ _fullRuntimeGasCount++;
+ break;
+ }
+
+ if (NeedsRuntimeAging(particle))
+ {
+ TickCellAging(worldX, worldY, particle);
+ }
+
+ if (particle.HasLifetimeBehavior && TickLifetime(worldX, worldY, ref particle))
+ {
+ return;
+ }
+
+ if (particle.HasBurnBehavior)
+ {
+ AutoIgnite(worldX, worldY, particle);
+ }
+
+ if (NeedsRuntimeEmission(particle))
+ {
+ ApplyRuntimeEmissionAndProduction(worldX, worldY, particle, ref seed);
+ }
+
+ if (particle.HasBurnBehavior && TickBurning(worldX, worldY, ref particle))
+ {
+ return;
+ }
+
+ if (particle.HasSpecialBehavior && TickSpecialBehavior(worldX, worldY, particle, ref seed))
+ {
+ return;
+ }
+
+ if (particle.HasPhaseBehavior)
+ {
+ ApplyPhaseTransition(worldX, worldY, ref particle);
+ if (!TryRefreshRuntimeParticle(worldX, worldY, ref particle))
+ {
+ return;
+ }
+ }
+
+ if (particle.HasReactionBehavior && ApplyLocalReaction(worldX, worldY, particle, ref seed))
+ {
+ return;
+ }
+
+ if (particle.HasReactionBehavior && !TryRefreshRuntimeParticle(worldX, worldY, ref particle))
+ {
+ return;
+ }
+
+ if (particle.HasPressureBehavior && ApplyPressureResponse(worldX, worldY, particle))
+ {
+ return;
+ }
+
+ if (particle.HasPressureBehavior && !TryRefreshRuntimeParticle(worldX, worldY, ref particle))
+ {
+ return;
+ }
+
+ if (particle.HasThermalBehavior)
+ {
+ ApplyTemperatureDiffusion(worldX, worldY, particle);
+ }
+
+ if (particle.IsStatic)
+ {
+ return;
+ }
+
+ ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY);
+ }
+
+ private static bool NeedsRuntimeAging(PrototypeParticle particle) =>
+ particle.PhaseTransitionHysteresis > 0f || particle.HasPressureBehavior;
+
+ private static bool NeedsRuntimeEmission(PrototypeParticle particle) =>
+ particle.HasEmissionBehavior || particle.IsMolten;
+
+ private bool TryRefreshRuntimeParticle(int x, int y, ref PrototypeParticle particle) =>
+ TryGetParticle(x, y, out particle);
+
+ private void ApplyMovementOnly(ChunkCoord coord, int localX, int localY, PrototypeParticle particle, ref int seed, int worldX, int worldY)
+ {
+ if (particle.IsStatic)
+ {
+ return;
+ }
+
+ var (netForceX, netForceY) = GetNetForce(worldX, worldY, particle);
+ netForceX *= GetForceResponse(particle);
+ netForceY *= GetForceResponse(particle);
+ var horizontalPreference = ResolveHorizontalPreference(coord, localX, localY, particle, netForceX);
+ var leftFirst = horizontalPreference < 0;
+ if (netForceX > 0.25f)
+ {
+ leftFirst = false;
+ }
+ else if (netForceX < -0.25f)
+ {
+ leftFirst = true;
+ }
+
+ switch (particle.BehaviorKind)
+ {
+ case ParticleBehaviorKind.Fire:
+ MoveFire(worldX, worldY, leftFirst, ref seed, netForceX);
+ return;
+ case ParticleBehaviorKind.BurningWood:
+ return;
+ case ParticleBehaviorKind.Ember:
+ MoveEmber(worldX, worldY, particle, leftFirst, ref seed, netForceX);
+ return;
+ case ParticleBehaviorKind.Plasma:
+ MovePlasma(worldX, worldY, particle, leftFirst, ref seed, netForceX, netForceY);
+ return;
+ }
+
+ switch (particle.Kind)
+ {
+ case ParticleKind.Solid:
+ TryMoveSolid(coord, localX, localY, worldX, worldY, particle, leftFirst, ref seed, netForceX);
+ break;
+ case ParticleKind.Liquid:
+ TryMoveLiquid(worldX, worldY, particle, leftFirst, ref seed, netForceX);
+ break;
+ case ParticleKind.Gas:
+ TryMoveGas(worldX, worldY, particle, leftFirst, ref seed, netForceX, netForceY);
+ break;
+ }
+ }
+
+ private void StepMovementOnlyParticle(ChunkCoord coord, int localX, int localY, PrototypeParticle particle)
+ {
+ _movementOnlyFastPathCount++;
+ var worldX = ToWorldX(coord, localX);
+ var worldY = ToWorldY(coord, localY);
+ var seed = unchecked(_stepCounter + (worldX * 31) + (worldY * 131));
+ ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY);
+ }
+
+ private bool CanUseMovementFastPath(int x, int y, PrototypeParticle particle)
+ {
+ if (particle.IsStatic)
+ {
+ return false;
+ }
+
+ if (!particle.RequiresFullRuntimeStep)
+ {
+ return true;
+ }
+
+ if (particle.HasLifetimeBehavior || particle.HasBurnBehavior || particle.HasEmissionBehavior || particle.HasSpecialBehavior)
+ {
+ return false;
+ }
+
+ if (particle.HasPressureBehavior && (GetLocalPressure(x, y) > 0.05f || GetFieldForceMagnitude(x, y) > 0.08f || GetPressureDurationAt(x, y) > 0f || GetIntegrityAt(x, y) < particle.Durability))
+ {
+ return false;
+ }
+
+ if (particle.HasPhaseBehavior)
+ {
+ var temperature = GetTemperatureAt(x, y);
+ if ((particle.MeltTypeId != 0 && temperature >= particle.MeltTemperature) ||
+ (particle.EvaporateTypeId != 0 && temperature >= particle.EvaporateTemperature) ||
+ (particle.FreezeTypeId != 0 && temperature <= particle.FreezeTemperature) ||
+ (particle.SolidifyTypeId != 0 &&
+ (temperature <= particle.SolidifyTemperature ||
+ (particle.MotionType == PrototypeParticleType.Steam && GetPressureLoad(x, y) >= MathF.Max(0.9f, particle.PressureThreshold * 0.35f)))))
+ {
+ return false;
+ }
+ }
+
+ if (particle.HasReactionBehavior && HasPotentialReactiveNeighbor(x, y, particle))
+ {
+ return false;
+ }
+
+ if (particle.HasThermalBehavior)
+ {
+ var temperature = GetTemperatureAt(x, y);
+ if (CanThrottleGasRuntime(x, y, particle, temperature))
+ {
+ return true;
+ }
+
+ if (MathF.Abs(temperature - _ambientTemperature) > 0.5f)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryMoveSolid(ChunkCoord coord, int localX, int localY, int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX)
+ {
+ if (TrySkipBlockedSolid(coord, localX, localY, netForceX))
+ {
+ return false;
+ }
+
+ if (TryMoveSolidDown(x, y, particle))
+ {
+ return true;
+ }
+
+ var canMoveLeft = CanMoveIntoEmpty(x - 1, y + 1);
+ var canMoveRight = CanMoveIntoEmpty(x + 1, y + 1);
+ if (!canMoveLeft && !canMoveRight)
+ {
+ CacheBlockedSolid(coord, localX, localY);
+ return false;
+ }
+
+ if (!CanSlipSolid(particle, ref seed, netForceX))
+ {
+ RecordStalledMovable();
+ KeepChunkAwakeAt(x, y);
+ return false;
+ }
+
+ if (leftFirst)
+ {
+ if (canMoveLeft && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return true;
+ }
+
+ if (canMoveRight && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (canMoveRight && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return true;
+ }
+
+ if (canMoveLeft && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return true;
+ }
+ }
+
+ RecordStalledMovable();
+ KeepChunkAwakeAt(x, y);
+ return false;
+ }
+
+ private bool TryMoveLiquid(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX)
+ {
+ if (TryMoveLiquidDown(x, y, particle))
+ {
+ return true;
+ }
+
+ var windPreferred = netForceX > 0.2f ? 1 : netForceX < -0.2f ? -1 : 0;
+ if (windPreferred != 0 && CanFlowLiquid(particle, ref seed, lateral: true, netForceX) && TryMoveEmpty(x, y, x + windPreferred, y))
+ {
+ return true;
+ }
+
+ var diagonalAllowed = CanFlowLiquid(particle, ref seed, lateral: false, netForceX);
+ var lateralAllowed = diagonalAllowed || CanFlowLiquid(particle, ref seed, lateral: true, netForceX);
+ var openLeftDiagonal = CanMoveIntoEmpty(x - 1, y + 1);
+ var openRightDiagonal = CanMoveIntoEmpty(x + 1, y + 1);
+ var openLeftLateral = CanMoveIntoEmpty(x - 1, y);
+ var openRightLateral = CanMoveIntoEmpty(x + 1, y);
+ if (leftFirst)
+ {
+ if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return true;
+ }
+
+ if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return true;
+ }
+
+ if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return true;
+ }
+
+ if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return true;
+ }
+
+ if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return true;
+ }
+
+ if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return true;
+ }
+
+ if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return true;
+ }
+ }
+
+ if (openLeftDiagonal || openRightDiagonal || openLeftLateral || openRightLateral)
+ {
+ RecordStalledMovable();
+ KeepChunkAwakeAt(x, y);
+ }
+
+ return false;
+ }
+
+ private bool TryMoveGas(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX, float netForceY)
+ {
+ var suppressRiseAndDiagonal = TrySkipGasRetry(x, y);
+ if (suppressRiseAndDiagonal && CanMoveIntoEmpty(x, y - 1))
+ {
+ ClearGasRetry(x, y);
+ suppressRiseAndDiagonal = false;
+ }
+
+ var openAbove = false;
+ if (!suppressRiseAndDiagonal)
+ {
+ openAbove = CanMoveIntoEmpty(x, y - 1);
+ if (openAbove)
+ {
+ ClearBlockedGasRise(x, y);
+ ClearGasRetry(x, y);
+ if (CanRiseGas(particle, ref seed, netForceY) && TryMoveEmpty(x, y, x, y - 1))
+ {
+ return true;
+ }
+ }
+ else if (!TrySkipBlockedGasRise(x, y) && InBounds(x, y - 1))
+ {
+ CacheBlockedGasRise(x, y);
+ }
+ }
+
+ var windPreferred = netForceX > 0.15f ? 1 : netForceX < -0.15f ? -1 : 0;
+ if (windPreferred != 0 && CanDriftGas(particle, ref seed, netForceX, lateral: true) && TryMoveEmpty(x, y, x + windPreferred, y))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ var diagonalAllowed = CanDriftGas(particle, ref seed, netForceX, lateral: false);
+ var lateralAllowed = diagonalAllowed || CanDriftGas(particle, ref seed, netForceX, lateral: true);
+ if (suppressRiseAndDiagonal)
+ {
+ diagonalAllowed = false;
+ }
+
+ var openLeftDiagonal = !suppressRiseAndDiagonal && CanMoveIntoEmpty(x - 1, y - 1);
+ var openRightDiagonal = !suppressRiseAndDiagonal && CanMoveIntoEmpty(x + 1, y - 1);
+ var openLeftLateral = CanMoveIntoEmpty(x - 1, y);
+ var openRightLateral = CanMoveIntoEmpty(x + 1, y);
+ if (leftFirst)
+ {
+ if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y - 1))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y - 1))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+ }
+ else
+ {
+ if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y - 1))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y - 1))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+
+ if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y))
+ {
+ ClearGasRetry(x, y);
+ return true;
+ }
+ }
+
+ if (!openAbove && !openLeftDiagonal && !openRightDiagonal)
+ {
+ CacheGasRetry(x, y, (openLeftLateral || openRightLateral) ? 1 : 2);
+ }
+ else
+ {
+ ClearGasRetry(x, y);
+ }
+
+ if (openLeftDiagonal || openRightDiagonal || openLeftLateral || openRightLateral)
+ {
+ RecordStalledMovable();
+ KeepChunkAwakeAt(x, y);
+ }
+
+ return false;
+ }
+
+ private void MoveFire(int x, int y, bool leftFirst, ref int seed, float netForceX)
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ var preferredRight = netForceX > 0.05f ? true : netForceX < -0.05f ? false : !leftFirst;
+ if (preferredRight)
+ {
+ if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+ else
+ {
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+
+ if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+ }
+
+ if (NextChance(ref seed) <= 0.15f)
+ {
+ RemoveParticle(x, y);
+ }
+ }
+
+ private void MoveEmber(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX)
+ {
+ if (NextChance(ref seed) <= MathF.Max(0.1f, particle.UpwardBias))
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ if (leftFirst)
+ {
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+ else if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+ }
+
+ TryMoveSolid(GetChunkCoord(x, y), GetLocalCoord(x, y).LocalX, GetLocalCoord(x, y).LocalY, x, y, particle, leftFirst, ref seed, netForceX);
+ }
+
+ private void MovePlasma(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX, float netForceY)
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ var lateralBias = MathF.Max(0.15f, particle.SideDriftBias);
+ if (NextChance(ref seed) <= lateralBias)
+ {
+ var dir = netForceX > 0.05f ? 1 : netForceX < -0.05f ? -1 : leftFirst ? -1 : 1;
+ if (TryMoveEmpty(x, y, x + dir, y - 1) || TryMoveEmpty(x, y, x + dir, y))
+ {
+ return;
+ }
+ }
+
+ TryMoveGas(x, y, particle, leftFirst, ref seed, netForceX, netForceY);
+ }
+
+ private bool TryMoveSolidDown(int x, int y, PrototypeParticle particle)
+ {
+ if (TryMoveEmpty(x, y, x, y + 1))
+ {
+ return true;
+ }
+
+ if (!TryGetParticle(x, y + 1, out var target))
+ {
+ return false;
+ }
+
+ if ((target.Kind is ParticleKind.Liquid or ParticleKind.Gas) && particle.Mass > target.Mass)
+ {
+ RecordSwapAttempt();
+ return SwapParticles(x, y, x, y + 1);
+ }
+
+ return false;
+ }
+
+ private bool TryMoveLiquidDown(int x, int y, PrototypeParticle particle)
+ {
+ if (TryMoveEmpty(x, y, x, y + 1))
+ {
+ return true;
+ }
+
+ if (!TryGetParticle(x, y + 1, out var target))
+ {
+ return false;
+ }
+
+ if (target.Kind == ParticleKind.Gas && particle.Mass > target.Mass)
+ {
+ RecordSwapAttempt();
+ return SwapParticles(x, y, x, y + 1);
+ }
+
+ if (particle.IsMolten && target.Kind == ParticleKind.Liquid && particle.Mass > target.Mass)
+ {
+ RecordSwapAttempt();
+ return SwapParticles(x, y, x, y + 1);
+ }
+
+ return false;
+ }
+
+ private bool TryMoveEmpty(int fromX, int fromY, int toX, int toY)
+ {
+ RecordMoveAttempt(toX - fromX, toY - fromY);
+ if (!InBounds(toX, toY))
+ {
+ return false;
+ }
+
+ if (TryGetCellPage(fromX, fromY, out var sourceCoord, out var sourcePage, out var sourceLocalX, out var sourceLocalY))
+ {
+ var destinationCoord = GetChunkCoord(toX, toY);
+ if (destinationCoord == sourceCoord)
+ {
+ var (destinationLocalX, destinationLocalY) = GetLocalCoord(toX, toY);
+ if (!sourcePage.IsOccupied(destinationLocalX, destinationLocalY))
+ {
+ return MoveParticleWithinPage(sourceCoord, sourcePage, sourceLocalX, sourceLocalY, destinationLocalX, destinationLocalY, fromX, fromY, toX, toY);
+ }
+
+ return false;
+ }
+ }
+
+ if (!CanMoveIntoEmpty(toX, toY))
+ {
+ return false;
+ }
+
+ return MoveParticle(fromX, fromY, toX, toY);
+ }
+
+ private bool CanMoveIntoEmpty(int x, int y) => InBounds(x, y) && !HasParticle(x, y);
+
+ private int ResolveHorizontalPreference(ChunkCoord coord, int localX, int localY, PrototypeParticle particle, float netForceX)
+ {
+ if (netForceX > 0.15f)
+ {
+ return 1;
+ }
+
+ if (netForceX < -0.15f)
+ {
+ return -1;
+ }
+
+ var page = _cellPages[coord];
+ var driftState = page.GetDriftState(localX, localY);
+ if (driftState != 0)
+ {
+ return driftState;
+ }
+
+ return PreferLeft(unchecked((coord.X * 92821) + (coord.Y * 68917) + (localX * 31) + (localY * 131) + _stepCounter + particle.TypeId))
+ ? -1
+ : 1;
+ }
+
+ private (float X, float Y) GetNetForce(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ var gradientXOnly = SamplePressure(x - 1, y) - SamplePressure(x + 1, y);
+ var gradientYOnly = SamplePressure(x, y + 1) - SamplePressure(x, y - 1);
+ return (gradientXOnly * 0.12f * particle.PressureResponse, gradientYOnly * 0.08f * particle.PressureResponse);
+ }
+
+ var cell = page.GetCell(localX, localY);
+ var forceX = cell.WindX + cell.ForceX;
+ var forceY = cell.WindY + cell.ForceY;
+ var pressureGradientX = SamplePressure(x - 1, y) - SamplePressure(x + 1, y);
+ var pressureGradientY = SamplePressure(x, y + 1) - SamplePressure(x, y - 1);
+ forceX += pressureGradientX * 0.12f * particle.PressureResponse;
+ forceY += pressureGradientY * 0.08f * particle.PressureResponse;
+ if (particle.MotionType == PrototypeParticleType.Steam)
+ {
+ forceY -= 0.2f;
+ }
+
+ if (MathF.Abs(cell.Pressure) > 0.05f)
+ {
+ forceY += particle.MotionType == PrototypeParticleType.Steam
+ ? -cell.Pressure * 0.05f * particle.PressureResponse
+ : cell.Pressure * 0.05f * particle.PressureResponse;
+ }
+
+ return (forceX, forceY);
+ }
+
+ private static float GetEffectiveVelocity(PrototypeParticle particle)
+ {
+ if (particle.Velocity > 0f)
+ {
+ return particle.Velocity;
+ }
+
+ return particle.Kind switch
+ {
+ ParticleKind.Solid => 0.45f,
+ ParticleKind.Liquid => 0.35f,
+ ParticleKind.Gas => 0.25f,
+ _ => 0.35f,
+ };
+ }
+
+ private static float GetForceResponse(PrototypeParticle particle)
+ {
+ var mobility = 0.35f + GetEffectiveVelocity(particle);
+ var damping = MathF.Max(0.25f, (particle.Mass * 0.85f) + (particle.Friction * 0.35f) + (particle.Viscosity * 0.45f));
+ return Math.Clamp((mobility / damping) * particle.ForceResponseMultiplier, 0.15f, 2f);
+ }
+
+ private static bool CanSlipSolid(PrototypeParticle particle, ref int seed, float netForceX)
+ {
+ if (MathF.Abs(netForceX) > 0.45f)
+ {
+ return true;
+ }
+
+ var chance = Math.Clamp(0.2f + (GetEffectiveVelocity(particle) * 0.95f) - (particle.Friction * 0.35f), 0.1f, 0.95f);
+ return NextChance(ref seed) <= chance;
+ }
+
+ private static bool CanFlowLiquid(PrototypeParticle particle, ref int seed, bool lateral, float netForceX)
+ {
+ var forceBonus = MathF.Min(0.25f, MathF.Abs(netForceX) * 0.08f);
+ var velocity = GetEffectiveVelocity(particle);
+ var baseChance = lateral
+ ? 0.18f + (velocity * 0.95f) - (particle.Viscosity * 0.42f) - (particle.Friction * 0.08f)
+ : 0.28f + (velocity * 0.9f) - (particle.Viscosity * 0.28f) - (particle.Friction * 0.05f);
+ var flowScale = lateral ? particle.LateralFlowMultiplier : particle.DiagonalFlowMultiplier;
+ var chance = Math.Clamp((baseChance * flowScale) + forceBonus, 0.03f, 0.98f);
+ return NextChance(ref seed) <= chance;
+ }
+
+ private static bool CanRiseGas(PrototypeParticle particle, ref int seed, float netForceY)
+ {
+ if (netForceY < -0.65f)
+ {
+ return true;
+ }
+
+ var velocity = GetEffectiveVelocity(particle);
+ var buoyancy = MathF.Max(0f, -netForceY) * 0.08f;
+ var chance = Math.Clamp(0.1f + (velocity * 1.8f) - (particle.Viscosity * 0.22f) + buoyancy, 0.05f, 0.99f);
+ return NextChance(ref seed) <= chance;
+ }
+
+ private static bool CanDriftGas(PrototypeParticle particle, ref int seed, float netForceX, bool lateral)
+ {
+ var forceBonus = MathF.Min(0.28f, MathF.Abs(netForceX) * 0.08f);
+ var velocity = GetEffectiveVelocity(particle);
+ var baseChance = lateral
+ ? 0.18f + (velocity * 1.15f) - (particle.Viscosity * 0.1f)
+ : 0.22f + (velocity * 1.2f) - (particle.Viscosity * 0.16f);
+ var chance = Math.Clamp(baseChance + forceBonus - (particle.Friction * 0.04f), 0.05f, 0.99f);
+ return NextChance(ref seed) <= chance;
+ }
+
+ private static sbyte GetDefaultDriftDirection(int x, int y) => PreferLeft(unchecked((x * 73856093) ^ (y * 19349663))) ? (sbyte)-1 : (sbyte)1;
+
+ private bool TrySkipBlockedSolid(ChunkCoord coord, int localX, int localY, float netForceX)
+ {
+ if (MathF.Abs(netForceX) > 0.1f ||
+ localX <= 0 ||
+ localX >= _config.ChunkWidth - 1 ||
+ localY >= _config.ChunkHeight - 1 ||
+ !_cellPages.TryGetValue(coord, out var page))
+ {
+ return false;
+ }
+
+ var signature = ComputeSolidBlockSignature(page, localY);
+ return signature != 0 && page.GetBlockedSolidSignature(localX, localY) == signature;
+ }
+
+ private void CacheBlockedSolid(ChunkCoord coord, int localX, int localY)
+ {
+ if (localX <= 0 || localX >= _config.ChunkWidth - 1 || localY >= _config.ChunkHeight - 1)
+ {
+ return;
+ }
+
+ if (_cellPages.TryGetValue(coord, out var page))
+ {
+ page.SetBlockedSolidSignature(localX, localY, ComputeSolidBlockSignature(page, localY));
+ }
+ }
+
+ private static int ComputeSolidBlockSignature(ChunkCellPage page, int localY)
+ {
+ if (localY < 0 || localY >= page.Height - 1)
+ {
+ return 0;
+ }
+
+ return HashCode.Combine(page.GetRowRevision(localY), page.GetRowRevision(localY + 1));
+ }
+
+ private bool CanThrottleGasRuntime(int x, int y, PrototypeParticle particle, float temperature)
+ {
+ if (particle.Kind != ParticleKind.Gas ||
+ particle.HasLifetimeBehavior ||
+ particle.HasBurnBehavior ||
+ particle.HasEmissionBehavior ||
+ particle.HasSpecialBehavior ||
+ particle.HasReactionBehavior)
+ {
+ return false;
+ }
+
+ var requiresPressureSensitiveRuntime = particle.HasPressureBehavior || particle.HasPhaseBehavior;
+ if (requiresPressureSensitiveRuntime &&
+ (GetPressureLoad(x, y) > 0.2f || GetPressureDurationAt(x, y) > 0f))
+ {
+ return false;
+ }
+
+ if (MathF.Abs(temperature - _ambientTemperature) <= 0.5f)
+ {
+ return false;
+ }
+
+ if (HasThermallyActiveNeighbor(x, y, temperature, out var hasPassiveBoundaryNeighbor))
+ {
+ return false;
+ }
+
+ if (hasPassiveBoundaryNeighbor)
+ {
+ return ((_stepCounter + (x * 3) + (y * 5)) & 3) != 0;
+ }
+
+ return true;
+ }
+
+ private bool HasThermallyActiveNeighbor(int x, int y, float localTemperature, out bool hasPassiveBoundaryNeighbor)
+ {
+ hasPassiveBoundaryNeighbor = false;
+ Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)];
+ for (var i = 0; i < neighbors.Length; i++)
+ {
+ var (nx, ny) = neighbors[i];
+ if (!TryGetParticle(nx, ny, out var neighbor))
+ {
+ continue;
+ }
+
+ var neighborTemperature = GetTemperatureAt(nx, ny);
+ if (MathF.Abs(neighborTemperature - localTemperature) > 8f)
+ {
+ return true;
+ }
+
+ if (neighbor.HasBurnBehavior ||
+ neighbor.HasEmissionBehavior ||
+ neighbor.HasSpecialBehavior ||
+ neighbor.HasFlag(PrototypeParticleFlags.HotSource))
+ {
+ return true;
+ }
+
+ if (neighbor.HasReactionBehavior || neighbor.HasPhaseBehavior || neighbor.HasPressureBehavior)
+ {
+ return true;
+ }
+
+ if (neighbor.Kind == ParticleKind.Gas)
+ {
+ continue;
+ }
+
+ if (neighbor.IsStatic || neighbor.MotionType == PrototypeParticleType.Wall)
+ {
+ hasPassiveBoundaryNeighbor = true;
+ continue;
+ }
+
+ if (neighbor.Conductivity > 0.25f || MathF.Abs(neighborTemperature - localTemperature) > 24f)
+ {
+ hasPassiveBoundaryNeighbor = true;
+ continue;
+ }
+ }
+
+ return false;
+ }
+
+ private bool TrySkipBlockedGasRise(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || localY <= 0)
+ {
+ return false;
+ }
+
+ if (!page.IsOccupied(localX, localY - 1))
+ {
+ return false;
+ }
+
+ var signature = ComputeGasRiseBlockSignature(page, localX, localY);
+ return signature != 0 && page.GetBlockedGasRiseSignature(localX, localY) == signature;
+ }
+
+ private void CacheBlockedGasRise(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || localY <= 0)
+ {
+ return;
+ }
+
+ if (!page.IsOccupied(localX, localY - 1))
+ {
+ page.SetBlockedGasRiseSignature(localX, localY, 0);
+ return;
+ }
+
+ page.SetBlockedGasRiseSignature(localX, localY, ComputeGasRiseBlockSignature(page, localX, localY));
+ }
+
+ private void ClearBlockedGasRise(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ page.SetBlockedGasRiseSignature(localX, localY, 0);
+ }
+
+ private static int ComputeGasRiseBlockSignature(ChunkCellPage page, int localX, int localY)
+ {
+ if (localY <= 0 || localY >= page.Height)
+ {
+ return 0;
+ }
+
+ return HashCode.Combine(localX, page.GetRowRevision(localY), page.GetRowRevision(localY - 1));
+ }
+
+ private bool TrySkipGasRetry(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ var signature = ComputeGasRetrySignature(page, localX, localY);
+ if (signature == 0)
+ {
+ page.ClearGasRetryState(localX, localY);
+ return false;
+ }
+
+ if (page.GetGasRetryUntilStep(localX, localY) > _stepCounter && page.GetGasRetrySignature(localX, localY) == signature)
+ {
+ return true;
+ }
+
+ if (page.GetGasRetrySignature(localX, localY) != signature)
+ {
+ page.ClearGasRetryState(localX, localY);
+ }
+
+ return false;
+ }
+
+ private void CacheGasRetry(int x, int y, int cooldownSteps)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ var signature = ComputeGasRetrySignature(page, localX, localY);
+ if (signature == 0)
+ {
+ page.ClearGasRetryState(localX, localY);
+ return;
+ }
+
+ page.SetGasRetryState(localX, localY, signature, _stepCounter + Math.Max(1, cooldownSteps));
+ }
+
+ private void ClearGasRetry(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ page.ClearGasRetryState(localX, localY);
+ }
+
+ private static int ComputeGasRetrySignature(ChunkCellPage page, int localX, int localY)
+ {
+ if (localY <= 0 || localY >= page.Height)
+ {
+ return 0;
+ }
+
+ return HashCode.Combine(
+ localX,
+ page.GetRowRevision(localY),
+ page.GetRowRevision(localY - 1),
+ page.GetOccupiedRowCount(localY),
+ page.GetOccupiedRowCount(localY - 1));
+ }
+
+ private static bool PreferLeft(int seed) => ((seed ^ 0x5bd1e995) & 1) == 0;
+
+ private static float NextChance(ref int seed)
+ {
+ seed = unchecked((seed * 1103515245) + 12345);
+ return ((seed >> 8) & 0x00FFFFFF) / 16777215f;
+ }
+
+ private static sbyte ResolveDriftAfterMove(int fromX, int toX, sbyte currentDrift)
+ {
+ if (toX > fromX)
+ {
+ return 1;
+ }
+
+ if (toX < fromX)
+ {
+ return -1;
+ }
+
+ return currentDrift;
+ }
+
+ private static PrototypeParticle CreateLegacyParticle(PrototypeParticleType type) => type switch
+ {
+ PrototypeParticleType.Sand => new PrototypeParticle(1, "sand", type, ParticleKind.Solid, ParticleBehaviorKind.None, 214, 188, 96, 1.4f, 0.65f, 0.18f, 0.08f),
+ PrototypeParticleType.Water => new PrototypeParticle(2, "water", type, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike),
+ PrototypeParticleType.Steam => new PrototypeParticle(3, "steam", type, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 2, PressureThreshold: 1.2f, InitialTemperature: 110f),
+ PrototypeParticleType.Wall => new PrototypeParticle(4, "wall", type, ParticleKind.Solid, ParticleBehaviorKind.None, 96, 96, 104, 100f, 0f, 1f, 1f, IsStatic: true),
+ _ => default,
+ };
+
+ private void KeepChunkAwakeAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ page.IsActive = true;
+ page.LastTouchedFrame = _stepCounter;
+ page.RequestFutureSteps(2);
+ page.MarkDirtyBand(localX, localY);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs
new file mode 100644
index 0000000..7aff53e
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs
@@ -0,0 +1,186 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private void TickCellAging(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ page.SetCellAge(localX, localY, page.GetCellAge(localX, localY) + 1f);
+ if (page.GetIntegrity(localX, localY) < particle.Durability)
+ {
+ page.SetIntegrity(localX, localY, MathF.Min(particle.Durability, page.GetIntegrity(localX, localY) + MathF.Max(0.02f, particle.Hardness * 0.03f)));
+ }
+ }
+
+ private bool TickLifetime(int x, int y, ref PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ var lifetime = page.GetLifetime(localX, localY);
+ if (lifetime > 0f)
+ {
+ lifetime -= 1f;
+ page.SetLifetime(localX, localY, lifetime);
+ }
+
+ if (lifetime <= 0f && particle.DefaultLifetime > 0f)
+ {
+ if (particle.MotionType == PrototypeParticleType.Steam && particle.SolidifyTypeId != 0 && TryResolveProfile(particle.SolidifyTypeId, out var condensed))
+ {
+ var currentTemperature = GetTemperatureAt(x, y);
+ ReplaceParticle(x, y, condensed);
+ if (TryGetCellPage(x, y, out _, out var targetPage, out var targetLocalX, out var targetLocalY))
+ {
+ targetPage.SetTemperature(targetLocalX, targetLocalY, MathF.Max(condensed.InitialTemperature, MathF.Min(currentTemperature, 95f)));
+ }
+
+ particle = condensed;
+ return false;
+ }
+
+ RemoveParticle(x, y);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void AutoIgnite(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ if (page.GetBurning(localX, localY) == 0 &&
+ particle.BurnTemperature > 0f &&
+ GetTemperatureAt(x, y) >= particle.BurnTemperature &&
+ particle.Flamability > 0f)
+ {
+ page.SetBurning(localX, localY, 1);
+ page.SetBurnTime(localX, localY, MathF.Max(particle.BurnDuration, 1f));
+ }
+ }
+
+ private void ApplyRuntimeEmissionAndProduction(int x, int y, PrototypeParticle particle, ref int seed)
+ {
+ var isEffectEmitter =
+ particle.BehaviorKind is ParticleBehaviorKind.Fire or ParticleBehaviorKind.BurningWood or ParticleBehaviorKind.Ember or ParticleBehaviorKind.Plasma;
+ var emission = particle.HeatEmission;
+ if (particle.BehaviorKind == ParticleBehaviorKind.Fire && emission <= 0f)
+ {
+ emission = 24f;
+ }
+ else if (particle.BehaviorKind == ParticleBehaviorKind.Plasma && emission <= 0f)
+ {
+ emission = 80f;
+ }
+ else if (particle.BehaviorKind == ParticleBehaviorKind.Ember && emission <= 0f)
+ {
+ emission = 12f;
+ }
+ else if (particle.IsMolten && emission <= 0f)
+ {
+ emission = particle.TypeId != 0 ? 28f : 0f;
+ }
+
+ if (emission > 0f)
+ {
+ ApplyHeatEmission(x, y, emission);
+ }
+
+ if ((isEffectEmitter || IsBurningAt(x, y)) && particle.ProduceTypeId != 0 && NextChance(ref seed) <= 0.08f)
+ {
+ TrySpawnAtOffset(x, y, 0, -1, particle.ProduceTypeId);
+ }
+
+ if (particle.SmokeSpawnChance > 0f && isEffectEmitter && TryFindProfileById("smoke", out var smoke) && NextChance(ref seed) <= particle.SmokeSpawnChance)
+ {
+ TrySpawnAtOffset(x, y, 0, -1, smoke.TypeId);
+ }
+
+ if (particle.EmberSpawnChance > 0f && isEffectEmitter && TryFindProfileById("ember", out var ember) && NextChance(ref seed) <= particle.EmberSpawnChance)
+ {
+ TrySpawnAtOffset(x, y, 0, -1, ember.TypeId);
+ }
+ }
+
+ private bool TickBurning(int x, int y, ref PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || page.GetBurning(localX, localY) == 0)
+ {
+ return false;
+ }
+
+ page.SetTemperature(localX, localY, page.GetTemperature(localX, localY) + 1.5f + (particle.HeatEmission * 0.02f));
+ page.SetBurnTime(localX, localY, page.GetBurnTime(localX, localY) - MathF.Max(0.05f, particle.BurnRate));
+ if (page.GetBurnTime(localX, localY) > 0f)
+ {
+ return false;
+ }
+
+ RemoveParticle(x, y);
+ return true;
+ }
+
+ private bool TickSpecialBehavior(int x, int y, PrototypeParticle particle, ref int seed)
+ {
+ var temperature = GetTemperatureAt(x, y);
+ switch (particle.BehaviorKind)
+ {
+ case ParticleBehaviorKind.Fire:
+ if (temperature < 120f && NextChance(ref seed) <= 0.1f)
+ {
+ RemoveParticle(x, y);
+ return true;
+ }
+ break;
+ case ParticleBehaviorKind.Ember:
+ if (temperature < 60f && NextChance(ref seed) <= 0.1f)
+ {
+ RemoveParticle(x, y);
+ return true;
+ }
+ break;
+ case ParticleBehaviorKind.BurningWood:
+ if (particle.EmberSpawnChance > 0f && TryFindProfileById("ember", out var ember) && NextChance(ref seed) <= particle.EmberSpawnChance * 0.5f)
+ {
+ TrySpawnAtOffset(x, y, 0, -1, ember.TypeId);
+ }
+ break;
+ }
+
+ return false;
+ }
+
+ private bool IsBurningAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ return page.GetBurning(localX, localY) != 0;
+ }
+
+ private bool TrySpawnAtOffset(int x, int y, int offsetX, int offsetY, ushort typeId)
+ {
+ var tx = x + offsetX;
+ var ty = y + offsetY;
+ if (!InBounds(tx, ty) || HasParticle(tx, ty) || !TryResolveProfile(typeId, out var particle))
+ {
+ return false;
+ }
+
+ return AddParticle(tx, ty, particle);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs
new file mode 100644
index 0000000..2abf131
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs
@@ -0,0 +1,125 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private bool ApplyPressureResponse(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return false;
+ }
+
+ var localPressure = GetLocalPressure(x, y);
+ var threshold = particle.PressureThreshold;
+ if (threshold <= 0f)
+ {
+ threshold = particle.IsStatic
+ ? 2.5f + particle.PressureResistance + particle.PressureTolerance
+ : 1.4f + (particle.PressureResistance * 0.35f) + particle.PressureTolerance;
+ }
+
+ if (localPressure < threshold)
+ {
+ var reducedDuration = MathF.Max(0f, page.GetPressureDuration(localX, localY) - 1f);
+ page.SetPressureDuration(localX, localY, reducedDuration);
+ var maxIntegrity = particle.Durability;
+ if (page.GetIntegrity(localX, localY) < maxIntegrity)
+ {
+ page.SetIntegrity(localX, localY, MathF.Min(maxIntegrity, page.GetIntegrity(localX, localY) + MathF.Max(0.2f, particle.Hardness * 0.1f)));
+ }
+
+ return false;
+ }
+
+ var pressureDuration = page.GetPressureDuration(localX, localY) + 1f;
+ page.SetPressureDuration(localX, localY, pressureDuration);
+ var overload = MathF.Max(0f, localPressure - threshold);
+ var damageScale = 1f / MathF.Max(0.35f, 0.4f + particle.Hardness + (particle.PressureResistance * 0.1f));
+ page.SetIntegrity(localX, localY, page.GetIntegrity(localX, localY) - MathF.Max(0.15f, 0.45f + overload) * damageScale);
+ var requiredDuration = particle.PressureThresholdDuration > 0 ? particle.PressureThresholdDuration : (short)2;
+ if (pressureDuration < requiredDuration)
+ {
+ return false;
+ }
+
+ if (particle.BrokenTypeId != 0 && TryResolveProfile(particle.BrokenTypeId, out var broken))
+ {
+ ReplaceParticle(x, y, broken);
+ return true;
+ }
+
+ if (particle.Flamability > 0f && GetTemperatureAt(x, y) >= MathF.Max(60f, particle.BurnTemperature * 0.5f))
+ {
+ page.SetBurning(localX, localY, 1);
+ page.SetBurnTime(localX, localY, MathF.Max(1f, particle.BurnDuration));
+ return false;
+ }
+
+ if (page.GetIntegrity(localX, localY) <= 0f)
+ {
+ if (!particle.IsStatic && particle.Kind == ParticleKind.Solid)
+ {
+ RemoveParticle(x, y);
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private float GetLocalPressure(int x, int y) => MathF.Abs(SamplePressure(x, y));
+
+ private float GetPressureLoad(int x, int y)
+ {
+ var local = GetLocalPressure(x, y);
+ var neighborLoad =
+ (MathF.Abs(SamplePressure(x - 1, y)) +
+ MathF.Abs(SamplePressure(x + 1, y)) +
+ MathF.Abs(SamplePressure(x, y - 1)) +
+ MathF.Abs(SamplePressure(x, y + 1))) * 0.5f;
+ return Math.Max(local, neighborLoad);
+ }
+
+ private float GetFieldForceMagnitude(int x, int y)
+ {
+ var wind = GetWindAtCell(x, y);
+ var force = GetForceAtCell(x, y);
+ var totalX = wind.X + force.X;
+ var totalY = wind.Y + force.Y;
+ return MathF.Sqrt((totalX * totalX) + (totalY * totalY));
+ }
+
+ private float SamplePressure(int x, int y)
+ {
+ if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return 0f;
+ }
+
+ return page.GetCell(localX, localY).Pressure;
+ }
+
+ private float GetPressureDurationAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return 0f;
+ }
+
+ return page.GetPressureDuration(localX, localY);
+ }
+
+ private float GetIntegrityAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return 0f;
+ }
+
+ return page.GetIntegrity(localX, localY);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs
new file mode 100644
index 0000000..d80f8f7
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs
@@ -0,0 +1,174 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private bool ApplyLocalReaction(int x, int y, PrototypeParticle particle, ref int seed)
+ {
+ Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)];
+ for (var i = 0; i < neighbors.Length; i++)
+ {
+ var (nx, ny) = neighbors[i];
+ if (!TryGetParticle(nx, ny, out var neighbor))
+ {
+ continue;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.WaterLike) && neighbor.HydrateTargetTypeId != 0 && TryResolveProfile(neighbor.HydrateTargetTypeId, out var hydrated))
+ {
+ ReplaceParticle(nx, ny, hydrated);
+ RemoveParticle(x, y);
+ return true;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.FireLike) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike))
+ {
+ RemoveParticle(x, y);
+ if (neighbor.EvaporateTypeId != 0 && TryResolveProfile(neighbor.EvaporateTypeId, out var steam))
+ {
+ ReplaceParticle(nx, ny, steam);
+ }
+
+ return true;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.HotSource) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike))
+ {
+ if (particle.SolidifyTypeId != 0 && TryResolveProfile(particle.SolidifyTypeId, out var solidifiedSelf))
+ {
+ ReplaceParticle(x, y, solidifiedSelf);
+ }
+
+ if (neighbor.EvaporateTypeId != 0 && TryResolveProfile(neighbor.EvaporateTypeId, out var vaporizedNeighbor))
+ {
+ ReplaceParticle(nx, ny, vaporizedNeighbor);
+ }
+ else
+ {
+ RemoveParticle(nx, ny);
+ }
+
+ return true;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.Acidic) &&
+ !neighbor.IsStatic &&
+ !neighbor.HasFlag(PrototypeParticleFlags.Acidic) &&
+ NextChance(ref seed) <= 0.08f)
+ {
+ RemoveParticle(nx, ny);
+ if (NextChance(ref seed) <= 0.18f)
+ {
+ RemoveParticle(x, y);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private void ApplyPhaseTransition(int x, int y, ref PrototypeParticle particle)
+ {
+ var localTemperature = GetTemperatureAt(x, y);
+ var localPressure = GetPressureLoad(x, y);
+ var fieldForce = GetFieldForceMagnitude(x, y);
+ if (fieldForce > 0f)
+ {
+ localPressure += fieldForce * 0.2f;
+ }
+
+ var cellAge = GetCellAgeAt(x, y);
+ if (particle.PhaseTransitionHysteresis > 0f && cellAge < particle.PhaseTransitionHysteresis)
+ {
+ return;
+ }
+
+ if (particle.EvaporateTypeId != 0 &&
+ localTemperature >= particle.EvaporateTemperature &&
+ TryResolveProfile(particle.EvaporateTypeId, out var evaporated))
+ {
+ ReplaceParticle(x, y, evaporated);
+ particle = evaporated;
+ return;
+ }
+
+ if (particle.MeltTypeId != 0 &&
+ localTemperature >= particle.MeltTemperature &&
+ TryResolveProfile(particle.MeltTypeId, out var melted))
+ {
+ ReplaceParticle(x, y, melted);
+ particle = melted;
+ return;
+ }
+
+ if (particle.FreezeTypeId != 0 &&
+ localTemperature <= particle.FreezeTemperature &&
+ TryResolveProfile(particle.FreezeTypeId, out var frozen))
+ {
+ ReplaceParticle(x, y, frozen);
+ particle = frozen;
+ return;
+ }
+
+ if (particle.SolidifyTypeId != 0 &&
+ (localTemperature <= particle.SolidifyTemperature ||
+ (particle.MotionType == PrototypeParticleType.Steam && localPressure >= MathF.Max(0.9f, particle.PressureThreshold * 0.35f))) &&
+ TryResolveProfile(particle.SolidifyTypeId, out var condensed))
+ {
+ ReplaceParticle(x, y, condensed);
+ if (TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ page.SetTemperature(localX, localY, MathF.Max(condensed.InitialTemperature, MathF.Min(localTemperature, 95f)));
+ }
+
+ particle = condensed;
+ }
+ }
+
+ private float GetCellAgeAt(int x, int y)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return 0f;
+ }
+
+ return page.GetCellAge(localX, localY);
+ }
+
+ private bool HasPotentialReactiveNeighbor(int x, int y, PrototypeParticle particle)
+ {
+ Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)];
+ for (var i = 0; i < neighbors.Length; i++)
+ {
+ var (nx, ny) = neighbors[i];
+ if (!TryGetParticle(nx, ny, out var neighbor))
+ {
+ continue;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.WaterLike) && neighbor.HydrateTargetTypeId != 0)
+ {
+ return true;
+ }
+
+ if (particle.HydrateTargetTypeId != 0 && neighbor.HasFlag(PrototypeParticleFlags.WaterLike))
+ {
+ return true;
+ }
+
+ if ((particle.HasFlag(PrototypeParticleFlags.FireLike) || particle.HasFlag(PrototypeParticleFlags.HotSource)) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike))
+ {
+ return true;
+ }
+
+ if (particle.HasFlag(PrototypeParticleFlags.Acidic) && !neighbor.IsStatic && !neighbor.HasFlag(PrototypeParticleFlags.Acidic))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs
new file mode 100644
index 0000000..2b21342
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs
@@ -0,0 +1,83 @@
+using Sand.Core;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private void ApplyHeatEmission(int x, int y, float amount)
+ {
+ AdjustTemperatureAt(x, y, amount * 0.08f);
+ AdjustTemperatureAt(x - 1, y, amount * 0.02f);
+ AdjustTemperatureAt(x + 1, y, amount * 0.02f);
+ AdjustTemperatureAt(x, y - 1, amount * 0.02f);
+ AdjustTemperatureAt(x, y + 1, amount * 0.02f);
+ }
+
+ private void ApplyTemperatureDiffusion(int x, int y, PrototypeParticle particle)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ var localTemperature = page.GetTemperature(localX, localY);
+ var airDelta = localTemperature - _ambientTemperature;
+ if (MathF.Abs(airDelta) > 0.5f)
+ {
+ var minTransfer = Math.Min(airDelta * -0.5f, airDelta * 0.5f);
+ var maxTransfer = Math.Max(airDelta * -0.5f, airDelta * 0.5f);
+ var transfer = Math.Clamp(
+ airDelta * 0.05f * particle.AmbientCoolingMultiplier / MathF.Max(0.1f, particle.Conductivity <= 0f ? 1f : particle.Conductivity),
+ minTransfer,
+ maxTransfer);
+ page.SetTemperature(localX, localY, localTemperature - transfer);
+ localTemperature -= transfer;
+ }
+
+ DiffuseBetween(x, y, x + 1, y, particle, localTemperature);
+ DiffuseBetween(x, y, x, y + 1, particle, localTemperature);
+ }
+
+ private void AdjustTemperatureAt(int x, int y, float delta)
+ {
+ if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY))
+ {
+ return;
+ }
+
+ page.SetTemperature(localX, localY, page.GetTemperature(localX, localY) + delta);
+ }
+
+ private void DiffuseBetween(int x, int y, int nx, int ny, PrototypeParticle particle, float localTemperature)
+ {
+ if (!TryGetCellPage(x, y, out _, out var sourcePage, out var sourceLocalX, out var sourceLocalY) ||
+ !TryGetCellPage(nx, ny, out _, out var targetPage, out var targetLocalX, out var targetLocalY))
+ {
+ return;
+ }
+
+ var neighborParticle = targetPage[targetLocalX, targetLocalY];
+ if (neighborParticle.TypeId == 0)
+ {
+ return;
+ }
+
+ var neighborTemperature = targetPage.GetTemperature(targetLocalX, targetLocalY);
+ var delta = localTemperature - neighborTemperature;
+ if (MathF.Abs(delta) <= 0.5f)
+ {
+ return;
+ }
+
+ var sourceConductivity = MathF.Max(particle.Conductivity, 0.1f);
+ var targetConductivity = MathF.Max(neighborParticle.Conductivity, 0.1f);
+ var minTransfer = Math.Min(delta * -0.5f, delta * 0.5f);
+ var maxTransfer = Math.Max(delta * -0.5f, delta * 0.5f);
+ var transfer = Math.Clamp(
+ delta * ((sourceConductivity + targetConductivity) * 0.5f) * 0.1f * particle.NeighborHeatTransferMultiplier,
+ minTransfer,
+ maxTransfer);
+ sourcePage.SetTemperature(sourceLocalX, sourceLocalY, localTemperature - transfer);
+ targetPage.SetTemperature(targetLocalX, targetLocalY, neighborTemperature + transfer);
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs
new file mode 100644
index 0000000..d84efb3
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs
@@ -0,0 +1,472 @@
+using Sand.Core;
+using System.Diagnostics;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private const int GasBandHaloRows = 2;
+ private const int FallingBandHaloRows = 1;
+
+ public int StepDown() => Step();
+
+ public int Step()
+ {
+ _stepCounter++;
+ ResetStepMetrics();
+
+ if (_particleCount == 0)
+ {
+ var emptyFieldStart = Stopwatch.GetTimestamp();
+ DecayFields();
+ var emptyFieldDecayMicros = ToMicroseconds(emptyFieldStart, Stopwatch.GetTimestamp());
+ _lastStepStats = new ChunkStepStats(
+ SteppedChunks: 0,
+ SleepingChunks: 0,
+ FieldPages: _fieldPages.Count,
+ MoveAttempts: 0,
+ VerticalMoveAttempts: 0,
+ DiagonalMoveAttempts: 0,
+ LateralMoveAttempts: 0,
+ SuccessfulMoves: 0,
+ SwapAttempts: 0,
+ StalledMovableCells: 0,
+ MovementOnlyFastPathCount: 0,
+ FullRuntimeStepCount: 0,
+ FullRuntimeSolidCount: 0,
+ FullRuntimeLiquidCount: 0,
+ FullRuntimeGasCount: 0,
+ MovedParticles: 0,
+ SwappedParticles: 0,
+ VisualDirtyPages: _dirtyVisualChunks.Count,
+ FrameBuildBytesTouched: _lastStepStats.FrameBuildBytesTouched,
+ ActivationTimeMicroseconds: 0,
+ MovementTimeMicroseconds: 0,
+ RuntimeTimeMicroseconds: 0,
+ FieldDecayTimeMicroseconds: emptyFieldDecayMicros,
+ RenderTimeMicroseconds: _lastStepStats.RenderTimeMicroseconds);
+ return 0;
+ }
+
+ var activationStart = Stopwatch.GetTimestamp();
+ var (activeChunks, sleepingChunks) = _scheduler.BuildSchedule(_cellPages);
+ var steppedChunks = 0;
+ for (var i = 0; i < activeChunks.Count; i++)
+ {
+ var coord = activeChunks[i];
+ if (!_cellPages.TryGetValue(coord, out var page))
+ {
+ continue;
+ }
+
+ page.HasFieldActivity = false;
+ page.IsActive = false;
+ }
+ var activationMicros = ToMicroseconds(activationStart, Stopwatch.GetTimestamp());
+ long movementMicros = 0;
+ long runtimeMicros = 0;
+
+ ProcessGasChunksInterleaved(activeChunks, ref movementMicros, ref runtimeMicros);
+
+ for (var i = 0; i < activeChunks.Count; i++)
+ {
+ var coord = activeChunks[i];
+ if (!_cellPages.TryGetValue(coord, out var page))
+ {
+ continue;
+ }
+
+ if (!TryGetRowRange(page, FallingBandHaloRows, out var minRow, out var maxRow))
+ {
+ page.ClearDirtyBands();
+ page.DecayPendingSteps();
+ page.IsActive = page.HasFieldActivity || page.PendingActiveSteps > 0;
+ steppedChunks++;
+ continue;
+ }
+
+ page.ClearDirtyBands();
+ ProcessFallingPage(coord, page, minRow, maxRow, ref movementMicros, ref runtimeMicros);
+ if (page.LastTouchedFrame == _stepCounter || page.HasFieldActivity)
+ {
+ page.IsActive = true;
+ }
+ else
+ {
+ page.DecayPendingSteps();
+ page.IsActive = page.PendingActiveSteps > 0;
+ }
+
+ steppedChunks++;
+ }
+
+ ResolveGasChunkRowSeams(activeChunks);
+
+ var fieldStart = Stopwatch.GetTimestamp();
+ DecayFields();
+ var fieldDecayMicros = ToMicroseconds(fieldStart, Stopwatch.GetTimestamp());
+ _lastStepStats = new ChunkStepStats(
+ SteppedChunks: steppedChunks,
+ SleepingChunks: sleepingChunks,
+ FieldPages: _fieldPages.Count,
+ MoveAttempts: _moveAttemptCount,
+ VerticalMoveAttempts: _verticalMoveAttemptCount,
+ DiagonalMoveAttempts: _diagonalMoveAttemptCount,
+ LateralMoveAttempts: _lateralMoveAttemptCount,
+ SuccessfulMoves: _movedParticles,
+ SwapAttempts: _swapAttemptCount,
+ StalledMovableCells: _stalledMovableCount,
+ MovementOnlyFastPathCount: _movementOnlyFastPathCount,
+ FullRuntimeStepCount: _fullRuntimeStepCount,
+ FullRuntimeSolidCount: _fullRuntimeSolidCount,
+ FullRuntimeLiquidCount: _fullRuntimeLiquidCount,
+ FullRuntimeGasCount: _fullRuntimeGasCount,
+ MovedParticles: _movedParticles,
+ SwappedParticles: _swappedParticles,
+ VisualDirtyPages: _dirtyVisualChunks.Count,
+ FrameBuildBytesTouched: _lastStepStats.FrameBuildBytesTouched,
+ ActivationTimeMicroseconds: activationMicros,
+ MovementTimeMicroseconds: movementMicros,
+ RuntimeTimeMicroseconds: runtimeMicros,
+ FieldDecayTimeMicroseconds: fieldDecayMicros,
+ RenderTimeMicroseconds: _lastStepStats.RenderTimeMicroseconds);
+ return _movedParticles + _swappedParticles;
+ }
+
+ private void ProcessGasChunksInterleaved(IReadOnlyList activeChunks, ref long movementMicros, ref long runtimeMicros)
+ {
+ if (activeChunks.Count == 0)
+ {
+ return;
+ }
+
+ var minChunkY = int.MaxValue;
+ var maxChunkY = int.MinValue;
+ for (var i = 0; i < activeChunks.Count; i++)
+ {
+ var coord = activeChunks[i];
+ if (!_cellPages.TryGetValue(coord, out var page) || page.GasCellCount == 0)
+ {
+ continue;
+ }
+
+ minChunkY = Math.Min(minChunkY, coord.Y);
+ maxChunkY = Math.Max(maxChunkY, coord.Y);
+ }
+
+ if (minChunkY == int.MaxValue)
+ {
+ return;
+ }
+
+ for (var chunkY = minChunkY; chunkY <= maxChunkY; chunkY++)
+ {
+ for (var localY = 0; localY < _config.ChunkHeight; localY++)
+ {
+ var leftToRight = (((chunkY * _config.ChunkHeight) + localY + _stepCounter) & 1) == 0;
+ if (leftToRight)
+ {
+ for (var i = 0; i < activeChunks.Count; i++)
+ {
+ ProcessGasRowIfNeeded(activeChunks[i], chunkY, localY, ref movementMicros, ref runtimeMicros);
+ }
+
+ continue;
+ }
+
+ for (var i = activeChunks.Count - 1; i >= 0; i--)
+ {
+ ProcessGasRowIfNeeded(activeChunks[i], chunkY, localY, ref movementMicros, ref runtimeMicros);
+ }
+ }
+ }
+ }
+
+ private void ProcessGasRowIfNeeded(ChunkCoord coord, int chunkY, int localY, ref long movementMicros, ref long runtimeMicros)
+ {
+ if (coord.Y != chunkY || !_cellPages.TryGetValue(coord, out var page) || page.GasCellCount == 0)
+ {
+ return;
+ }
+
+ if (page.GetGasRowCount(localY) == 0)
+ {
+ return;
+ }
+
+ if (!ShouldProcessGasRow(page, localY))
+ {
+ return;
+ }
+
+ ProcessGasRow(coord, page, localY, ref movementMicros, ref runtimeMicros);
+ }
+
+ private void ProcessGasRow(ChunkCoord coord, ChunkCellPage page, int localY, ref long movementMicros, ref long runtimeMicros)
+ {
+ var movementOnlyPage = page.RuntimeCellCount == 0;
+ var moveCountBefore = _movedParticles + _swappedParticles;
+ var moveAttemptsBefore = _moveAttemptCount;
+ var rowGasCountAtStart = page.GetGasRowCount(localY);
+ var start = Stopwatch.GetTimestamp();
+ for (var localX = 0; localX < page.Width; localX++)
+ {
+ if (page.IsProcessed(localX, localY, _stepCounter))
+ {
+ continue;
+ }
+
+ var particle = page[localX, localY];
+ if (particle.MotionType != PrototypeParticleType.Steam)
+ {
+ continue;
+ }
+
+ page.MarkProcessed(localX, localY, _stepCounter);
+ if (movementOnlyPage || !particle.RequiresFullRuntimeStep)
+ {
+ StepMovementOnlyParticle(coord, localX, localY, particle);
+ continue;
+ }
+
+ TryStepParticle(coord, localX, localY, particle);
+ }
+
+ if (movementOnlyPage)
+ {
+ UpdateSparseGasRowCooldown(page, localY, rowGasCountAtStart, (_movedParticles + _swappedParticles) - moveCountBefore, _moveAttemptCount - moveAttemptsBefore);
+ }
+ else
+ {
+ page.ClearGasRowCooldown(localY);
+ }
+
+ var elapsedMicros = ToMicroseconds(start, Stopwatch.GetTimestamp());
+ if (movementOnlyPage)
+ {
+ movementMicros += elapsedMicros;
+ }
+ else
+ {
+ runtimeMicros += elapsedMicros;
+ }
+ }
+
+ private void ResolveGasChunkRowSeams(IReadOnlyList activeChunks)
+ {
+ for (var i = activeChunks.Count - 1; i >= 0; i--)
+ {
+ var lowerCoord = activeChunks[i];
+ if (!_cellPages.TryGetValue(lowerCoord, out var lowerPage))
+ {
+ continue;
+ }
+
+ for (var localX = 0; localX < lowerPage.Width; localX++)
+ {
+ var particle = lowerPage[localX, 0];
+ if (particle.MotionType != PrototypeParticleType.Steam)
+ {
+ continue;
+ }
+
+ var worldX = ToWorldX(lowerCoord, localX);
+ var worldY = ToWorldY(lowerCoord, 0);
+ if (!InBounds(worldX, worldY - 1))
+ {
+ continue;
+ }
+
+ if (TryMoveEmpty(worldX, worldY, worldX, worldY - 1))
+ {
+ CompactGasSeamColumn(worldX, worldY);
+ }
+ }
+ }
+ }
+
+ private void CompactGasSeamColumn(int x, int vacancyY)
+ {
+ while (InBounds(x, vacancyY + 1) && !HasParticle(x, vacancyY))
+ {
+ if (!TryGetParticle(x, vacancyY + 1, out var below) || below.MotionType != PrototypeParticleType.Steam)
+ {
+ break;
+ }
+
+ if (!MoveParticle(x, vacancyY + 1, x, vacancyY))
+ {
+ break;
+ }
+
+ vacancyY++;
+ }
+ }
+
+ private void ProcessFallingPage(ChunkCoord coord, ChunkCellPage page, int minRow, int maxRow, ref long movementMicros, ref long runtimeMicros)
+ {
+ var movementOnlyPage = page.RuntimeCellCount == 0;
+ var start = Stopwatch.GetTimestamp();
+ for (var localY = maxRow; localY >= minRow; localY--)
+ {
+ if (page.GetOccupiedRowCount(localY) == 0)
+ {
+ continue;
+ }
+
+ for (var localX = 0; localX < page.Width; localX++)
+ {
+ if (page.IsProcessed(localX, localY, _stepCounter))
+ {
+ continue;
+ }
+
+ var particle = page[localX, localY];
+ if (particle.TypeId == 0 || particle.MotionType == PrototypeParticleType.Steam)
+ {
+ continue;
+ }
+
+ page.MarkProcessed(localX, localY, _stepCounter);
+ if (movementOnlyPage || !particle.RequiresFullRuntimeStep)
+ {
+ StepMovementOnlyParticle(coord, localX, localY, particle);
+ continue;
+ }
+
+ TryStepParticle(coord, localX, localY, particle);
+ }
+ }
+
+ var elapsedMicros = ToMicroseconds(start, Stopwatch.GetTimestamp());
+ if (movementOnlyPage)
+ {
+ movementMicros += elapsedMicros;
+ }
+ else
+ {
+ runtimeMicros += elapsedMicros;
+ }
+ }
+
+ private bool ShouldProcessGasRow(ChunkCellPage page, int localY)
+ {
+ if (page.HasFieldActivity)
+ {
+ page.ClearGasRowCooldown(localY);
+ return true;
+ }
+
+ if (page.RuntimeCellCount > 0)
+ {
+ page.ClearGasRowCooldown(localY);
+ return true;
+ }
+
+ if (ShouldSkipSparseGasRow(page, localY))
+ {
+ return false;
+ }
+
+ if (!page.HasDirtyBands)
+ {
+ return false;
+ }
+
+ var minRow = Math.Max(0, page.DirtyMinRow - GasBandHaloRows);
+ var maxRow = Math.Min(page.Height - 1, page.DirtyMaxRow + GasBandHaloRows);
+ return localY >= minRow && localY <= maxRow;
+ }
+
+ private bool ShouldSkipSparseGasRow(ChunkCellPage page, int localY)
+ {
+ var signature = ComputeSparseGasRowSignature(page, localY);
+ if (signature == 0)
+ {
+ page.ClearGasRowCooldown(localY);
+ return false;
+ }
+
+ if (page.GetGasRowCooldownUntilStep(localY) > _stepCounter && page.GetGasRowCooldownSignature(localY) == signature)
+ {
+ return true;
+ }
+
+ if (page.GetGasRowCooldownSignature(localY) != signature)
+ {
+ page.ClearGasRowCooldown(localY);
+ }
+
+ return false;
+ }
+
+ private void UpdateSparseGasRowCooldown(ChunkCellPage page, int localY, int rowGasCountAtStart, int moveDelta, int attemptDelta)
+ {
+ if (rowGasCountAtStart <= 0 || rowGasCountAtStart > 4)
+ {
+ page.ClearGasRowCooldown(localY);
+ return;
+ }
+
+ if (moveDelta != 0 || attemptDelta <= 0)
+ {
+ page.ClearGasRowCooldown(localY);
+ return;
+ }
+
+ var signature = ComputeSparseGasRowSignature(page, localY);
+ if (signature == 0)
+ {
+ page.ClearGasRowCooldown(localY);
+ return;
+ }
+
+ var cooldownFrames = attemptDelta > rowGasCountAtStart ? 2 : 1;
+ page.SetGasRowCooldown(localY, signature, _stepCounter + cooldownFrames);
+ }
+
+ private static int ComputeSparseGasRowSignature(ChunkCellPage page, int localY)
+ {
+ var gasCount = page.GetGasRowCount(localY);
+ if (gasCount <= 0 || gasCount > 4)
+ {
+ return 0;
+ }
+
+ var aboveRow = Math.Max(0, localY - 1);
+ return HashCode.Combine(
+ localY,
+ page.GetRowRevision(localY),
+ page.GetRowRevision(aboveRow),
+ page.GetGasRowCount(localY),
+ page.GetGasRowCount(aboveRow));
+ }
+
+ private static bool TryGetRowRange(ChunkCellPage page, int haloRows, out int minRow, out int maxRow)
+ {
+ if (page.HasFieldActivity)
+ {
+ minRow = 0;
+ maxRow = page.Height - 1;
+ return true;
+ }
+
+ if (!page.HasDirtyBands)
+ {
+ if (page.RuntimeCellCount > 0)
+ {
+ minRow = 0;
+ maxRow = page.Height - 1;
+ return true;
+ }
+
+ minRow = 0;
+ maxRow = -1;
+ return false;
+ }
+
+ minRow = Math.Max(0, page.DirtyMinRow - haloRows);
+ maxRow = Math.Min(page.Height - 1, page.DirtyMaxRow + haloRows);
+ return minRow <= maxRow;
+ }
+}
diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs
new file mode 100644
index 0000000..3a769fd
--- /dev/null
+++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs
@@ -0,0 +1,183 @@
+using Sand.Core;
+using System.Diagnostics;
+
+namespace Sand.ChunkPrototype;
+
+public sealed partial class PrototypeSparseSandAdapter
+{
+ private const float FieldDecayFactor = 0.84f;
+ private const int RenderHaloCells = 1;
+ private readonly ChunkResidencyConfig _config;
+ private readonly Dictionary _particleProfiles = new();
+ private readonly Dictionary _cellPages = new();
+ private readonly Dictionary _fieldPages = new();
+ private readonly HashSet _dirtyVisualChunks = new();
+ private readonly ChunkStepScheduler _scheduler = new();
+ private readonly byte[] _rgbaBuffer;
+ private readonly byte[] _rgbBuffer;
+ private readonly float _ambientTemperature;
+ private bool _fullFrameDirty = true;
+ private int _stepCounter;
+ private int _particleCount;
+ private int _moveAttemptCount;
+ private int _verticalMoveAttemptCount;
+ private int _diagonalMoveAttemptCount;
+ private int _lateralMoveAttemptCount;
+ private int _swapAttemptCount;
+ private int _stalledMovableCount;
+ private int _movementOnlyFastPathCount;
+ private int _fullRuntimeStepCount;
+ private int _fullRuntimeSolidCount;
+ private int _fullRuntimeLiquidCount;
+ private int _fullRuntimeGasCount;
+ private int _movedParticles;
+ private int _swappedParticles;
+ private ChunkStepStats _lastStepStats;
+
+ public PrototypeSparseSandAdapter(int width, int height)
+ : this(width, height, 22f, null)
+ {
+ }
+
+ public PrototypeSparseSandAdapter(int width, int height, ChunkResidencyConfig config)
+ : this(width, height, 22f, config)
+ {
+ }
+
+ public PrototypeSparseSandAdapter(int width, int height, float ambientTemperature)
+ : this(width, height, ambientTemperature, null)
+ {
+ }
+
+ public PrototypeSparseSandAdapter(int width, int height, float ambientTemperature, ChunkResidencyConfig? config)
+ {
+ if (width <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(width));
+ }
+
+ if (height <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(height));
+ }
+
+ Width = width;
+ Height = height;
+ _ambientTemperature = ambientTemperature;
+ _config = config ?? ChunkResidencyConfig.Default;
+ World = new PrototypeChunkResidencyWorld(_config);
+ _rgbaBuffer = new byte[width * height * 4];
+ _rgbBuffer = new byte[width * height * 3];
+ RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Sand));
+ RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Water));
+ RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Steam));
+ RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Wall));
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public PrototypeChunkResidencyWorld World { get; }
+ public int ParticleCount => _particleCount;
+ public ChunkStepStats LastStepStats => _lastStepStats;
+ public int FieldCellCount => _fieldPages.Values.Sum(static page => page.ActiveCellCount);
+ public long EstimatedStorageBytes =>
+ World.EstimatedLoadedBytes +
+ ((long)_cellPages.Count * _config.ChunkWidth * _config.ChunkHeight * 16L) +
+ ((long)_fieldPages.Count * _config.ChunkWidth * _config.ChunkHeight * 20L);
+
+ public IReadOnlyCollection<(int X, int Y)> Particles
+ {
+ get
+ {
+ var particles = new List<(int X, int Y)>(_particleCount);
+ foreach (var (coord, page) in _cellPages)
+ {
+ foreach (var (localX, localY, _) in page.EnumerateOccupied())
+ {
+ particles.Add((ToWorldX(coord, localX), ToWorldY(coord, localY)));
+ }
+ }
+
+ return particles;
+ }
+ }
+
+ public IReadOnlyCollection> ParticleEntries
+ {
+ get
+ {
+ var particles = new List>(_particleCount);
+ foreach (var (coord, page) in _cellPages)
+ {
+ foreach (var (localX, localY, particle) in page.EnumerateOccupied())
+ {
+ particles.Add(new KeyValuePair<(int X, int Y), PrototypeParticle>((ToWorldX(coord, localX), ToWorldY(coord, localY)), particle));
+ }
+ }
+
+ return particles;
+ }
+ }
+
+ public bool AddParticle(int x, int y, PrototypeParticleType type = PrototypeParticleType.Sand) => AddParticle(x, y, CreateLegacyParticle(type));
+
+ public void RegisterParticleProfile(PrototypeParticle particle)
+ {
+ if (!particle.IsEmpty)
+ {
+ _particleProfiles[particle.TypeId] = particle;
+ }
+ }
+
+ private static long ToMicroseconds(long startTimestamp, long endTimestamp)
+ {
+ if (endTimestamp <= startTimestamp)
+ {
+ return 0;
+ }
+
+ return (endTimestamp - startTimestamp) * 1_000_000L / Stopwatch.Frequency;
+ }
+
+ private void ResetStepMetrics()
+ {
+ _moveAttemptCount = 0;
+ _verticalMoveAttemptCount = 0;
+ _diagonalMoveAttemptCount = 0;
+ _lateralMoveAttemptCount = 0;
+ _swapAttemptCount = 0;
+ _stalledMovableCount = 0;
+ _movementOnlyFastPathCount = 0;
+ _fullRuntimeStepCount = 0;
+ _fullRuntimeSolidCount = 0;
+ _fullRuntimeLiquidCount = 0;
+ _fullRuntimeGasCount = 0;
+ _movedParticles = 0;
+ _swappedParticles = 0;
+ }
+
+ private void RecordMoveAttempt(int deltaX, int deltaY)
+ {
+ _moveAttemptCount++;
+ if (deltaY == 0)
+ {
+ _lateralMoveAttemptCount++;
+ return;
+ }
+
+ if (deltaX == 0)
+ {
+ _verticalMoveAttemptCount++;
+ return;
+ }
+
+ _diagonalMoveAttemptCount++;
+ }
+
+ private void RecordSwapAttempt() => _swapAttemptCount++;
+
+ private void RecordStalledMovable()
+ {
+ _stalledMovableCount++;
+ }
+}
diff --git a/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj b/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj
new file mode 100644
index 0000000..dd2bf73
--- /dev/null
+++ b/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Sand.Core/IParticleLibrary.cs b/Sand.Core/IParticleLibrary.cs
new file mode 100644
index 0000000..387062b
--- /dev/null
+++ b/Sand.Core/IParticleLibrary.cs
@@ -0,0 +1,8 @@
+namespace Sand.Core;
+
+public interface IParticleLibrary
+{
+ IReadOnlyList Definitions { get; }
+ ushort GetTypeId(string id);
+ ParticleDef GetDefinition(ushort typeId);
+}
diff --git a/Sand.Core/ISimulationAccelerator.cs b/Sand.Core/ISimulationAccelerator.cs
new file mode 100644
index 0000000..d0e2cce
--- /dev/null
+++ b/Sand.Core/ISimulationAccelerator.cs
@@ -0,0 +1,6 @@
+namespace Sand.Core;
+
+public interface ISimulationAccelerator
+{
+ void Step(SandSimulation simulation, float dt);
+}
diff --git a/Sand.Core/ParticleBalanceProfile.cs b/Sand.Core/ParticleBalanceProfile.cs
new file mode 100644
index 0000000..ddf2bbf
--- /dev/null
+++ b/Sand.Core/ParticleBalanceProfile.cs
@@ -0,0 +1,27 @@
+namespace Sand.Core;
+
+public sealed class ParticleBalanceProfile
+{
+ public string Id { get; init; } = "";
+ public ParticleBehaviorKind BehaviorKind { get; init; }
+ public float LifetimeMultiplier { get; init; } = 1f;
+ public float BurnDecayPerStep { get; init; } = 1f;
+ public float AmbientCoolingMultiplier { get; init; } = 1f;
+ public float NeighborHeatTransferMultiplier { get; init; } = 1f;
+ public float UpwardBias { get; init; }
+ public float SideDriftBias { get; init; }
+ public float FireSpreadChance { get; init; }
+ public float SmokeSpawnChance { get; init; }
+ public float EmberSpawnChance { get; init; }
+ public float ProduceChance { get; init; }
+ public float HeatEmissionMultiplier { get; init; } = 1f;
+ public float EnergyTransferMultiplier { get; init; } = 1f;
+ public float PressureSensitivity { get; init; } = 1f;
+ public float PressureDecayMultiplier { get; init; } = 1f;
+ public float ForceResponseMultiplier { get; init; } = 1f;
+ public float LateralFlowMultiplier { get; init; } = 1f;
+ public float DiagonalFlowMultiplier { get; init; } = 1f;
+ public float PhaseTransitionHysteresis { get; init; }
+ public float MinLifetimeTicks { get; init; }
+ public float MaxLifetimeTicks { get; init; }
+}
diff --git a/Sand.Core/ParticleBehaviorKind.cs b/Sand.Core/ParticleBehaviorKind.cs
new file mode 100644
index 0000000..f9352c9
--- /dev/null
+++ b/Sand.Core/ParticleBehaviorKind.cs
@@ -0,0 +1,10 @@
+namespace Sand.Core;
+
+public enum ParticleBehaviorKind : byte
+{
+ None = 0,
+ Fire = 1,
+ BurningWood = 2,
+ Ember = 3,
+ Plasma = 4,
+}
diff --git a/Sand.Core/ParticleDef.cs b/Sand.Core/ParticleDef.cs
new file mode 100644
index 0000000..243f8c5
--- /dev/null
+++ b/Sand.Core/ParticleDef.cs
@@ -0,0 +1,63 @@
+namespace Sand.Core;
+
+public sealed class ParticleDef
+{
+ public string Id { get; init; } = "";
+ public string Name { get; init; } = "";
+ public ParticleKind Kind { get; init; } = ParticleKind.Solid;
+ public bool IsStatic { get; init; }
+ public bool IsSpecial { get; init; }
+ public float Mass { get; init; } = 1f;
+ public float Hardness { get; init; } = 0.5f;
+ public float Velocity { get; init; }
+ public float Conductivity { get; init; }
+ public bool Conductive { get; init; }
+ public float Flamability { get; init; }
+ public float Durability { get; init; } = 100f;
+ public float HeatCapacity { get; init; } = 1f;
+ public float Friction { get; init; }
+ public float Viscosity { get; init; }
+ public float Pressure { get; init; }
+ public float Temperature { get; init; } = 22f;
+ public string? Melt { get; init; }
+ public float? MeltTemperature { get; init; }
+ public string? Evaporate { get; init; }
+ public float? EvaporateTemperature { get; init; }
+ public string? Solidify { get; init; }
+ public float? SolidifyTemperature { get; init; }
+ public string? Freeze { get; init; }
+ public float? FreezeTemperature { get; init; }
+ public float BurnDuration { get; init; }
+ public float BurnTemperature { get; init; }
+ public float BurnRate { get; init; } = 1f;
+ public bool Burning { get; init; }
+ public float? Lifetime { get; init; }
+ public bool Explosive { get; init; }
+ public int ExplosionRadius { get; init; }
+ public Rgb24? ExplosionColor { get; init; }
+ public float ExplosionForce { get; init; }
+ public int ExplosionDuration { get; init; }
+ public float PressureResistance { get; init; }
+ public float PressureTolerance { get; init; }
+ public float PressureThreshold { get; init; }
+ public int PressureThresholdDuration { get; init; }
+ public string? Broken { get; init; }
+ public string? Produces { get; init; }
+ public string? ProducesOnDeath { get; init; }
+ public float HeatEmission { get; init; }
+ public float EnergyTransfer { get; init; }
+ public float Radius { get; init; }
+ public float ForceFalloff { get; init; }
+ public float Turbulence { get; init; }
+ public string[] Affects { get; init; } = [];
+ public bool IsWind { get; init; }
+ public float WindStrength { get; init; }
+ public float[]? WindDirection { get; init; }
+ public bool IsGravity { get; init; }
+ public float GravityStrength { get; init; }
+ public string? PullDirection { get; init; }
+ public bool IsRepulsor { get; init; }
+ public float RepulsionStrength { get; init; }
+ public string? PushDirection { get; init; }
+ public Rgb24 Color { get; init; } = new(255, 255, 255);
+}
diff --git a/Sand.Core/ParticleKind.cs b/Sand.Core/ParticleKind.cs
new file mode 100644
index 0000000..ce07265
--- /dev/null
+++ b/Sand.Core/ParticleKind.cs
@@ -0,0 +1,8 @@
+namespace Sand.Core;
+
+public enum ParticleKind : byte
+{
+ Solid = 1,
+ Liquid = 2,
+ Gas = 3,
+}
diff --git a/Sand.Core/ParticleLibrary.cs b/Sand.Core/ParticleLibrary.cs
new file mode 100644
index 0000000..7b47f58
--- /dev/null
+++ b/Sand.Core/ParticleLibrary.cs
@@ -0,0 +1,49 @@
+using System.Collections.ObjectModel;
+
+namespace Sand.Core;
+
+public sealed class ParticleLibrary : IParticleLibrary
+{
+ private readonly IReadOnlyList _definitions;
+ private readonly Dictionary _typeIds;
+
+ public ParticleLibrary(IReadOnlyList definitions)
+ {
+ if (definitions.Count == 0)
+ {
+ throw new ArgumentException("At least one particle definition is required.", nameof(definitions));
+ }
+
+ _definitions = new ReadOnlyCollection(definitions.ToArray());
+ _typeIds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ for (var index = 0; index < definitions.Count; index++)
+ {
+ _typeIds[definitions[index].Id] = checked((ushort)(index + 1));
+ }
+ }
+
+ public IReadOnlyList Definitions => _definitions;
+
+ public ushort GetTypeId(string id)
+ {
+ if (_typeIds.TryGetValue(id, out var typeId))
+ {
+ return typeId;
+ }
+
+ throw new KeyNotFoundException($"Unknown particle id '{id}'.");
+ }
+
+ public bool TryGetTypeId(string id, out ushort typeId) => _typeIds.TryGetValue(id, out typeId);
+
+ public ParticleDef GetDefinition(ushort typeId)
+ {
+ if (typeId == 0 || typeId > _definitions.Count)
+ {
+ throw new ArgumentOutOfRangeException(nameof(typeId), $"Unknown type id '{typeId}'.");
+ }
+
+ return _definitions[typeId - 1];
+ }
+}
diff --git a/Sand.Core/ParticleLibraryLoader.cs b/Sand.Core/ParticleLibraryLoader.cs
new file mode 100644
index 0000000..57f1a95
--- /dev/null
+++ b/Sand.Core/ParticleLibraryLoader.cs
@@ -0,0 +1,255 @@
+using System.Text.Json.Nodes;
+
+namespace Sand.Core;
+
+public static class ParticleLibraryLoader
+{
+ private static readonly HashSet StaticTypes =
+ [
+ "wall",
+ "stone",
+ "rock",
+ "iron",
+ "gold",
+ "copper",
+ "wood",
+ "brass",
+ "glass",
+ ];
+
+ public static ParticleLibrary LoadFromDirectory(string partRootPath)
+ {
+ var directories = new[]
+ {
+ Path.Combine(partRootPath, "coreparts"),
+ Path.Combine(partRootPath, "mods"),
+ };
+
+ var rawDefinitions = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var directory in directories)
+ {
+ if (!Directory.Exists(directory))
+ {
+ continue;
+ }
+
+ foreach (var filePath in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories))
+ {
+ var rootNode = JsonNode.Parse(File.ReadAllText(filePath)) as JsonObject;
+ if (rootNode is null)
+ {
+ continue;
+ }
+
+ foreach (var pair in rootNode)
+ {
+ if (pair.Value is JsonObject obj)
+ {
+ rawDefinitions[pair.Key.ToLowerInvariant()] = obj;
+ }
+ }
+ }
+ }
+
+ var orderedIds = rawDefinitions.Keys.OrderBy(static id => id, StringComparer.Ordinal).ToArray();
+ var definitions = new List(orderedIds.Length);
+
+ foreach (var id in orderedIds)
+ {
+ definitions.Add(ParseDefinition(id, rawDefinitions[id]));
+ }
+
+ var knownIds = new HashSet(orderedIds, StringComparer.OrdinalIgnoreCase);
+ foreach (var definition in definitions)
+ {
+ ValidateTarget(knownIds, definition.Id, definition.Melt, nameof(definition.Melt));
+ ValidateTarget(knownIds, definition.Id, definition.Evaporate, nameof(definition.Evaporate));
+ ValidateTarget(knownIds, definition.Id, definition.Solidify, nameof(definition.Solidify));
+ ValidateTarget(knownIds, definition.Id, definition.Freeze, nameof(definition.Freeze));
+ ValidateTarget(knownIds, definition.Id, definition.Broken, nameof(definition.Broken));
+ ValidateTarget(knownIds, definition.Id, definition.Produces, nameof(definition.Produces));
+ ValidateTarget(knownIds, definition.Id, definition.ProducesOnDeath, nameof(definition.ProducesOnDeath));
+ }
+
+ return new ParticleLibrary(definitions);
+ }
+
+ private static ParticleDef ParseDefinition(string id, JsonObject source)
+ {
+ var isGas = GetBool(source, "is_gas");
+ var isLiquid = GetBool(source, "liquid");
+ var isSolid = GetBool(source, "solid", true);
+ var isSpecial = !isGas && !isLiquid && !isSolid;
+
+ return new ParticleDef
+ {
+ Id = id,
+ Name = GetString(source, "name") ?? id,
+ Kind = isGas ? ParticleKind.Gas : isLiquid ? ParticleKind.Liquid : ParticleKind.Solid,
+ IsStatic = StaticTypes.Contains(id) || GetBool(source, "static"),
+ IsSpecial = isSpecial,
+ Mass = GetFloat(source, "mass", 1f),
+ Hardness = GetFloat(source, "hardness", 0.5f),
+ Velocity = GetFloat(source, "velocity", 0f),
+ Conductivity = GetFloat(source, "conductivity", 0f),
+ Conductive = GetBool(source, "conductive"),
+ Flamability = GetFloat(source, "flamability", 0f),
+ Durability = GetFloat(source, "durability", 100f),
+ HeatCapacity = GetFloat(source, "heat_capacity", 1f),
+ Friction = GetFloat(source, "friction", 0f),
+ Viscosity = GetFloat(source, "viscosity", 0f),
+ Pressure = GetFloat(source, "pressure", 0f),
+ Temperature = GetFloat(source, "temperature", 22f),
+ Melt = GetLowerString(source, "melt"),
+ MeltTemperature = GetNullableFloat(source, "melt_temperature"),
+ Evaporate = GetLowerString(source, "evaporate"),
+ EvaporateTemperature = GetNullableFloat(source, "evaporate_temperature"),
+ Solidify = GetLowerString(source, "solidify"),
+ SolidifyTemperature = GetNullableFloat(source, "solidify_temperature"),
+ Freeze = GetLowerString(source, "freeze"),
+ FreezeTemperature = GetNullableFloat(source, "freeze_temperature"),
+ BurnDuration = GetFloat(source, "burn_duration", 0f),
+ BurnTemperature = GetFloat(source, "burn_temperature", 0f),
+ BurnRate = GetFloat(source, "burn_rate", 1f),
+ Burning = GetBool(source, "burning"),
+ Lifetime = GetNullableFloat(source, "lifetime"),
+ Explosive = GetBool(source, "explosive"),
+ ExplosionRadius = GetInt(source, "explosion_radius", 0),
+ ExplosionColor = GetOptionalColor(source, "explosion_color"),
+ ExplosionForce = GetFloat(source, "explosion_force", 6f),
+ ExplosionDuration = GetInt(source, "explosion_duration", 1),
+ PressureResistance = GetFloat(source, "pressure_resistance", 0f),
+ PressureTolerance = GetFloat(source, "pressure_tolerance", 0f),
+ PressureThreshold = GetFloat(source, "pressure_threshold", 0f),
+ PressureThresholdDuration = GetInt(source, "pressure_threshold_duration", 0),
+ Broken = GetLowerString(source, "Broken") ?? GetLowerString(source, "broken"),
+ Produces = GetLowerString(source, "produces"),
+ ProducesOnDeath = GetLowerString(source, "produces_on_death"),
+ HeatEmission = GetFloat(source, "heat_emission", 0f),
+ EnergyTransfer = GetFloat(source, "energy_transfer", 0f),
+ Radius = GetFloat(source, "radius", 0f),
+ ForceFalloff = GetFloat(source, "force_falloff", 1f),
+ Turbulence = GetFloat(source, "turbulence", 0f),
+ Affects = GetStringArray(source, "affects"),
+ IsWind = GetBool(source, "is_wind"),
+ WindStrength = GetFloat(source, "wind_strength", 0f),
+ WindDirection = GetFloatArray(source, "wind_direction"),
+ IsGravity = GetBool(source, "is_gravity"),
+ GravityStrength = GetFloat(source, "gravity_strength", 0f),
+ PullDirection = GetLowerString(source, "pull_direction"),
+ IsRepulsor = GetBool(source, "is_repulsor"),
+ RepulsionStrength = GetFloat(source, "repulsion_strength", 0f),
+ PushDirection = GetLowerString(source, "push_direction"),
+ Color = GetColor(source),
+ };
+ }
+
+ private static void ValidateTarget(HashSet knownIds, string id, string? target, string propertyName)
+ {
+ if (target is not null && !knownIds.Contains(target))
+ {
+ throw new InvalidDataException($"Particle '{id}' references unknown {propertyName} target '{target}'.");
+ }
+ }
+
+ private static string? GetString(JsonObject source, string key) => source[key]?.GetValue();
+
+ private static string? GetLowerString(JsonObject source, string key)
+ {
+ var value = GetString(source, key);
+ return value?.ToLowerInvariant();
+ }
+
+ private static bool GetBool(JsonObject source, string key, bool defaultValue = false)
+ {
+ if (source[key] is null)
+ {
+ return defaultValue;
+ }
+
+ return source[key]!.GetValue();
+ }
+
+ private static float GetFloat(JsonObject source, string key, float defaultValue)
+ {
+ if (source[key] is null)
+ {
+ return defaultValue;
+ }
+
+ return source[key]!.GetValue();
+ }
+
+ private static int GetInt(JsonObject source, string key, int defaultValue)
+ {
+ if (source[key] is null)
+ {
+ return defaultValue;
+ }
+
+ return source[key]!.GetValue();
+ }
+
+ private static float? GetNullableFloat(JsonObject source, string key)
+ {
+ if (source[key] is null)
+ {
+ return null;
+ }
+
+ return source[key]!.GetValue();
+ }
+
+ private static Rgb24 GetColor(JsonObject source)
+ {
+ if (source["color"] is not JsonArray array || array.Count < 3)
+ {
+ return new Rgb24(255, 255, 255);
+ }
+
+ return new Rgb24(
+ (byte)array[0]!.GetValue(),
+ (byte)array[1]!.GetValue(),
+ (byte)array[2]!.GetValue());
+ }
+
+ private static Rgb24? GetOptionalColor(JsonObject source, string key)
+ {
+ if (source[key] is not JsonArray array || array.Count < 3)
+ {
+ return null;
+ }
+
+ return new Rgb24(
+ (byte)array[0]!.GetValue(),
+ (byte)array[1]!.GetValue(),
+ (byte)array[2]!.GetValue());
+ }
+
+ private static string[] GetStringArray(JsonObject source, string key)
+ {
+ if (source[key] is not JsonArray array)
+ {
+ return [];
+ }
+
+ return array
+ .Where(static item => item is not null)
+ .Select(static item => item!.GetValue().ToLowerInvariant())
+ .ToArray();
+ }
+
+ private static float[]? GetFloatArray(JsonObject source, string key)
+ {
+ if (source[key] is not JsonArray array || array.Count == 0)
+ {
+ return null;
+ }
+
+ return array
+ .Where(static item => item is not null)
+ .Select(static item => item!.GetValue())
+ .ToArray();
+ }
+}
diff --git a/Sand.Core/ParticleRuntimeProfile.cs b/Sand.Core/ParticleRuntimeProfile.cs
new file mode 100644
index 0000000..a2d46ea
--- /dev/null
+++ b/Sand.Core/ParticleRuntimeProfile.cs
@@ -0,0 +1,7 @@
+namespace Sand.Core;
+
+public sealed class ParticleRuntimeProfile
+{
+ public required ParticleDef Definition { get; init; }
+ public required ParticleBalanceProfile Balance { get; init; }
+}
diff --git a/Sand.Core/ParticleRuntimeProfileBuilder.cs b/Sand.Core/ParticleRuntimeProfileBuilder.cs
new file mode 100644
index 0000000..496d8a0
--- /dev/null
+++ b/Sand.Core/ParticleRuntimeProfileBuilder.cs
@@ -0,0 +1,146 @@
+namespace Sand.Core;
+
+public static class ParticleRuntimeProfileBuilder
+{
+ public static ParticleRuntimeProfile Build(ParticleDef definition)
+ {
+ return new ParticleRuntimeProfile
+ {
+ Definition = definition,
+ Balance = BuildBalance(definition),
+ };
+ }
+
+ private static ParticleBalanceProfile BuildBalance(ParticleDef definition)
+ {
+ return definition.Id switch
+ {
+ "fire" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ BehaviorKind = ParticleBehaviorKind.Fire,
+ LifetimeMultiplier = 1f,
+ BurnDecayPerStep = 2.5f,
+ AmbientCoolingMultiplier = 0.2f,
+ NeighborHeatTransferMultiplier = 0.45f,
+ UpwardBias = 1f,
+ SideDriftBias = 0.45f,
+ FireSpreadChance = 0.14f,
+ SmokeSpawnChance = 0.18f,
+ EmberSpawnChance = 0.04f,
+ HeatEmissionMultiplier = 1.1f,
+ MinLifetimeTicks = 18f,
+ MaxLifetimeTicks = 40f,
+ PhaseTransitionHysteresis = 0f,
+ },
+ "burning_wood" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ BehaviorKind = ParticleBehaviorKind.BurningWood,
+ BurnDecayPerStep = 0.65f,
+ AmbientCoolingMultiplier = 0.35f,
+ NeighborHeatTransferMultiplier = 0.75f,
+ SmokeSpawnChance = 0.14f,
+ EmberSpawnChance = 0.025f,
+ ProduceChance = 0.12f,
+ HeatEmissionMultiplier = 1f,
+ FireSpreadChance = 0.08f,
+ PressureSensitivity = 0.8f,
+ MinLifetimeTicks = 240f,
+ MaxLifetimeTicks = 420f,
+ },
+ "ember" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ BehaviorKind = ParticleBehaviorKind.Ember,
+ LifetimeMultiplier = 1f,
+ BurnDecayPerStep = 1.5f,
+ AmbientCoolingMultiplier = 0.3f,
+ NeighborHeatTransferMultiplier = 0.65f,
+ UpwardBias = 0.35f,
+ SideDriftBias = 0.25f,
+ FireSpreadChance = 0.05f,
+ SmokeSpawnChance = 0.10f,
+ ProduceChance = 0.08f,
+ HeatEmissionMultiplier = 0.8f,
+ MinLifetimeTicks = 20f,
+ MaxLifetimeTicks = 45f,
+ },
+ "plasma" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ BehaviorKind = ParticleBehaviorKind.Plasma,
+ AmbientCoolingMultiplier = 0.1f,
+ NeighborHeatTransferMultiplier = 0.5f,
+ UpwardBias = 0.95f,
+ SideDriftBias = 0.55f,
+ FireSpreadChance = 0.18f,
+ HeatEmissionMultiplier = 1.6f,
+ EnergyTransferMultiplier = 1.75f,
+ MinLifetimeTicks = 18f,
+ MaxLifetimeTicks = 60f,
+ },
+ "lava" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ AmbientCoolingMultiplier = 0.05f,
+ NeighborHeatTransferMultiplier = 0.12f,
+ FireSpreadChance = 0.08f,
+ HeatEmissionMultiplier = 1.35f,
+ ForceResponseMultiplier = 0.22f,
+ LateralFlowMultiplier = 0.38f,
+ DiagonalFlowMultiplier = 0.6f,
+ PhaseTransitionHysteresis = 60f,
+ PressureSensitivity = 1.1f,
+ },
+ var id when id.StartsWith("molten_", StringComparison.Ordinal) => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ AmbientCoolingMultiplier = 0.08f,
+ NeighborHeatTransferMultiplier = 0.16f,
+ HeatEmissionMultiplier = 1.15f,
+ ForceResponseMultiplier = 0.32f,
+ LateralFlowMultiplier = 0.5f,
+ DiagonalFlowMultiplier = 0.72f,
+ PhaseTransitionHysteresis = 45f,
+ PressureSensitivity = 1.05f,
+ },
+ "steam" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ AmbientCoolingMultiplier = 0.2f,
+ NeighborHeatTransferMultiplier = 0.45f,
+ PhaseTransitionHysteresis = 15f,
+ },
+ "smoke" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ LifetimeMultiplier = 1.25f,
+ AmbientCoolingMultiplier = 0.25f,
+ UpwardBias = 0.4f,
+ SideDriftBias = 0.2f,
+ MinLifetimeTicks = 100f,
+ MaxLifetimeTicks = 140f,
+ },
+ "ice" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ AmbientCoolingMultiplier = 0.15f,
+ NeighborHeatTransferMultiplier = 0.4f,
+ PhaseTransitionHysteresis = 24f,
+ },
+ "snow" => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ AmbientCoolingMultiplier = 0.22f,
+ NeighborHeatTransferMultiplier = 0.5f,
+ PhaseTransitionHysteresis = 18f,
+ },
+ _ => new ParticleBalanceProfile
+ {
+ Id = definition.Id,
+ BurnDecayPerStep = 1f,
+ },
+ };
+ }
+}
diff --git a/Sand.Core/Rgb24.cs b/Sand.Core/Rgb24.cs
new file mode 100644
index 0000000..33ca245
--- /dev/null
+++ b/Sand.Core/Rgb24.cs
@@ -0,0 +1,3 @@
+namespace Sand.Core;
+
+public readonly record struct Rgb24(byte R, byte G, byte B);
diff --git a/Sand.Core/Sand.Core.csproj b/Sand.Core/Sand.Core.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/Sand.Core/Sand.Core.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/Sand.Core/SandSimulation.CellsAndForces.cs b/Sand.Core/SandSimulation.CellsAndForces.cs
new file mode 100644
index 0000000..b52b53a
--- /dev/null
+++ b/Sand.Core/SandSimulation.CellsAndForces.cs
@@ -0,0 +1,544 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ private void SetCell(int x, int y, ushort typeId, bool markProcessed = true, bool clearDynamics = false)
+ {
+ var wasEmpty = TypeId[x, y] == 0;
+ if (wasEmpty)
+ {
+ ParticleCount++;
+ }
+
+ TypeId[x, y] = typeId;
+ Temperature[x, y] = _initialTemperature[typeId];
+ BurnTime[x, y] = _burnDuration[typeId];
+ Burning[x, y] = _burningInit[typeId];
+ SparkTime[x, y] = 0;
+ Lifetime[x, y] = _defaultLifetime[typeId];
+ _pressureDuration[x, y] = 0f;
+ _cellAge[x, y] = 0f;
+ _integrity[x, y] = _durability[typeId];
+ if (clearDynamics)
+ {
+ ClearDynamicFieldsAt(x, y);
+ }
+ ExpandOccupiedBoundsToInclude(x, y);
+ MarkVisualDirty(x, y);
+ if (markProcessed)
+ {
+ MarkProcessed(x, y);
+ }
+ }
+
+ private void ForceSetCell(int x, int y, ushort typeId)
+ {
+ var wasEmpty = TypeId[x, y] == 0;
+ if (wasEmpty)
+ {
+ ParticleCount++;
+ }
+
+ TypeId[x, y] = typeId;
+ Temperature[x, y] = _initialTemperature[typeId];
+ BurnTime[x, y] = _burnDuration[typeId];
+ Burning[x, y] = _burningInit[typeId];
+ SparkTime[x, y] = 0;
+ Lifetime[x, y] = _defaultLifetime[typeId];
+ _pressureDuration[x, y] = 0f;
+ _cellAge[x, y] = 0f;
+ _integrity[x, y] = _durability[typeId];
+ ExpandOccupiedBoundsToInclude(x, y);
+ MarkVisualDirty(x, y);
+ }
+
+ private void ReplaceCell(int x, int y, ushort typeId)
+ {
+ TypeId[x, y] = typeId;
+ Temperature[x, y] = _initialTemperature[typeId];
+ BurnTime[x, y] = _burnDuration[typeId];
+ Burning[x, y] = _burningInit[typeId];
+ SparkTime[x, y] = 0;
+ Lifetime[x, y] = _defaultLifetime[typeId];
+ _pressureDuration[x, y] = 0f;
+ _cellAge[x, y] = 0f;
+ _integrity[x, y] = _durability[typeId];
+ ExpandOccupiedBoundsToInclude(x, y);
+ MarkVisualDirty(x, y);
+ MarkProcessed(x, y);
+ }
+
+ private void ResetCell(int x, int y)
+ {
+ var typeId = TypeId[x, y];
+ if (typeId != 0)
+ {
+ TrySpawnOnDeath(x, y, typeId);
+ }
+
+ if (TypeId[x, y] != 0)
+ {
+ ParticleCount = Math.Max(0, ParticleCount - 1);
+ }
+
+ MarkBoundsDirtyIfRemovingOccupiedCell(x, y);
+ ResetCellWithoutCountChange(x, y);
+ MarkVisualDirty(x, y);
+ }
+
+ private void MoveCell(int x, int y, int nx, int ny)
+ {
+ TypeId[nx, ny] = TypeId[x, y];
+ Temperature[nx, ny] = Temperature[x, y];
+ BurnTime[nx, ny] = BurnTime[x, y];
+ Burning[nx, ny] = Burning[x, y];
+ SparkTime[nx, ny] = SparkTime[x, y];
+ Lifetime[nx, ny] = Lifetime[x, y];
+ _pressureDuration[nx, ny] = _pressureDuration[x, y];
+ _cellAge[nx, ny] = _cellAge[x, y];
+ _integrity[nx, ny] = _integrity[x, y];
+ MarkBoundsDirtyIfRemovingOccupiedCell(x, y);
+ ResetCellWithoutCountChange(x, y);
+ ExpandOccupiedBoundsToInclude(nx, ny);
+ MarkVisualDirty(x, y);
+ MarkVisualDirty(nx, ny);
+ MarkProcessed(nx, ny);
+ InjectMovementWind(x, y, nx, ny, TypeId[nx, ny]);
+ InjectMovementPressure(x, y, nx, ny, TypeId[nx, ny]);
+ }
+
+ private void SwapCells(int x, int y, int nx, int ny)
+ {
+ (TypeId[x, y], TypeId[nx, ny]) = (TypeId[nx, ny], TypeId[x, y]);
+ (Temperature[x, y], Temperature[nx, ny]) = (Temperature[nx, ny], Temperature[x, y]);
+ (BurnTime[x, y], BurnTime[nx, ny]) = (BurnTime[nx, ny], BurnTime[x, y]);
+ (Burning[x, y], Burning[nx, ny]) = (Burning[nx, ny], Burning[x, y]);
+ (SparkTime[x, y], SparkTime[nx, ny]) = (SparkTime[nx, ny], SparkTime[x, y]);
+ (Lifetime[x, y], Lifetime[nx, ny]) = (Lifetime[nx, ny], Lifetime[x, y]);
+ (_pressureDuration[x, y], _pressureDuration[nx, ny]) = (_pressureDuration[nx, ny], _pressureDuration[x, y]);
+ (_cellAge[x, y], _cellAge[nx, ny]) = (_cellAge[nx, ny], _cellAge[x, y]);
+ (_integrity[x, y], _integrity[nx, ny]) = (_integrity[nx, ny], _integrity[x, y]);
+ ExpandOccupiedBoundsToInclude(x, y);
+ ExpandOccupiedBoundsToInclude(nx, ny);
+ MarkVisualDirty(x, y);
+ MarkVisualDirty(nx, ny);
+ MarkProcessed(x, y);
+ MarkProcessed(nx, ny);
+ }
+
+ private void ResetCellWithoutCountChange(int x, int y)
+ {
+ TypeId[x, y] = 0;
+ Temperature[x, y] = _settings.AmbientTemperature;
+ BurnTime[x, y] = 0f;
+ Burning[x, y] = 0;
+ SparkTime[x, y] = 0;
+ Lifetime[x, y] = 0f;
+ _pressureDuration[x, y] = 0f;
+ _cellAge[x, y] = 0f;
+ _integrity[x, y] = 0f;
+ MarkVisualDirty(x, y);
+ }
+
+ private void DecayFields(float dt)
+ {
+ if (!TryGetSimulationBounds(out var startX, out var startY, out var endX, out var endY))
+ {
+ return;
+ }
+
+ var windDecay = MathF.Max(0f, 1f - (dt * 3.5f));
+ var forceDecay = MathF.Max(0f, 1f - (dt * 1.2f));
+ var pressureDecay = MathF.Max(0f, 1f - (dt * 4.5f));
+ _hasActiveFieldBounds = false;
+ for (var y = startY; y <= endY; y++)
+ {
+ for (var x = startX; x <= endX; x++)
+ {
+ _windFieldX[x, y] *= windDecay;
+ _windFieldY[x, y] *= windDecay;
+ _forceFieldX[x, y] *= forceDecay;
+ _forceFieldY[x, y] *= forceDecay;
+ var localPressureDecay = pressureDecay * _pressureDecayMultiplier[Math.Min(TypeId[x, y], _pressureDecayMultiplier.Length - 1)];
+ _airPressure[x, y] *= localPressureDecay;
+ if (MathF.Abs(_windFieldX[x, y]) < 0.02f)
+ {
+ _windFieldX[x, y] = 0f;
+ }
+
+ if (MathF.Abs(_windFieldY[x, y]) < 0.02f)
+ {
+ _windFieldY[x, y] = 0f;
+ }
+
+ if (MathF.Abs(_forceFieldX[x, y]) < 0.02f)
+ {
+ _forceFieldX[x, y] = 0f;
+ }
+
+ if (MathF.Abs(_forceFieldY[x, y]) < 0.02f)
+ {
+ _forceFieldY[x, y] = 0f;
+ }
+
+ if (MathF.Abs(_airPressure[x, y]) < 0.02f)
+ {
+ _airPressure[x, y] = 0f;
+ }
+
+ if (TypeId[x, y] != 0)
+ {
+ _cellAge[x, y] += dt * 60f;
+ var maxIntegrity = _durability[TypeId[x, y]];
+ if (_integrity[x, y] < maxIntegrity)
+ {
+ _integrity[x, y] = MathF.Min(maxIntegrity, _integrity[x, y] + (dt * 60f * MathF.Max(0.02f, _hardness[TypeId[x, y]] * 0.03f)));
+ }
+ }
+
+ if (HasDynamicFieldAt(x, y))
+ {
+ ExpandActiveFieldBoundsToInclude(x, y);
+ }
+ }
+ }
+
+ _fieldBoundsDirty = false;
+ if (AreFieldVisualsEnabled())
+ {
+ MarkVisualDirtyRect(startX, startY, endX, endY);
+ }
+ }
+
+ private void InjectMovementWind(int x, int y, int nx, int ny, ushort typeId)
+ {
+ if (typeId == 0)
+ {
+ return;
+ }
+
+ var kind = (ParticleKind)_kind[typeId];
+ if (kind != ParticleKind.Solid && kind != ParticleKind.Liquid)
+ {
+ return;
+ }
+
+ var moveX = nx - x;
+ var moveY = ny - y;
+ if (moveX == 0 && moveY == 0)
+ {
+ return;
+ }
+
+ var strength = kind == ParticleKind.Solid ? 0.55f : 0.35f;
+ var impulseX = moveX * strength;
+ var impulseY = moveY * strength * 0.5f;
+
+ AddWindImpulse(nx, ny, impulseX * 0.5f, impulseY);
+ AddWindImpulse(nx - Math.Sign(moveX == 0 ? 1 : moveX), ny, impulseX * 0.25f, impulseY * 0.35f);
+ AddWindImpulse(nx + Math.Sign(moveX == 0 ? 1 : moveX), ny, impulseX * 0.25f, impulseY * 0.35f);
+ AddWindImpulse(nx, ny - 1, impulseX * 0.15f, impulseY * 0.2f);
+ }
+
+ private void AddWindImpulse(int x, int y, float impulseX, float impulseY)
+ {
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ return;
+ }
+
+ if (_settings.OuterWall && IsBoundary(x, y))
+ {
+ return;
+ }
+
+ var hadDynamicBefore = HasDynamicFieldAt(x, y);
+ _windFieldX[x, y] = Math.Clamp(_windFieldX[x, y] + impulseX, -8f, 8f);
+ _windFieldY[x, y] = Math.Clamp(_windFieldY[x, y] + impulseY, -8f, 8f);
+ TrackDynamicFieldMutation(x, y, hadDynamicBefore);
+ }
+
+ private void InjectMovementPressure(int x, int y, int nx, int ny, ushort typeId)
+ {
+ if (typeId == 0)
+ {
+ return;
+ }
+
+ var kind = (ParticleKind)_kind[typeId];
+ if (kind != ParticleKind.Solid && kind != ParticleKind.Liquid)
+ {
+ return;
+ }
+
+ var moveY = ny - y;
+ if (moveY <= 0)
+ {
+ return;
+ }
+
+ var sourceAge = _cellAge[nx, ny];
+ var impulse = ComputeMovementPressureImpulse(typeId, (ParticleKind)_kind[typeId], moveY, sourceAge);
+ if (impulse <= 0f)
+ {
+ return;
+ }
+
+ AddPressureImpulse(nx, ny, impulse);
+ AddPressureImpulse(nx - 1, ny, impulse * 0.3f);
+ AddPressureImpulse(nx + 1, ny, impulse * 0.3f);
+ }
+
+ private void AddPressureImpulse(int x, int y, float amount)
+ {
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ return;
+ }
+
+ if (_settings.OuterWall && IsBoundary(x, y))
+ {
+ return;
+ }
+
+ var hadDynamicBefore = HasDynamicFieldAt(x, y);
+ _airPressure[x, y] = Math.Clamp(_airPressure[x, y] + amount, -10f, 10f);
+ TrackDynamicFieldMutation(x, y, hadDynamicBefore);
+ }
+
+ private void TriggerExplosion(int x, int y, ushort typeId, ref uint seed)
+ {
+ var marker = _activeStepToken != 0 ? _activeStepToken : Frame + 1;
+ if (!InBounds(x, y))
+ {
+ return;
+ }
+
+ if (_explosionFrame[x, y] == marker)
+ {
+ return;
+ }
+
+ var pending = new Stack<(int X, int Y, ushort TypeId)>();
+ _explosionFrame[x, y] = marker;
+ pending.Push((x, y, typeId));
+
+ while (pending.Count > 0)
+ {
+ var current = pending.Pop();
+ var cx = current.X;
+ var cy = current.Y;
+ var currentTypeId = current.TypeId;
+ var radius = Math.Max(1, (int)_explosionRadius[currentTypeId]);
+ var force = MathF.Max(2f, _explosionForce[currentTypeId]);
+
+ for (var dx = -radius; dx <= radius; dx++)
+ {
+ for (var dy = -radius; dy <= radius; dy++)
+ {
+ var distanceSq = (dx * dx) + (dy * dy);
+ if (distanceSq > radius * radius)
+ {
+ continue;
+ }
+
+ var tx = cx + dx;
+ var ty = cy + dy;
+ if (!TryNormalizeCoordinate(ref tx, ref ty))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && IsBoundary(tx, ty))
+ {
+ continue;
+ }
+
+ var distance = MathF.Max(1f, MathF.Sqrt(distanceSq));
+ var falloff = 1f - (distance / radius);
+ var hadDynamicBefore = HasDynamicFieldAt(tx, ty);
+ _airPressure[tx, ty] = Math.Clamp(_airPressure[tx, ty] + (force * falloff * 2.5f), -10f, 10f);
+ Temperature[tx, ty] += force * falloff * 25f;
+ if (distanceSq > 0)
+ {
+ _forceFieldX[tx, ty] = Math.Clamp(_forceFieldX[tx, ty] + (dx / distance) * force * falloff, -8f, 8f);
+ _forceFieldY[tx, ty] = Math.Clamp(_forceFieldY[tx, ty] + (dy / distance) * force * falloff, -8f, 8f);
+ }
+ TrackDynamicFieldMutation(tx, ty, hadDynamicBefore);
+
+ var neighborId = TypeId[tx, ty];
+ if (neighborId == 0)
+ {
+ if (_idSmoke != 0 && NextChance(ref seed, 0.08f))
+ {
+ SetCell(tx, ty, _idSmoke);
+ }
+ else if (_idFire != 0 && NextChance(ref seed, 0.05f))
+ {
+ SetCell(tx, ty, _idFire);
+ }
+
+ continue;
+ }
+
+ if (neighborId == _idWall || neighborId == _idGlass)
+ {
+ continue;
+ }
+
+ if (_explosive[neighborId] != 0 && NextChance(ref seed, 0.25f))
+ {
+ if (_explosionFrame[tx, ty] != marker)
+ {
+ _explosionFrame[tx, ty] = marker;
+ pending.Push((tx, ty, neighborId));
+ }
+
+ ResetCell(tx, ty);
+ continue;
+ }
+
+ if (_flamability[neighborId] > 0f && _idFire != 0 && NextChance(ref seed, 0.12f))
+ {
+ ReplaceCell(tx, ty, _idFire);
+ continue;
+ }
+
+ if (_mass[neighborId] < 0.5f && NextChance(ref seed, 0.18f + falloff * 0.22f))
+ {
+ var brokenType = _brokenTarget[neighborId];
+ if (brokenType != 0 && brokenType != neighborId)
+ {
+ ReplaceCell(tx, ty, brokenType);
+ }
+ else
+ {
+ ResetCell(tx, ty);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void ApplyRadialForceBrushAtPixel(int centerX, int centerY, int brushRadius, float strength, bool inward, ToolProfile profile)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ var effectiveRadius = Math.Max(brushRadius, profile.RadiusCells);
+
+ for (var dx = -effectiveRadius; dx <= effectiveRadius; dx++)
+ {
+ for (var dy = -effectiveRadius; dy <= effectiveRadius; dy++)
+ {
+ var radiusSq = (dx * dx) + (dy * dy);
+ if (radiusSq == 0 || radiusSq > effectiveRadius * effectiveRadius)
+ {
+ continue;
+ }
+
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && IsBoundary(x, y))
+ {
+ continue;
+ }
+
+ var distance = MathF.Sqrt(radiusSq);
+ if (!ToolAffectsCell(profile.Affects, x, y))
+ {
+ continue;
+ }
+
+ var falloff = ComputeFalloff(distance, effectiveRadius, profile.Falloff);
+ var dirX = dx / distance;
+ var dirY = dy / distance;
+ if (inward)
+ {
+ dirX = -dirX;
+ dirY = -dirY;
+ }
+
+ var turbulence = SampleTurbulence(x, y, profile.Turbulence);
+ var hadDynamicBefore = HasDynamicFieldAt(x, y);
+ _forceFieldX[x, y] = Math.Clamp(_forceFieldX[x, y] + (dirX + turbulence.X) * falloff * strength * profile.Strength, -8f, 8f);
+ _forceFieldY[x, y] = Math.Clamp(_forceFieldY[x, y] + (dirY + turbulence.Y) * falloff * strength * profile.Strength, -8f, 8f);
+ TrackDynamicFieldMutation(x, y, hadDynamicBefore);
+ }
+ }
+ }
+
+ private float ComputeMovementPressureImpulse(ushort typeId, ParticleKind kind, int moveY, float cellAge)
+ {
+ if (moveY <= 0 || cellAge < 4f)
+ {
+ return 0f;
+ }
+
+ var impact = MathF.Max(0f, _mass[typeId] - 1.1f) * 0.08f;
+ impact *= MathF.Max(0.45f, _velocity[typeId] + 0.4f);
+
+ if (kind == ParticleKind.Liquid)
+ {
+ impact *= 0.3f;
+ }
+ else if (kind == ParticleKind.Solid)
+ {
+ impact *= _isStatic[typeId] != 0 ? 0f : 0.55f;
+ }
+
+ if (_isMolten[typeId] != 0)
+ {
+ impact *= 0.45f;
+ }
+
+ return Math.Clamp(impact, 0f, 0.4f);
+ }
+
+ private float GetNetForceX(int x, int y, ushort typeId)
+ {
+ var pressureGradient = SamplePressure(x - 1, y) - SamplePressure(x + 1, y);
+ var pressureInfluence = GetPressureForceInfluence(typeId, x, y);
+ return _settings.WindX + _windFieldX[x, y] + _forceFieldX[x, y] + (pressureGradient * pressureInfluence);
+ }
+
+ private float GetNetForceY(int x, int y, ushort typeId)
+ {
+ var pressureGradient = SamplePressure(x, y + 1) - SamplePressure(x, y - 1);
+ var pressureInfluence = GetPressureForceInfluence(typeId, x, y) * 0.85f;
+ return _settings.WindY + _windFieldY[x, y] + _forceFieldY[x, y] + (pressureGradient * pressureInfluence);
+ }
+
+ private float GetPressureForceInfluence(ushort typeId, int x, int y)
+ {
+ if (_cellAge[x, y] < 4f)
+ {
+ return 0f;
+ }
+
+ return ((ParticleBehaviorKind)_behaviorKind[typeId], (ParticleKind)_kind[typeId]) switch
+ {
+ (ParticleBehaviorKind.Fire, _) => 0.09f,
+ (ParticleBehaviorKind.Plasma, _) => 0.1f,
+ (_, ParticleKind.Gas) => 0.08f,
+ (_, ParticleKind.Liquid) => 0.025f,
+ (_, ParticleKind.Solid) => _isStatic[typeId] != 0 ? 0f : 0.015f,
+ _ => 0.03f,
+ };
+ }
+
+ private float SamplePressure(int x, int y)
+ {
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ return 0f;
+ }
+
+ return _airPressure[x, y];
+ }
+}
diff --git a/Sand.Core/SandSimulation.Infrastructure.cs b/Sand.Core/SandSimulation.Infrastructure.cs
new file mode 100644
index 0000000..6901f86
--- /dev/null
+++ b/Sand.Core/SandSimulation.Infrastructure.cs
@@ -0,0 +1,799 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ private uint GetVisualSettingsStamp()
+ {
+ unchecked
+ {
+ var stamp = 17u;
+ stamp = (stamp * 31u) + (_settings.EnablePressureVisuals ? 1u : 0u);
+ stamp = (stamp * 31u) + (_settings.EnableWindVisuals ? 1u : 0u);
+ stamp = (stamp * 31u) + (_settings.EnableTempVisuals ? 1u : 0u);
+ stamp = (stamp * 31u) + (_settings.EnableGasEffect ? 1u : 0u);
+ stamp = (stamp * 31u) + (_settings.EnableGlow ? 1u : 0u);
+ stamp = (stamp * 31u) + (uint)BitConverter.SingleToInt32Bits(_settings.AmbientTemperature);
+ return stamp;
+ }
+ }
+
+ private void EnsureVisualSettingsState()
+ {
+ var stamp = GetVisualSettingsStamp();
+ if (_visualSettingsStamp == stamp)
+ {
+ return;
+ }
+
+ _visualSettingsStamp = stamp;
+ MarkFullVisualDirty();
+ }
+
+ private void MarkFullVisualDirty()
+ {
+ _fullVisualDirty = true;
+ _hasDirtyVisualBounds = true;
+ _minDirtyVisualX = 0;
+ _minDirtyVisualY = 0;
+ _maxDirtyVisualX = Width - 1;
+ _maxDirtyVisualY = Height - 1;
+ }
+
+ private bool AreFieldVisualsEnabled() => _settings.EnablePressureVisuals || _settings.EnableWindVisuals;
+
+ private void MarkVisualDirty(int x, int y)
+ {
+ if (!InBounds(x, y))
+ {
+ return;
+ }
+
+ if (_deferVisualDirtyTracking)
+ {
+ return;
+ }
+
+ if (_fullVisualDirty)
+ {
+ return;
+ }
+
+ if (!_hasDirtyVisualBounds)
+ {
+ _minDirtyVisualX = _maxDirtyVisualX = x;
+ _minDirtyVisualY = _maxDirtyVisualY = y;
+ _hasDirtyVisualBounds = true;
+ return;
+ }
+
+ _minDirtyVisualX = Math.Min(_minDirtyVisualX, x);
+ _minDirtyVisualY = Math.Min(_minDirtyVisualY, y);
+ _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, x);
+ _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, y);
+ }
+
+ private void MarkVisualDirtyRect(int startX, int startY, int endX, int endY)
+ {
+ if (_deferVisualDirtyTracking)
+ {
+ return;
+ }
+
+ if (_fullVisualDirty)
+ {
+ return;
+ }
+
+ if (startX > endX || startY > endY)
+ {
+ return;
+ }
+
+ startX = Math.Max(0, startX);
+ startY = Math.Max(0, startY);
+ endX = Math.Min(Width - 1, endX);
+ endY = Math.Min(Height - 1, endY);
+ if (startX > endX || startY > endY)
+ {
+ return;
+ }
+
+ if (!_hasDirtyVisualBounds)
+ {
+ _minDirtyVisualX = startX;
+ _minDirtyVisualY = startY;
+ _maxDirtyVisualX = endX;
+ _maxDirtyVisualY = endY;
+ _hasDirtyVisualBounds = true;
+ return;
+ }
+
+ _minDirtyVisualX = Math.Min(_minDirtyVisualX, startX);
+ _minDirtyVisualY = Math.Min(_minDirtyVisualY, startY);
+ _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, endX);
+ _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, endY);
+ }
+
+ private bool TryGetDirtyVisualBounds(out int startX, out int startY, out int endX, out int endY)
+ {
+ if (_fullVisualDirty)
+ {
+ startX = 0;
+ startY = 0;
+ endX = Width - 1;
+ endY = Height - 1;
+ return true;
+ }
+
+ if (_hasDirtyVisualBounds)
+ {
+ startX = _minDirtyVisualX;
+ startY = _minDirtyVisualY;
+ endX = _maxDirtyVisualX;
+ endY = _maxDirtyVisualY;
+ return true;
+ }
+
+ startX = startY = endX = endY = 0;
+ return false;
+ }
+
+ private void ClearDirtyVisualBounds()
+ {
+ _fullVisualDirty = false;
+ _hasDirtyVisualBounds = false;
+ }
+
+ private void UpdateCachedVisualBuffers()
+ {
+ EnsureVisualSettingsState();
+ if (!TryGetDirtyVisualBounds(out var startX, out var startY, out var endX, out var endY))
+ {
+ return;
+ }
+
+ for (var y = startY; y <= endY; y++)
+ {
+ for (var x = startX; x <= endX; x++)
+ {
+ var color = ResolveVisualColor(x, y);
+ var pixelIndex = (y * Width) + x;
+ var rgbIndex = pixelIndex * 3;
+ var rgbaIndex = pixelIndex * 4;
+ _rgbBuffer[rgbIndex] = color.R;
+ _rgbBuffer[rgbIndex + 1] = color.G;
+ _rgbBuffer[rgbIndex + 2] = color.B;
+ _rgbaBuffer[rgbaIndex] = color.R;
+ _rgbaBuffer[rgbaIndex + 1] = color.G;
+ _rgbaBuffer[rgbaIndex + 2] = color.B;
+ _rgbaBuffer[rgbaIndex + 3] = 255;
+ }
+ }
+
+ ClearDirtyVisualBounds();
+ }
+
+ private bool HasDynamicFieldAt(int x, int y)
+ {
+ return
+ MathF.Abs(_windFieldX[x, y]) >= 0.02f ||
+ MathF.Abs(_windFieldY[x, y]) >= 0.02f ||
+ MathF.Abs(_forceFieldX[x, y]) >= 0.02f ||
+ MathF.Abs(_forceFieldY[x, y]) >= 0.02f ||
+ MathF.Abs(_airPressure[x, y]) >= 0.02f;
+ }
+
+ private void ExpandActiveFieldBoundsToInclude(int x, int y)
+ {
+ if (!_hasActiveFieldBounds)
+ {
+ _minActiveFieldX = _maxActiveFieldX = x;
+ _minActiveFieldY = _maxActiveFieldY = y;
+ _hasActiveFieldBounds = true;
+ return;
+ }
+
+ _minActiveFieldX = Math.Min(_minActiveFieldX, x);
+ _minActiveFieldY = Math.Min(_minActiveFieldY, y);
+ _maxActiveFieldX = Math.Max(_maxActiveFieldX, x);
+ _maxActiveFieldY = Math.Max(_maxActiveFieldY, y);
+ }
+
+ private void TrackDynamicFieldMutation(int x, int y, bool hadDynamicBefore)
+ {
+ if (!InBounds(x, y))
+ {
+ return;
+ }
+
+ var hasDynamicAfter = HasDynamicFieldAt(x, y);
+ if (hasDynamicAfter)
+ {
+ ExpandActiveFieldBoundsToInclude(x, y);
+ }
+ else if (hadDynamicBefore)
+ {
+ _fieldBoundsDirty = true;
+ }
+
+ if (AreFieldVisualsEnabled())
+ {
+ MarkVisualDirty(x, y);
+ }
+ }
+
+ private void ClearDynamicFieldsAt(int x, int y)
+ {
+ var hadDynamicBefore = HasDynamicFieldAt(x, y);
+ _windFieldX[x, y] = 0f;
+ _windFieldY[x, y] = 0f;
+ _forceFieldX[x, y] = 0f;
+ _forceFieldY[x, y] = 0f;
+ _airPressure[x, y] = 0f;
+ if (hadDynamicBefore)
+ {
+ _fieldBoundsDirty = true;
+ if (AreFieldVisualsEnabled())
+ {
+ MarkVisualDirty(x, y);
+ }
+ }
+ }
+
+ private bool TryGetOccupiedBoundsOnly(out int startX, out int startY, out int endX, out int endY)
+ {
+ if (_hasOccupiedBounds)
+ {
+ startX = _minOccupiedX;
+ startY = _minOccupiedY;
+ endX = _maxOccupiedX;
+ endY = _maxOccupiedY;
+ return true;
+ }
+
+ startX = startY = endX = endY = 0;
+ return false;
+ }
+
+ private bool TryGetSimulationBounds(out int startX, out int startY, out int endX, out int endY)
+ {
+ EnsureOccupiedBounds();
+ if (_fieldBoundsDirty && (_hasActiveFieldBounds || _hasOccupiedBounds))
+ {
+ var recomputeStartX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldX, _minOccupiedX) : _hasActiveFieldBounds ? _minActiveFieldX : _minOccupiedX;
+ var recomputeStartY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldY, _minOccupiedY) : _hasActiveFieldBounds ? _minActiveFieldY : _minOccupiedY;
+ var recomputeEndX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldX, _maxOccupiedX) : _hasActiveFieldBounds ? _maxActiveFieldX : _maxOccupiedX;
+ var recomputeEndY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldY, _maxOccupiedY) : _hasActiveFieldBounds ? _maxActiveFieldY : _maxOccupiedY;
+ RebuildActiveFieldBounds(recomputeStartX, recomputeStartY, recomputeEndX, recomputeEndY);
+ }
+
+ var hasBounds = false;
+ startX = startY = endX = endY = 0;
+ if (_hasOccupiedBounds)
+ {
+ startX = _minOccupiedX;
+ startY = _minOccupiedY;
+ endX = _maxOccupiedX;
+ endY = _maxOccupiedY;
+ hasBounds = true;
+ }
+
+ if (_hasActiveFieldBounds)
+ {
+ if (!hasBounds)
+ {
+ startX = _minActiveFieldX;
+ startY = _minActiveFieldY;
+ endX = _maxActiveFieldX;
+ endY = _maxActiveFieldY;
+ hasBounds = true;
+ }
+ else
+ {
+ startX = Math.Min(startX, _minActiveFieldX);
+ startY = Math.Min(startY, _minActiveFieldY);
+ endX = Math.Max(endX, _maxActiveFieldX);
+ endY = Math.Max(endY, _maxActiveFieldY);
+ }
+ }
+
+ return hasBounds;
+ }
+
+ private void MarkBoundsUnionDirty(bool hadFirstBounds, int firstStartX, int firstStartY, int firstEndX, int firstEndY)
+ {
+ int secondStartX;
+ int secondStartY;
+ int secondEndX;
+ int secondEndY;
+ var hasSecondBounds = AreFieldVisualsEnabled()
+ ? TryGetSimulationBounds(out secondStartX, out secondStartY, out secondEndX, out secondEndY)
+ : TryGetOccupiedBoundsOnly(out secondStartX, out secondStartY, out secondEndX, out secondEndY);
+
+ if (hasSecondBounds)
+ {
+ if (!hadFirstBounds)
+ {
+ MarkVisualDirtyRect(secondStartX, secondStartY, secondEndX, secondEndY);
+ return;
+ }
+
+ MarkVisualDirtyRect(
+ Math.Min(firstStartX, secondStartX),
+ Math.Min(firstStartY, secondStartY),
+ Math.Max(firstEndX, secondEndX),
+ Math.Max(firstEndY, secondEndY));
+ return;
+ }
+
+ if (hadFirstBounds)
+ {
+ MarkVisualDirtyRect(firstStartX, firstStartY, firstEndX, firstEndY);
+ }
+ }
+
+ private ushort ResolveOptionalTypeId(string? particleId)
+ {
+ if (particleId is null)
+ {
+ return 0;
+ }
+
+ return _library.TryGetTypeId(particleId, out var typeId) ? typeId : (ushort)0;
+ }
+
+ private float ResolveDefaultLifetime(ParticleDef definition, ParticleBalanceProfile balance)
+ {
+ var lifetime = (definition.Lifetime ?? 0f) * MathF.Max(0.01f, balance.LifetimeMultiplier);
+ if (lifetime <= 0f && balance.MaxLifetimeTicks > 0f)
+ {
+ lifetime = (balance.MinLifetimeTicks + balance.MaxLifetimeTicks) * 0.5f;
+ }
+
+ if (balance.MinLifetimeTicks > 0f)
+ {
+ lifetime = MathF.Max(lifetime, balance.MinLifetimeTicks);
+ }
+
+ if (balance.MaxLifetimeTicks > 0f)
+ {
+ lifetime = MathF.Min(lifetime, balance.MaxLifetimeTicks);
+ }
+
+ return lifetime;
+ }
+
+ private ToolProfile BuildToolProfile(string id, float defaultStrength, int fallbackRadiusCells)
+ {
+ if (_library.TryGetTypeId(id, out var typeId))
+ {
+ var definition = _library.GetDefinition(typeId);
+ var radiusCells = definition.Radius > 0f ? Math.Max(fallbackRadiusCells, (int)MathF.Ceiling(definition.Radius / (ParticleSize * 4f))) : fallbackRadiusCells;
+ var strength = definition.WindStrength > 0f ? definition.WindStrength : definition.GravityStrength > 0f ? definition.GravityStrength : definition.RepulsionStrength > 0f ? definition.RepulsionStrength : defaultStrength;
+ return new ToolProfile(
+ definition.Id,
+ radiusCells,
+ strength,
+ definition.ForceFalloff <= 0f ? 1f : definition.ForceFalloff,
+ definition.Turbulence,
+ definition.Affects.Length == 0 ? ["all"] : definition.Affects);
+ }
+
+ return new ToolProfile(id, fallbackRadiusCells, defaultStrength, 1f, 0f, ["all"]);
+ }
+
+ private void ApplyDirectionalBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY, ToolProfile profile, float[,] fieldX, float[,] fieldY, bool addPressure = false)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ var effectiveRadius = Math.Max(brushRadius, profile.RadiusCells);
+ var magnitude = MathF.Sqrt((forceX * forceX) + (forceY * forceY));
+ if (magnitude <= 0.001f)
+ {
+ forceX = 1f;
+ forceY = 0f;
+ magnitude = 1f;
+ }
+
+ for (var dx = -effectiveRadius; dx <= effectiveRadius; dx++)
+ {
+ for (var dy = -effectiveRadius; dy <= effectiveRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > effectiveRadius * effectiveRadius)
+ {
+ continue;
+ }
+
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && IsBoundary(x, y))
+ {
+ continue;
+ }
+
+ if (!ToolAffectsCell(profile.Affects, x, y))
+ {
+ continue;
+ }
+
+ var distance = MathF.Sqrt((dx * dx) + (dy * dy));
+ var falloff = ComputeFalloff(distance, effectiveRadius, profile.Falloff);
+ var turbulence = SampleTurbulence(x, y, profile.Turbulence);
+ var hadDynamicBefore = HasDynamicFieldAt(x, y);
+ fieldX[x, y] = Math.Clamp(fieldX[x, y] + (((forceX / magnitude) + turbulence.X) * falloff * profile.Strength), -8f, 8f);
+ fieldY[x, y] = Math.Clamp(fieldY[x, y] + (((forceY / magnitude) + turbulence.Y) * falloff * profile.Strength), -8f, 8f);
+ if (addPressure)
+ {
+ _airPressure[x, y] = Math.Clamp(_airPressure[x, y] + falloff * profile.Strength, -10f, 10f);
+ }
+ TrackDynamicFieldMutation(x, y, hadDynamicBefore);
+ }
+ }
+ }
+
+ private bool ToolAffectsCell(string[] affects, int x, int y)
+ {
+ if (affects.Length == 0 || affects.Contains("all", StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var typeId = TypeId[x, y];
+ if (typeId == 0)
+ {
+ return true;
+ }
+
+ var definition = _library.GetDefinition(typeId);
+ foreach (var affect in affects)
+ {
+ if (string.Equals(affect, definition.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (affect == "solid" && definition.Kind == ParticleKind.Solid)
+ {
+ return true;
+ }
+
+ if (affect == "liquid" && definition.Kind == ParticleKind.Liquid)
+ {
+ return true;
+ }
+
+ if (affect == "gas" && definition.Kind == ParticleKind.Gas)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static float ComputeFalloff(float distance, int radius, float exponent)
+ {
+ if (radius <= 0)
+ {
+ return 0f;
+ }
+
+ var normalized = 1f - (distance / radius);
+ if (normalized <= 0f)
+ {
+ return 0f;
+ }
+
+ return MathF.Pow(normalized, MathF.Max(0.1f, exponent));
+ }
+
+ private (float X, float Y) SampleTurbulence(int x, int y, float turbulence)
+ {
+ if (turbulence <= 0.001f)
+ {
+ return (0f, 0f);
+ }
+
+ var hash = Hash(x, y, Frame);
+ var tx = (((hash & 0xFFu) / 255f) - 0.5f) * turbulence;
+ var ty = ((((hash >> 8) & 0xFFu) / 255f) - 0.5f) * turbulence;
+ return (tx, ty);
+ }
+
+ private void TrySpawnOnDeath(int x, int y, ushort typeId)
+ {
+ var spawnType = _producesOnDeathTarget[typeId];
+ if (spawnType == 0)
+ {
+ return;
+ }
+
+ SpawnNeighborParticle(x, y, spawnType, Hash(x, y, Frame + 17));
+ }
+
+ private bool SpawnNeighborParticle(int x, int y, ushort spawnType, uint seed)
+ {
+ var offsets = new (int X, int Y)[]
+ {
+ (0, -1),
+ (-1, 0),
+ (1, 0),
+ (0, 1),
+ (-1, -1),
+ (1, -1),
+ (-1, 1),
+ (1, 1),
+ };
+
+ var start = (int)(seed % (uint)offsets.Length);
+ for (var i = 0; i < offsets.Length; i++)
+ {
+ var offset = offsets[(start + i) % offsets.Length];
+ var nx = x + offset.X;
+ var ny = y + offset.Y;
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ continue;
+ }
+
+ if ((_settings.OuterWall && IsBoundary(nx, ny)) || TypeId[nx, ny] != 0)
+ {
+ continue;
+ }
+
+ SetCell(nx, ny, spawnType);
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool BreakCellFromPressure(int x, int y, ushort typeId, ref uint seed)
+ {
+ var brokenType = _brokenTarget[typeId];
+ if (brokenType != 0 && brokenType != typeId)
+ {
+ ReplaceCell(x, y, brokenType);
+ Temperature[x, y] = MathF.Max(Temperature[x, y], _initialTemperature[brokenType]);
+ return true;
+ }
+
+ var isHotBreak = Temperature[x, y] >= Math.Max(200f, _burnTemperature[typeId]);
+ if (_idSmoke != 0 && isHotBreak && NextChance(ref seed, 0.08f))
+ {
+ SpawnNeighborParticle(x, y, _idSmoke, seed);
+ }
+
+ ResetCell(x, y);
+ return true;
+ }
+
+ private void EnsureOccupiedBounds()
+ {
+ if (_boundsDirty && _staleOccupiedBoundsFrames >= 8)
+ {
+ RecomputeOccupiedBounds();
+ }
+ }
+
+ private void MarkBoundsDirtyIfRemovingOccupiedCell(int x, int y)
+ {
+ if (ParticleCount == 0)
+ {
+ _hasOccupiedBounds = false;
+ _boundsDirty = false;
+ return;
+ }
+
+ if (!_hasOccupiedBounds)
+ {
+ _boundsDirty = true;
+ return;
+ }
+
+ if (x == _minOccupiedX || x == _maxOccupiedX || y == _minOccupiedY || y == _maxOccupiedY)
+ {
+ _boundsDirty = true;
+ }
+ }
+
+ private void ExpandOccupiedBoundsToInclude(int x, int y)
+ {
+ if (!_hasOccupiedBounds)
+ {
+ _minOccupiedX = _maxOccupiedX = x;
+ _minOccupiedY = _maxOccupiedY = y;
+ _hasOccupiedBounds = true;
+ return;
+ }
+
+ _minOccupiedX = Math.Min(_minOccupiedX, x);
+ _minOccupiedY = Math.Min(_minOccupiedY, y);
+ _maxOccupiedX = Math.Max(_maxOccupiedX, x);
+ _maxOccupiedY = Math.Max(_maxOccupiedY, y);
+ }
+
+ private void RecomputeOccupiedBounds()
+ {
+ _hasOccupiedBounds = false;
+ for (var y = 0; y < Height; y++)
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ if (TypeId[x, y] != 0)
+ {
+ ExpandOccupiedBoundsToInclude(x, y);
+ }
+ }
+ }
+
+ _boundsDirty = false;
+ _staleOccupiedBoundsFrames = 0;
+ }
+
+ private void ClearBoundaryWalls()
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ TryClearBoundaryWall(x, 0);
+ TryClearBoundaryWall(x, Height - 1);
+ }
+
+ for (var y = 0; y < Height; y++)
+ {
+ TryClearBoundaryWall(0, y);
+ TryClearBoundaryWall(Width - 1, y);
+ }
+ }
+
+ private void TryClearBoundaryWall(int x, int y)
+ {
+ if (TypeId[x, y] == _idWall)
+ {
+ ParticleCount = Math.Max(0, ParticleCount - 1);
+ MarkBoundsDirtyIfRemovingOccupiedCell(x, y);
+ ResetCellWithoutCountChange(x, y);
+ MarkVisualDirty(x, y);
+ }
+ }
+
+ private void ApplyBoundaryWallsFast()
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ SetBoundaryWall(x, 0);
+ SetBoundaryWall(x, Height - 1);
+ }
+
+ for (var y = 0; y < Height; y++)
+ {
+ SetBoundaryWall(0, y);
+ SetBoundaryWall(Width - 1, y);
+ }
+ }
+
+ private void SetBoundaryWall(int x, int y)
+ {
+ if (TypeId[x, y] == _idWall)
+ {
+ return;
+ }
+
+ if (TypeId[x, y] == 0)
+ {
+ ParticleCount++;
+ }
+
+ TypeId[x, y] = _idWall;
+ Temperature[x, y] = _initialTemperature[_idWall];
+ BurnTime[x, y] = _burnDuration[_idWall];
+ Burning[x, y] = _burningInit[_idWall];
+ SparkTime[x, y] = 0;
+ Lifetime[x, y] = _defaultLifetime[_idWall];
+ _pressureDuration[x, y] = 0f;
+ _cellAge[x, y] = 0f;
+ _integrity[x, y] = _durability[_idWall];
+ ExpandOccupiedBoundsToInclude(x, y);
+ MarkVisualDirty(x, y);
+ MarkProcessed(x, y);
+ }
+
+ private bool InBounds(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
+
+ private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == Width - 1 || y == Height - 1;
+
+ private bool TryNormalizeCoordinate(ref int x, ref int y)
+ {
+ if (_settings.WrapParticles)
+ {
+ x = ((x % Width) + Width) % Width;
+ y = ((y % Height) + Height) % Height;
+ return true;
+ }
+
+ return InBounds(x, y);
+ }
+
+ private void MarkProcessed(int x, int y)
+ {
+ if (_activeStepToken != 0 && InBounds(x, y))
+ {
+ _processedFrame[x, y] = _activeStepToken;
+ }
+ }
+
+ private void ForEachNeighbor8(int x, int y, Action visitor)
+ {
+ for (var dx = -1; dx <= 1; dx++)
+ {
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ if (dx == 0 && dy == 0)
+ {
+ continue;
+ }
+
+ var nx = x + dx;
+ var ny = y + dy;
+ if (TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ visitor(nx, ny);
+ }
+ }
+ }
+ }
+
+ private static uint Hash(int x, int y, int frame)
+ {
+ unchecked
+ {
+ var seed = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(frame * 83492791);
+ seed ^= seed >> 13;
+ return seed * 1274126177u;
+ }
+ }
+
+ private static bool Chance(uint hash, float threshold) => (hash & 0xFFFF) / 65535f < threshold;
+
+ private static bool NextChance(ref uint seed, float threshold)
+ {
+ unchecked
+ {
+ seed = (seed * 1664525u) + 1013904223u;
+ }
+
+ return Chance(seed, threshold);
+ }
+
+ private static float Clamp(float value, float min, float max)
+ {
+ if (min > max)
+ {
+ (min, max) = (max, min);
+ }
+
+ return MathF.Min(max, MathF.Max(min, value));
+ }
+
+ private void RebuildActiveFieldBounds(int startX, int startY, int endX, int endY)
+ {
+ _hasActiveFieldBounds = false;
+ for (var y = startY; y <= endY; y++)
+ {
+ for (var x = startX; x <= endX; x++)
+ {
+ if (HasDynamicFieldAt(x, y))
+ {
+ ExpandActiveFieldBoundsToInclude(x, y);
+ }
+ }
+ }
+
+ _fieldBoundsDirty = false;
+ }
+}
diff --git a/Sand.Core/SandSimulation.Movement.cs b/Sand.Core/SandSimulation.Movement.cs
new file mode 100644
index 0000000..0952469
--- /dev/null
+++ b/Sand.Core/SandSimulation.Movement.cs
@@ -0,0 +1,493 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ private void ApplyMovement(int x, int y, ushort typeId, bool leftFirst, ref uint seed)
+ {
+ if (_isStatic[typeId] != 0)
+ {
+ return;
+ }
+
+ var netForceX = 0f;
+ var netForceY = 0f;
+ if (_hasActiveFieldBounds || _settings.WindX != 0f || _settings.WindY != 0f)
+ {
+ var forceResponse = GetForceResponse(typeId);
+ netForceX = GetNetForceX(x, y, typeId) * forceResponse;
+ netForceY = GetNetForceY(x, y, typeId) * forceResponse;
+ }
+
+ if (netForceX > 0.25f)
+ {
+ leftFirst = false;
+ }
+ else if (netForceX < -0.25f)
+ {
+ leftFirst = true;
+ }
+
+ switch ((ParticleBehaviorKind)_behaviorKind[typeId])
+ {
+ case ParticleBehaviorKind.Fire:
+ MoveFire(x, y, leftFirst, ref seed, netForceX);
+ return;
+ case ParticleBehaviorKind.BurningWood:
+ return;
+ case ParticleBehaviorKind.Ember:
+ MoveEmber(x, y, leftFirst, ref seed, netForceX);
+ return;
+ case ParticleBehaviorKind.Plasma:
+ MovePlasma(x, y, leftFirst, ref seed, netForceX, netForceY);
+ return;
+ }
+
+ switch ((ParticleKind)_kind[typeId])
+ {
+ case ParticleKind.Solid:
+ MoveSolid(x, y, typeId, leftFirst, ref seed, netForceX);
+ break;
+ case ParticleKind.Liquid:
+ MoveLiquid(x, y, typeId, leftFirst, ref seed, netForceX);
+ break;
+ case ParticleKind.Gas:
+ MoveGas(x, y, typeId, leftFirst, ref seed, netForceX, netForceY);
+ break;
+ }
+ }
+
+ private void MoveSolid(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX)
+ {
+ if (TryMoveSolidDown(x, y, typeId))
+ {
+ return;
+ }
+
+ if (!CanSlipSolid(typeId, ref seed, netForceX))
+ {
+ return;
+ }
+
+ if (leftFirst)
+ {
+ if (TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return;
+ }
+
+ TryMoveEmpty(x, y, x + 1, y + 1);
+ return;
+ }
+
+ if (TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return;
+ }
+
+ TryMoveEmpty(x, y, x - 1, y + 1);
+ }
+
+ private void MoveLiquid(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX)
+ {
+ if (TryMoveLiquidDown(x, y, typeId))
+ {
+ return;
+ }
+
+ var windPreferred = netForceX > 0.2f ? 1 : netForceX < -0.2f ? -1 : 0;
+ if (windPreferred != 0 && CanFlowLiquid(typeId, ref seed, lateral: true, netForceX))
+ {
+ if (TryMoveEmpty(x, y, x + windPreferred, y))
+ {
+ return;
+ }
+ }
+
+ var diagonalAllowed = CanFlowLiquid(typeId, ref seed, lateral: false, netForceX);
+ var lateralAllowed = diagonalAllowed || CanFlowLiquid(typeId, ref seed, lateral: true, netForceX);
+
+ if (leftFirst)
+ {
+ if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y + 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y + 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+
+ private void MoveGas(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX, float netForceY)
+ {
+ if (CanRiseGas(typeId, ref seed, netForceY) && TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ var windPreferred = netForceX > 0.15f ? 1 : netForceX < -0.15f ? -1 : 0;
+ if (windPreferred != 0 && CanDriftGas(typeId, ref seed, netForceX, lateral: true) && TryMoveEmpty(x, y, x + windPreferred, y))
+ {
+ return;
+ }
+
+ var diagonalAllowed = CanDriftGas(typeId, ref seed, netForceX, lateral: false);
+ var lateralAllowed = diagonalAllowed || CanDriftGas(typeId, ref seed, netForceX, lateral: true);
+
+ if (leftFirst)
+ {
+ if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y - 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y - 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y - 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+
+ if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y - 1))
+ {
+ return;
+ }
+
+ if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+
+ private bool TryMoveInto(int x, int y, int nx, int ny, ushort typeId, bool swapAllowed)
+ {
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ return false;
+ }
+
+ if (_settings.OuterWall && typeId != _idWall && IsBoundary(nx, ny))
+ {
+ return false;
+ }
+
+ var neighborId = TypeId[nx, ny];
+ if (neighborId == 0)
+ {
+ MoveCell(x, y, nx, ny);
+ return true;
+ }
+
+ if (!swapAllowed)
+ {
+ return false;
+ }
+
+ var neighborKind = (ParticleKind)_kind[neighborId];
+ var movableNeighbor = neighborKind == ParticleKind.Liquid || neighborKind == ParticleKind.Gas;
+ if (movableNeighbor && _mass[typeId] > _mass[neighborId])
+ {
+ SwapCells(x, y, nx, ny);
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryMoveEmpty(int x, int y, int nx, int ny)
+ {
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ return false;
+ }
+
+ if (_settings.OuterWall && IsBoundary(nx, ny))
+ {
+ return false;
+ }
+
+ if (TypeId[nx, ny] != 0)
+ {
+ return false;
+ }
+
+ MoveCell(x, y, nx, ny);
+ return true;
+ }
+
+ private bool TryMoveSolidDown(int x, int y, ushort typeId)
+ {
+ var nx = x;
+ var ny = y + 1;
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ return false;
+ }
+
+ if (_settings.OuterWall && IsBoundary(nx, ny))
+ {
+ return false;
+ }
+
+ var below = TypeId[nx, ny];
+ if (below == 0)
+ {
+ MoveCell(x, y, nx, ny);
+ return true;
+ }
+
+ var belowKind = (ParticleKind)_kind[below];
+ if ((belowKind == ParticleKind.Liquid || belowKind == ParticleKind.Gas) && _mass[typeId] > _mass[below])
+ {
+ SwapCells(x, y, nx, ny);
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryMoveLiquidDown(int x, int y, ushort typeId)
+ {
+ var nx = x;
+ var ny = y + 1;
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ return false;
+ }
+
+ if (_settings.OuterWall && IsBoundary(nx, ny))
+ {
+ return false;
+ }
+
+ var below = TypeId[nx, ny];
+ if (below == 0)
+ {
+ MoveCell(x, y, nx, ny);
+ return true;
+ }
+
+ var belowKind = (ParticleKind)_kind[below];
+ if (belowKind == ParticleKind.Gas && _mass[typeId] > _mass[below])
+ {
+ SwapCells(x, y, nx, ny);
+ return true;
+ }
+
+ if (_isMolten[typeId] != 0 && belowKind == ParticleKind.Liquid && _mass[typeId] > _mass[below])
+ {
+ SwapCells(x, y, nx, ny);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void MoveFire(int x, int y, bool leftFirst, ref uint seed, float netForceX)
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ var preferredRight = netForceX > 0.05f ? true : netForceX < -0.05f ? false : !leftFirst;
+ if (preferredRight)
+ {
+ if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+ else
+ {
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+
+ if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+ }
+
+ if (NextChance(ref seed, 0.15f))
+ {
+ ResetCell(x, y);
+ }
+ }
+
+ private void MoveEmber(int x, int y, bool leftFirst, ref uint seed, float netForceX)
+ {
+ if (NextChance(ref seed, MathF.Max(0.1f, _upwardBias[TypeId[x, y]])))
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ if (leftFirst)
+ {
+ if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y))
+ {
+ return;
+ }
+ }
+ else if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y))
+ {
+ return;
+ }
+ }
+
+ MoveSolid(x, y, TypeId[x, y], leftFirst, ref seed, netForceX);
+ }
+
+ private void MovePlasma(int x, int y, bool leftFirst, ref uint seed, float netForceX, float netForceY)
+ {
+ if (TryMoveEmpty(x, y, x, y - 1))
+ {
+ return;
+ }
+
+ var lateralBias = MathF.Max(0.15f, _sideDriftBias[TypeId[x, y]]);
+ if (NextChance(ref seed, lateralBias))
+ {
+ var dir = netForceX > 0.05f ? 1 : netForceX < -0.05f ? -1 : leftFirst ? -1 : 1;
+ if (TryMoveEmpty(x, y, x + dir, y - 1) || TryMoveEmpty(x, y, x + dir, y))
+ {
+ return;
+ }
+ }
+
+ MoveGas(x, y, TypeId[x, y], leftFirst, ref seed, netForceX, netForceY);
+ }
+
+ private float GetEffectiveVelocity(ushort typeId)
+ {
+ if (_velocity[typeId] > 0f)
+ {
+ return _velocity[typeId];
+ }
+
+ return (ParticleKind)_kind[typeId] switch
+ {
+ ParticleKind.Solid => 0.45f,
+ ParticleKind.Liquid => 0.35f,
+ ParticleKind.Gas => 0.25f,
+ _ => 0.35f,
+ };
+ }
+
+ private float GetForceResponse(ushort typeId)
+ {
+ var mobility = 0.35f + GetEffectiveVelocity(typeId);
+ var damping = MathF.Max(0.25f, (_mass[typeId] * 0.85f) + (_friction[typeId] * 0.35f) + (_viscosity[typeId] * 0.45f));
+ return Clamp((mobility / damping) * _forceResponseMultiplier[typeId], 0.15f, 2f);
+ }
+
+ private bool CanSlipSolid(ushort typeId, ref uint seed, float netForceX)
+ {
+ if (MathF.Abs(netForceX) > 0.45f)
+ {
+ return true;
+ }
+
+ var chance = Clamp(0.2f + (GetEffectiveVelocity(typeId) * 0.95f) - (_friction[typeId] * 0.35f), 0.1f, 0.95f);
+ return NextChance(ref seed, chance);
+ }
+
+ private bool CanFlowLiquid(ushort typeId, ref uint seed, bool lateral, float netForceX)
+ {
+ var forceBonus = MathF.Min(0.25f, MathF.Abs(netForceX) * 0.08f);
+ var velocity = GetEffectiveVelocity(typeId);
+ var baseChance = lateral
+ ? 0.18f + (velocity * 0.95f) - (_viscosity[typeId] * 0.42f) - (_friction[typeId] * 0.08f)
+ : 0.28f + (velocity * 0.9f) - (_viscosity[typeId] * 0.28f) - (_friction[typeId] * 0.05f);
+ var flowScale = lateral ? _lateralFlowMultiplier[typeId] : _diagonalFlowMultiplier[typeId];
+ var chance = Clamp((baseChance * flowScale) + forceBonus, 0.03f, 0.98f);
+ return NextChance(ref seed, chance);
+ }
+
+ private bool CanRiseGas(ushort typeId, ref uint seed, float netForceY)
+ {
+ if (netForceY < -0.65f)
+ {
+ return true;
+ }
+
+ var velocity = GetEffectiveVelocity(typeId);
+ var buoyancy = MathF.Max(0f, -netForceY) * 0.08f;
+ var chance = Clamp(0.1f + (velocity * 1.8f) - (_viscosity[typeId] * 0.22f) + buoyancy, 0.05f, 0.99f);
+ return NextChance(ref seed, chance);
+ }
+
+ private bool CanDriftGas(ushort typeId, ref uint seed, float netForceX, bool lateral)
+ {
+ var forceBonus = MathF.Min(0.28f, MathF.Abs(netForceX) * 0.08f);
+ var velocity = GetEffectiveVelocity(typeId);
+ var baseChance = lateral
+ ? 0.18f + (velocity * 1.15f) - (_viscosity[typeId] * 0.1f)
+ : 0.22f + (velocity * 1.2f) - (_viscosity[typeId] * 0.16f);
+ var chance = Clamp(baseChance + forceBonus - (_friction[typeId] * 0.04f), 0.05f, 0.99f);
+ return NextChance(ref seed, chance);
+ }
+}
diff --git a/Sand.Core/SandSimulation.Painting.cs b/Sand.Core/SandSimulation.Painting.cs
new file mode 100644
index 0000000..381cde9
--- /dev/null
+++ b/Sand.Core/SandSimulation.Painting.cs
@@ -0,0 +1,266 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ public void Clear()
+ {
+ Array.Clear(TypeId);
+ Array.Clear(Temperature);
+ Array.Clear(BurnTime);
+ Array.Clear(Burning);
+ Array.Clear(SparkTime);
+ Array.Clear(Lifetime);
+ Array.Clear(_pressureDuration);
+ Array.Clear(_cellAge);
+ Array.Clear(_integrity);
+ Array.Clear(_windFieldX);
+ Array.Clear(_windFieldY);
+ Array.Clear(_forceFieldX);
+ Array.Clear(_forceFieldY);
+ Array.Clear(_airPressure);
+ Array.Clear(_processedFrame);
+ Array.Clear(_explosionFrame);
+ Array.Clear(_rgbBuffer);
+ Array.Clear(_rgbaBuffer);
+ ParticleCount = 0;
+ Frame = 0;
+ _activeStepToken = 0;
+ _boundsDirty = false;
+ _staleOccupiedBoundsFrames = 0;
+ _hasOccupiedBounds = false;
+ _fieldBoundsDirty = false;
+ _hasActiveFieldBounds = false;
+ _hasDirtyVisualBounds = false;
+ _fullVisualDirty = true;
+ RefreshSettingsState();
+ }
+
+ public void RefreshSettingsState()
+ {
+ if (_settings.OuterWall && _idWall != 0)
+ {
+ for (var x = 0; x < Width; x++)
+ {
+ ForceSetCell(x, 0, _idWall);
+ ForceSetCell(x, Height - 1, _idWall);
+ }
+
+ for (var y = 0; y < Height; y++)
+ {
+ ForceSetCell(0, y, _idWall);
+ ForceSetCell(Width - 1, y, _idWall);
+ }
+ }
+ else if (_idWall != 0)
+ {
+ ClearBoundaryWalls();
+ }
+
+ RecomputeOccupiedBounds();
+ MarkFullVisualDirty();
+ }
+
+ public ushort GetTypeIdAtCell(int x, int y) => TypeId[x, y];
+
+ public float GetTemperatureAtCell(int x, int y) => Temperature[x, y];
+
+ public (float X, float Y) GetWindAtCell(int x, int y) => (_windFieldX[x, y], _windFieldY[x, y]);
+
+ public (float X, float Y) GetForceAtCell(int x, int y) => (_forceFieldX[x, y], _forceFieldY[x, y]);
+
+ public float GetAirPressureAtCell(int x, int y) => _airPressure[x, y];
+
+ public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY)
+ {
+ ApplyDirectionalBrush(centerX, centerY, brushRadius, forceX, forceY, _windTool, _windFieldX, _windFieldY);
+ }
+
+ public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY)
+ {
+ ApplyDirectionalBrush(centerX, centerY, brushRadius, forceX, forceY, _airTool, _windFieldX, _windFieldY, addPressure: true);
+ }
+
+ public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength)
+ {
+ ApplyRadialForceBrushAtPixel(centerX, centerY, brushRadius, MathF.Abs(strength), inward: true, _gravityTool);
+ }
+
+ public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength)
+ {
+ ApplyRadialForceBrushAtPixel(centerX, centerY, brushRadius, MathF.Abs(strength), inward: false, _repulsorTool);
+ }
+
+ public void CreateParticleAtPixel(int x, int y, string particleId)
+ {
+ var gridX = x / ParticleSize;
+ var gridY = y / ParticleSize;
+ if (!TryNormalizeCoordinate(ref gridX, ref gridY))
+ {
+ return;
+ }
+
+ if (!_library.TryGetTypeId(particleId.ToLowerInvariant(), out var typeId))
+ {
+ return;
+ }
+
+ if (_settings.OuterWall && typeId != _idWall && IsBoundary(gridX, gridY))
+ {
+ return;
+ }
+
+ if (TypeId[gridX, gridY] != 0)
+ {
+ if (typeId == _idSpark)
+ {
+ var targetId = TypeId[gridX, gridY];
+ if (_conductivity[targetId] > 0.5f && SparkTime[gridX, gridY] == 0)
+ {
+ SparkTime[gridX, gridY] = 4;
+ }
+ }
+ return;
+ }
+
+ SetCell(gridX, gridY, typeId, markProcessed: false, clearDynamics: true);
+ }
+
+ public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId)
+ {
+ for (var dx = -brushRadius; dx <= brushRadius; dx++)
+ {
+ for (var dy = -brushRadius; dy <= brushRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ CreateParticleAtPixel(centerX + (dx * ParticleSize), centerY + (dy * ParticleSize), particleId);
+ }
+ }
+ }
+
+ public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ if (!_library.TryGetTypeId(particleId.ToLowerInvariant(), out var typeId))
+ {
+ return;
+ }
+
+ var radiusSq = brushRadius * brushRadius;
+ var diameter = (brushRadius * 2) + 1;
+ var maxCells = diameter * diameter;
+ var attempts = Math.Max(maxParticles * 6, maxCells);
+ var placed = 0;
+
+ for (var i = 0; i < attempts && placed < maxParticles; i++)
+ {
+ var hashX = Hash(gridCenterX + (i * 17), gridCenterY - (i * 31), seed + i);
+ var hashY = Hash(gridCenterX - (i * 29), gridCenterY + (i * 13), seed - i);
+ var dx = (int)(hashX % (uint)diameter) - brushRadius;
+ var dy = (int)(hashY % (uint)diameter) - brushRadius;
+ if ((dx * dx) + (dy * dy) > radiusSq)
+ {
+ continue;
+ }
+
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && typeId != _idWall && IsBoundary(x, y))
+ {
+ continue;
+ }
+
+ if (TypeId[x, y] != 0)
+ {
+ continue;
+ }
+
+ SetCell(x, y, typeId, markProcessed: false, clearDynamics: true);
+ placed++;
+ }
+ }
+
+ public void ClearParticleCircle(int centerX, int centerY, int brushRadius)
+ {
+ for (var dx = -brushRadius; dx <= brushRadius; dx++)
+ {
+ for (var dy = -brushRadius; dy <= brushRadius; dy++)
+ {
+ if ((dx * dx) + (dy * dy) > brushRadius * brushRadius)
+ {
+ continue;
+ }
+
+ var gridX = (centerX / ParticleSize) + dx;
+ var gridY = (centerY / ParticleSize) + dy;
+ if (!TryNormalizeCoordinate(ref gridX, ref gridY))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && IsBoundary(gridX, gridY))
+ {
+ continue;
+ }
+
+ if (TypeId[gridX, gridY] != 0)
+ {
+ ResetCell(gridX, gridY);
+ }
+ }
+ }
+ }
+
+ public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed)
+ {
+ var gridCenterX = centerX / ParticleSize;
+ var gridCenterY = centerY / ParticleSize;
+ var radiusSq = brushRadius * brushRadius;
+ var diameter = (brushRadius * 2) + 1;
+ var maxCells = diameter * diameter;
+ var attempts = Math.Max(maxParticles * 6, maxCells);
+ var cleared = 0;
+
+ for (var i = 0; i < attempts && cleared < maxParticles; i++)
+ {
+ var hashX = Hash(gridCenterX + (i * 17), gridCenterY - (i * 31), seed + i);
+ var hashY = Hash(gridCenterX - (i * 29), gridCenterY + (i * 13), seed - i);
+ var dx = (int)(hashX % (uint)diameter) - brushRadius;
+ var dy = (int)(hashY % (uint)diameter) - brushRadius;
+ if ((dx * dx) + (dy * dy) > radiusSq)
+ {
+ continue;
+ }
+
+ var x = gridCenterX + dx;
+ var y = gridCenterY + dy;
+ if (!TryNormalizeCoordinate(ref x, ref y))
+ {
+ continue;
+ }
+
+ if (_settings.OuterWall && IsBoundary(x, y))
+ {
+ continue;
+ }
+
+ if (TypeId[x, y] == 0)
+ {
+ continue;
+ }
+
+ ResetCell(x, y);
+ cleared++;
+ }
+ }
+
+}
diff --git a/Sand.Core/SandSimulation.Step.cs b/Sand.Core/SandSimulation.Step.cs
new file mode 100644
index 0000000..a56e4b0
--- /dev/null
+++ b/Sand.Core/SandSimulation.Step.cs
@@ -0,0 +1,299 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ public void Step(float dt)
+ {
+ if (_settings.PauseSim)
+ {
+ return;
+ }
+
+ if (_settings.EnableAcceleration && Accelerator is not null)
+ {
+ Accelerator.Step(this, dt);
+ return;
+ }
+
+ int preStepStartX;
+ int preStepStartY;
+ int preStepEndX;
+ int preStepEndY;
+ var hadPreStepBounds = AreFieldVisualsEnabled()
+ ? TryGetSimulationBounds(out preStepStartX, out preStepStartY, out preStepEndX, out preStepEndY)
+ : TryGetOccupiedBoundsOnly(out preStepStartX, out preStepStartY, out preStepEndX, out preStepEndY);
+ _deferVisualDirtyTracking = true;
+ DecayFields(dt);
+
+ if (ParticleCount == 0)
+ {
+ _deferVisualDirtyTracking = false;
+ MarkBoundsUnionDirty(hadPreStepBounds, preStepStartX, preStepStartY, preStepEndX, preStepEndY);
+ Frame++;
+ FrameStats.Frame = Frame;
+ FrameStats.ProcessedCells = 0;
+ FrameStats.ParticleCount = 0;
+ FrameStats.MinActiveX = 0;
+ FrameStats.MinActiveY = 0;
+ FrameStats.MaxActiveX = 0;
+ FrameStats.MaxActiveY = 0;
+ FrameStats.LoadedChunkCount = 0;
+ FrameStats.ActiveChunkCount = 0;
+ FrameStats.DirtyChunkCount = 0;
+ FrameStats.SteppedChunkCount = 0;
+ FrameStats.SleepingChunkCount = 0;
+ FrameStats.FieldPageCount = 0;
+ FrameStats.MoveAttemptCount = 0;
+ FrameStats.VerticalMoveAttemptCount = 0;
+ FrameStats.DiagonalMoveAttemptCount = 0;
+ FrameStats.LateralMoveAttemptCount = 0;
+ FrameStats.SuccessfulMoveCount = 0;
+ FrameStats.SwapAttemptCount = 0;
+ FrameStats.StalledMovableCount = 0;
+ FrameStats.MovementOnlyFastPathCount = 0;
+ FrameStats.FullRuntimeStepCount = 0;
+ FrameStats.FullRuntimeSolidCount = 0;
+ FrameStats.FullRuntimeLiquidCount = 0;
+ FrameStats.FullRuntimeGasCount = 0;
+ FrameStats.MovedParticleCount = 0;
+ FrameStats.SwappedParticleCount = 0;
+ FrameStats.VisualDirtyPageCount = 0;
+ FrameStats.FrameBuildBytesTouched = 0;
+ FrameStats.ActivationTimeMicroseconds = 0;
+ FrameStats.MovementTimeMicroseconds = 0;
+ FrameStats.RuntimeTimeMicroseconds = 0;
+ FrameStats.FieldDecayTimeMicroseconds = 0;
+ FrameStats.RenderTimeMicroseconds = 0;
+ return;
+ }
+
+ EnsureOccupiedBounds();
+ var margin = _settings.WrapParticles ? 0 : 1;
+ var startX = _settings.WrapParticles ? 0 : Math.Max(0, _minOccupiedX - margin);
+ var endX = _settings.WrapParticles ? Width - 1 : Math.Min(Width - 1, _maxOccupiedX + margin);
+ var startY = _settings.WrapParticles ? 0 : Math.Max(0, _minOccupiedY - margin);
+ var endY = _settings.WrapParticles ? Height - 1 : Math.Min(Height - 1, _maxOccupiedY + margin);
+
+ _activeStepToken = Frame + 1;
+ var processedCells = 0;
+
+ for (var y = endY; y >= startY; y--)
+ {
+ for (var x = startX; x <= endX; x++)
+ {
+ if (_processedFrame[x, y] == _activeStepToken)
+ {
+ continue;
+ }
+
+ var typeId = TypeId[x, y];
+ if (typeId == 0)
+ {
+ continue;
+ }
+
+ var seed = Hash(x, y, Frame);
+ var leftFirst = (seed & 1u) == 0;
+ MarkProcessed(x, y);
+ processedCells++;
+
+ if (TickLifetime(x, y, dt))
+ {
+ continue;
+ }
+
+ typeId = TypeId[x, y];
+ if (TickSpark(x, y, typeId, ref seed))
+ {
+ continue;
+ }
+
+ typeId = TypeId[x, y];
+ AutoIgnite(x, y, typeId);
+ ApplyRuntimeEmissionAndProduction(x, y, typeId, ref seed);
+ if (TickBurning(x, y, typeId, ref seed))
+ {
+ continue;
+ }
+
+ typeId = TypeId[x, y];
+ if (TickSpecialBehavior(x, y, typeId, ref seed))
+ {
+ continue;
+ }
+
+ typeId = TypeId[x, y];
+ ApplyPhaseTransitions(x, y, ref typeId);
+ if (typeId == 0)
+ {
+ continue;
+ }
+
+ if (ApplyLocalReactions(x, y, ref typeId, ref seed))
+ {
+ continue;
+ }
+
+ typeId = TypeId[x, y];
+ if (TickPressure(x, y, typeId, ref seed))
+ {
+ continue;
+ }
+
+ ApplyTemperatureDiffusion(x, y, typeId);
+ ApplyMovement(x, y, typeId, leftFirst, ref seed);
+ }
+ }
+
+ if (_settings.OuterWall && _idWall != 0)
+ {
+ ApplyBoundaryWallsFast();
+ }
+
+ _deferVisualDirtyTracking = false;
+ MarkBoundsUnionDirty(hadPreStepBounds, preStepStartX, preStepStartY, preStepEndX, preStepEndY);
+ Frame++;
+ _activeStepToken = 0;
+ _staleOccupiedBoundsFrames = _boundsDirty ? _staleOccupiedBoundsFrames + 1 : 0;
+ FrameStats.Frame = Frame;
+ FrameStats.ProcessedCells = processedCells;
+ FrameStats.ParticleCount = ParticleCount;
+ FrameStats.MinActiveX = _hasOccupiedBounds ? _minOccupiedX : 0;
+ FrameStats.MinActiveY = _hasOccupiedBounds ? _minOccupiedY : 0;
+ FrameStats.MaxActiveX = _hasOccupiedBounds ? _maxOccupiedX : 0;
+ FrameStats.MaxActiveY = _hasOccupiedBounds ? _maxOccupiedY : 0;
+ FrameStats.LoadedChunkCount = 0;
+ FrameStats.ActiveChunkCount = 0;
+ FrameStats.DirtyChunkCount = 0;
+ FrameStats.SteppedChunkCount = 0;
+ FrameStats.SleepingChunkCount = 0;
+ FrameStats.FieldPageCount = 0;
+ FrameStats.MoveAttemptCount = 0;
+ FrameStats.VerticalMoveAttemptCount = 0;
+ FrameStats.DiagonalMoveAttemptCount = 0;
+ FrameStats.LateralMoveAttemptCount = 0;
+ FrameStats.SuccessfulMoveCount = 0;
+ FrameStats.SwapAttemptCount = 0;
+ FrameStats.StalledMovableCount = 0;
+ FrameStats.MovementOnlyFastPathCount = 0;
+ FrameStats.FullRuntimeStepCount = 0;
+ FrameStats.FullRuntimeSolidCount = 0;
+ FrameStats.FullRuntimeLiquidCount = 0;
+ FrameStats.FullRuntimeGasCount = 0;
+ FrameStats.MovedParticleCount = 0;
+ FrameStats.SwappedParticleCount = 0;
+ FrameStats.VisualDirtyPageCount = 0;
+ FrameStats.FrameBuildBytesTouched = 0;
+ FrameStats.ActivationTimeMicroseconds = 0;
+ FrameStats.MovementTimeMicroseconds = 0;
+ FrameStats.RuntimeTimeMicroseconds = 0;
+ FrameStats.FieldDecayTimeMicroseconds = 0;
+ FrameStats.RenderTimeMicroseconds = 0;
+ }
+
+ public ReadOnlySpan BuildRgbFrame()
+ {
+ UpdateCachedVisualBuffers();
+ return _rgbBuffer;
+ }
+
+ public ReadOnlySpan BuildRgbaFrame()
+ {
+ UpdateCachedVisualBuffers();
+ FrameStats.FrameBuildBytesTouched = (long)_rgbaBuffer.Length;
+ FrameStats.RenderTimeMicroseconds = 0;
+ return _rgbaBuffer;
+ }
+
+ public void BuildRgbaFrame(Span destination)
+ {
+ var expectedLength = Width * Height * 4;
+ if (destination.Length < expectedLength)
+ {
+ throw new ArgumentException($"RGBA destination must be at least {expectedLength} bytes.", nameof(destination));
+ }
+
+ UpdateCachedVisualBuffers();
+ _rgbaBuffer.AsSpan().CopyTo(destination);
+ FrameStats.FrameBuildBytesTouched = (long)_rgbaBuffer.Length;
+ FrameStats.RenderTimeMicroseconds = 0;
+ }
+
+ private Rgb24 ResolveVisualColor(int x, int y)
+ {
+ var typeId = TypeId[x, y];
+ if (typeId == 0)
+ {
+ if (_settings.EnablePressureVisuals)
+ {
+ var pressure = MathF.Abs(_airPressure[x, y]);
+ if (pressure > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140);
+ if (_airPressure[x, y] >= 0f)
+ {
+ return new Rgb24((byte)(26 + tint), (byte)(18 + tint / 3), 24);
+ }
+
+ return new Rgb24(18, (byte)(24 + tint / 4), (byte)(30 + tint));
+ }
+ }
+
+ if (_settings.EnableWindVisuals)
+ {
+ var totalFieldX = _windFieldX[x, y] + _forceFieldX[x, y];
+ var totalFieldY = _windFieldY[x, y] + _forceFieldY[x, y];
+ var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY));
+ if (windStrength > 0.08f)
+ {
+ var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120);
+ return new Rgb24((byte)(16 + tint / 3), (byte)(24 + tint / 2), (byte)(32 + tint));
+ }
+ }
+
+ return new Rgb24(0, 0, 0);
+ }
+
+ var color = SparkTime[x, y] > 0 ? new Rgb24(255, 255, 150) : _colorLut[typeId];
+ if (_settings.EnableTempVisuals)
+ {
+ var delta = Temperature[x, y] - _settings.AmbientTemperature;
+ if (delta > 5f)
+ {
+ var heat = (byte)Math.Clamp((int)(delta * 4f), 0, 120);
+ color = new Rgb24(
+ (byte)Math.Min(255, color.R + heat),
+ (byte)Math.Max(0, color.G - heat / 3),
+ (byte)Math.Max(0, color.B - heat / 2));
+ }
+ else if (delta < -5f)
+ {
+ var cool = (byte)Math.Clamp((int)(Math.Abs(delta) * 4f), 0, 120);
+ color = new Rgb24(
+ (byte)Math.Max(0, color.R - cool / 2),
+ (byte)Math.Max(0, color.G - cool / 3),
+ (byte)Math.Min(255, color.B + cool));
+ }
+ }
+
+ if (_settings.EnableGasEffect && _kind[typeId] == (byte)ParticleKind.Gas)
+ {
+ var bias = (byte)(Hash(x, y, Frame) & 0x0F);
+ color = new Rgb24(
+ (byte)Math.Min(255, color.R + bias),
+ (byte)Math.Min(255, color.G + bias),
+ (byte)Math.Min(255, color.B + bias));
+ }
+
+ if (_settings.EnableGlow && (Burning[x, y] != 0 || typeId == _idFire || typeId == _idLava || SparkTime[x, y] > 0))
+ {
+ color = new Rgb24(
+ (byte)Math.Min(255, color.R + 25),
+ (byte)Math.Min(255, color.G + 18),
+ (byte)Math.Min(255, color.B + 12));
+ }
+
+ return color;
+ }
+
+}
diff --git a/Sand.Core/SandSimulation.Thermal.cs b/Sand.Core/SandSimulation.Thermal.cs
new file mode 100644
index 0000000..30c923c
--- /dev/null
+++ b/Sand.Core/SandSimulation.Thermal.cs
@@ -0,0 +1,609 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ private bool TickLifetime(int x, int y, float dt)
+ {
+ var typeId = TypeId[x, y];
+ if (typeId == 0)
+ {
+ return false;
+ }
+
+ if (Lifetime[x, y] > 0f)
+ {
+ Lifetime[x, y] -= dt * 60f;
+ }
+
+ if (Lifetime[x, y] <= 0f && _defaultLifetime[typeId] > 0f)
+ {
+ if (typeId == _idSteam && TryCondenseSteam(x, y, ref typeId))
+ {
+ return true;
+ }
+
+ ResetCell(x, y);
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TickSpark(int x, int y, ushort typeId, ref uint seed)
+ {
+ var spark = SparkTime[x, y];
+ if (spark > 0)
+ {
+ SparkTime[x, y]--;
+ ForEachNeighbor8(x, y, (nx, ny) =>
+ {
+ var neighborId = TypeId[nx, ny];
+ if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f) && SparkTime[nx, ny] == 0)
+ {
+ SparkTime[nx, ny] = 4;
+ MarkProcessed(nx, ny);
+ }
+ });
+
+ if (typeId == _idSpark)
+ {
+ ResetCell(x, y);
+ return true;
+ }
+
+ if (SparkTime[x, y] == 0)
+ {
+ SparkTime[x, y] = -10;
+ }
+ }
+ else if (spark < 0)
+ {
+ SparkTime[x, y]++;
+ }
+
+ if (typeId != _idEnergy)
+ {
+ return false;
+ }
+
+ TransferEnergy(x, y, typeId, ref seed, 0.3f);
+ return false;
+ }
+
+ private void AutoIgnite(int x, int y, ushort typeId)
+ {
+ if (Burning[x, y] == 0 && _burnTemperature[typeId] > 0f && Temperature[x, y] >= _burnTemperature[typeId] && _flamability[typeId] > 0f)
+ {
+ Burning[x, y] = 1;
+ BurnTime[x, y] = MathF.Max(_burnDuration[typeId], 1f);
+ }
+ }
+
+ private void ApplyRuntimeEmissionAndProduction(int x, int y, ushort typeId, ref uint seed)
+ {
+ var behavior = (ParticleBehaviorKind)_behaviorKind[typeId];
+ var isEffectEmitter =
+ behavior == ParticleBehaviorKind.Fire ||
+ behavior == ParticleBehaviorKind.BurningWood ||
+ behavior == ParticleBehaviorKind.Ember ||
+ behavior == ParticleBehaviorKind.Plasma;
+
+ var emission = _heatEmission[typeId] * _heatEmissionMultiplier[typeId];
+ if (behavior == ParticleBehaviorKind.Fire && emission <= 0f)
+ {
+ emission = 24f;
+ }
+ else if (behavior == ParticleBehaviorKind.Plasma && emission <= 0f)
+ {
+ emission = 80f;
+ }
+ else if (behavior == ParticleBehaviorKind.Ember && emission <= 0f)
+ {
+ emission = 12f;
+ }
+ else if (_isMolten[typeId] != 0 && emission <= 0f)
+ {
+ emission = typeId == _idLava ? 42f : 28f;
+ }
+
+ if (emission > 0f)
+ {
+ ApplyHeatEmission(x, y, emission);
+ }
+
+ var transfer = _energyTransfer[typeId] * _energyTransferMultiplier[typeId];
+ if ((behavior == ParticleBehaviorKind.Plasma || typeId == _idEnergy) && transfer <= 0f)
+ {
+ transfer = 100f;
+ }
+
+ if (transfer > 0f)
+ {
+ var chance = behavior == ParticleBehaviorKind.Plasma
+ ? 0.9f
+ : MathF.Min(0.85f, 0.15f + (transfer / 100f) * 0.55f);
+ TransferEnergy(x, y, typeId, ref seed, chance);
+ if (behavior == ParticleBehaviorKind.Plasma)
+ {
+ ForEachNeighbor8(x, y, (nx, ny) =>
+ {
+ var neighborId = TypeId[nx, ny];
+ if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f))
+ {
+ SparkTime[nx, ny] = 4;
+ MarkProcessed(nx, ny);
+ }
+ });
+ }
+ }
+
+ if (isEffectEmitter || Burning[x, y] != 0)
+ {
+ TrySpawnProducedParticle(x, y, typeId, ref seed);
+ }
+
+ if (_idSmoke != 0 && isEffectEmitter && _smokeSpawnChance[typeId] > 0f && NextChance(ref seed, _smokeSpawnChance[typeId]))
+ {
+ TrySpawnAtOffset(x, y, 0, -1, _idSmoke);
+ }
+
+ if (_idEmber != 0 && isEffectEmitter && _emberSpawnChance[typeId] > 0f && NextChance(ref seed, _emberSpawnChance[typeId]))
+ {
+ TrySpawnAtOffset(x, y, 0, -1, _idEmber);
+ }
+ }
+
+ private bool TickBurning(int x, int y, ushort typeId, ref uint seed)
+ {
+ if (Burning[x, y] == 0)
+ {
+ return false;
+ }
+
+ Temperature[x, y] += 1.5f + (_heatEmission[typeId] * 0.02f);
+ BurnTime[x, y] -= MathF.Max(0.05f, _burnDecayPerStep[typeId] * _burnRate[typeId]);
+
+ if (BurnTime[x, y] > 0f)
+ {
+ return false;
+ }
+
+ ResetCell(x, y);
+ return true;
+ }
+
+ private bool TickSpecialBehavior(int x, int y, ushort typeId, ref uint seed)
+ {
+ switch ((ParticleBehaviorKind)_behaviorKind[typeId])
+ {
+ case ParticleBehaviorKind.Fire:
+ if (Temperature[x, y] < 120f && NextChance(ref seed, 0.1f))
+ {
+ ResetCell(x, y);
+ return true;
+ }
+ break;
+ case ParticleBehaviorKind.Ember:
+ if (Temperature[x, y] < 60f && NextChance(ref seed, 0.1f))
+ {
+ ResetCell(x, y);
+ return true;
+ }
+ break;
+ case ParticleBehaviorKind.BurningWood:
+ if (_idEmber != 0 && NextChance(ref seed, _emberSpawnChance[typeId] * 0.5f))
+ {
+ TrySpawnAtOffset(x, y, 0, -1, _idEmber);
+ }
+ break;
+ }
+
+ return false;
+ }
+
+ private void ApplyPhaseTransitions(int x, int y, ref ushort typeId)
+ {
+ if (_phaseTransitionHysteresis[typeId] > 0f && _cellAge[x, y] < _phaseTransitionHysteresis[typeId])
+ {
+ return;
+ }
+
+ if (typeId == _idWater)
+ {
+ if (_idSteam != 0 && Temperature[x, y] >= 100f)
+ {
+ ReplaceCell(x, y, _idSteam);
+ typeId = TypeId[x, y];
+ return;
+ }
+
+ if (_idIce != 0 && Temperature[x, y] <= 0f)
+ {
+ ReplaceCell(x, y, _idIce);
+ typeId = TypeId[x, y];
+ return;
+ }
+ }
+
+ if (typeId == _idSteam && Temperature[x, y] <= 85f)
+ {
+ if (TryCondenseSteam(x, y, ref typeId))
+ {
+ return;
+ }
+ }
+
+ if ((typeId == _idIce || typeId == _idSnow) && _idWater != 0)
+ {
+ var meltPoint = typeId == _idIce ? 7f : 9f;
+ if (Temperature[x, y] >= meltPoint)
+ {
+ ReplaceCell(x, y, _idWater);
+ typeId = TypeId[x, y];
+ return;
+ }
+ }
+
+ if (_evapTarget[typeId] != 0 && Temperature[x, y] >= _evapTemperature[typeId])
+ {
+ ReplaceCell(x, y, _evapTarget[typeId]);
+ typeId = TypeId[x, y];
+ return;
+ }
+
+ if (_meltTarget[typeId] != 0 && Temperature[x, y] >= _meltTemperature[typeId])
+ {
+ ReplaceCell(x, y, _meltTarget[typeId]);
+ typeId = TypeId[x, y];
+ return;
+ }
+
+ if (_solidifyTarget[typeId] != 0 && Temperature[x, y] <= _solidifyTemperature[typeId])
+ {
+ ReplaceCell(x, y, _solidifyTarget[typeId]);
+ typeId = TypeId[x, y];
+ return;
+ }
+
+ if (_freezeTarget[typeId] != 0 && Temperature[x, y] <= _freezeTemperature[typeId])
+ {
+ ReplaceCell(x, y, _freezeTarget[typeId]);
+ typeId = TypeId[x, y];
+ }
+ }
+
+ private bool ApplyLocalReactions(int x, int y, ref ushort typeId, ref uint seed)
+ {
+ var neighbors = new (int X, int Y)[]
+ {
+ (x - 1, y),
+ (x + 1, y),
+ (x, y - 1),
+ (x, y + 1),
+ };
+
+ foreach (var (nx, ny) in neighbors)
+ {
+ var tx = nx;
+ var ty = ny;
+ if (!TryNormalizeCoordinate(ref tx, ref ty))
+ {
+ continue;
+ }
+
+ var neighborId = TypeId[tx, ty];
+ if (neighborId == 0)
+ {
+ continue;
+ }
+
+ if (typeId == _idWater && neighborId == _idSand && _idWetSand != 0)
+ {
+ ReplaceCell(tx, ty, _idWetSand);
+ ResetCell(x, y);
+ return true;
+ }
+
+ if (typeId == _idWater && neighborId == _idDirt && _idMud != 0)
+ {
+ ReplaceCell(tx, ty, _idMud);
+ ResetCell(x, y);
+ return true;
+ }
+
+ if (typeId == _idLava && neighborId == _idWater && _idStone != 0 && _idSteam != 0)
+ {
+ ReplaceCell(x, y, _idStone);
+ ReplaceCell(tx, ty, _idSteam);
+ typeId = TypeId[x, y];
+ return true;
+ }
+
+ if (IsFireLike(typeId) && IsWaterLike(neighborId))
+ {
+ ResetCell(x, y);
+ if (_idSteam != 0 && NextChance(ref seed, 0.5f))
+ {
+ ReplaceCell(tx, ty, _idSteam);
+ }
+
+ return true;
+ }
+
+ if ((IsFireLike(typeId) || typeId == _idLava) && neighborId != _idFire && neighborId != _idLava && _flamability[neighborId] > 0f && _idFire != 0)
+ {
+ var spreadChance = typeId == _idLava
+ ? (_fireSpreadChance[typeId] > 0f ? _fireSpreadChance[typeId] : 0.1f)
+ : MathF.Max(0.05f, _fireSpreadChance[typeId]);
+ if (NextChance(ref seed, spreadChance))
+ {
+ ReplaceCell(tx, ty, _idFire);
+ }
+ }
+
+ if (typeId == _idAcid && neighborId != _idAcid && neighborId != _idFire && neighborId != _idSmoke && neighborId != _idWall && neighborId != _idGlass)
+ {
+ if (NextChance(ref seed, 0.05f))
+ {
+ ResetCell(tx, ty);
+ if (NextChance(ref seed, 0.2f))
+ {
+ ResetCell(x, y);
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private bool TickPressure(int x, int y, ushort typeId, ref uint seed)
+ {
+ if (_airPressure[x, y] == 0f &&
+ _pressure[typeId] <= 0f &&
+ _explosive[typeId] == 0 &&
+ _pressureDuration[x, y] <= 0f &&
+ _integrity[x, y] >= _durability[typeId])
+ {
+ return false;
+ }
+
+ var localPressure = MathF.Abs(_airPressure[x, y]) * _pressureSensitivity[typeId];
+ localPressure += _pressure[typeId] * 0.1f;
+
+ // Mechanical pressure should come from compression/impacts, not from heat alone.
+ // Keep a small thermal contribution only for explicitly explosive materials.
+ if (_explosive[typeId] != 0 && Temperature[x, y] > _settings.AmbientTemperature + 250f)
+ {
+ localPressure += (Temperature[x, y] - _settings.AmbientTemperature) * 0.0015f;
+ }
+
+ var threshold = _pressureThreshold[typeId];
+ if (_explosive[typeId] != 0 && threshold <= 0f)
+ {
+ threshold = 8f;
+ }
+ else if (threshold <= 0f)
+ {
+ threshold = 2.5f + (_hardness[typeId] * 4f) + (_pressureResistance[typeId] * 0.35f);
+ }
+
+ threshold += _pressureTolerance[typeId] + _pressureResistance[typeId];
+
+ if (threshold <= 0f)
+ {
+ return false;
+ }
+
+ if (localPressure >= threshold)
+ {
+ _pressureDuration[x, y] += 1f;
+ }
+ else
+ {
+ _pressureDuration[x, y] = MathF.Max(0f, _pressureDuration[x, y] - 1f);
+ var maxIntegrity = _durability[typeId];
+ if (_integrity[x, y] < maxIntegrity)
+ {
+ _integrity[x, y] = MathF.Min(maxIntegrity, _integrity[x, y] + MathF.Max(0.2f, _hardness[typeId] * 0.1f));
+ }
+
+ return false;
+ }
+
+ var overload = MathF.Max(0f, localPressure - threshold);
+ var damageScale = 1f / MathF.Max(0.35f, 0.4f + _hardness[typeId] + (_pressureResistance[typeId] * 0.1f));
+ _integrity[x, y] -= MathF.Max(0.15f, 0.45f + overload) * damageScale;
+
+ var requiredDuration = _pressureThresholdDuration[typeId] > 0
+ ? _pressureThresholdDuration[typeId]
+ : Math.Max(2, (int)MathF.Ceiling(2f + (_hardness[typeId] * 2f)));
+ if (_pressureDuration[x, y] < requiredDuration && _integrity[x, y] > 0f)
+ {
+ return false;
+ }
+
+ if (_explosive[typeId] != 0)
+ {
+ TriggerExplosion(x, y, typeId, ref seed);
+ ResetCell(x, y);
+ return true;
+ }
+
+ if (_flamability[typeId] > 0f && Temperature[x, y] >= Math.Max(60f, _burnTemperature[typeId] * 0.5f))
+ {
+ Burning[x, y] = 1;
+ BurnTime[x, y] = MathF.Max(1f, _burnDuration[typeId]);
+ return false;
+ }
+
+ if (_integrity[x, y] <= 0f || overload >= MathF.Max(1.5f, threshold * 0.45f))
+ {
+ return BreakCellFromPressure(x, y, typeId, ref seed);
+ }
+
+ return false;
+ }
+
+ private void ApplyHeatEmission(int x, int y, float amount)
+ {
+ Temperature[x, y] += amount * 0.08f;
+ ForEachNeighbor8(x, y, (nx, ny) =>
+ {
+ Temperature[nx, ny] += amount * 0.02f;
+ });
+ }
+
+ private void TransferEnergy(int x, int y, ushort typeId, ref uint seed, float chance)
+ {
+ var localSeed = seed;
+ ForEachNeighbor8(x, y, (nx, ny) =>
+ {
+ var neighborId = TypeId[nx, ny];
+ if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f) && SparkTime[nx, ny] == 0 && NextChance(ref localSeed, chance))
+ {
+ SparkTime[nx, ny] = 4;
+ MarkProcessed(nx, ny);
+ }
+ else if (neighborId == 0 && _idSpark != 0 && NextChance(ref localSeed, MathF.Min(0.75f, chance * 0.5f)))
+ {
+ SetCell(nx, ny, _idSpark);
+ SparkTime[nx, ny] = 4;
+ }
+ });
+ seed = localSeed;
+ }
+
+ private void TrySpawnProducedParticle(int x, int y, ushort typeId, ref uint seed)
+ {
+ var produceType = _producesTarget[typeId];
+ if (produceType == 0)
+ {
+ return;
+ }
+
+ var chance = _produceChance[typeId];
+ if (chance <= 0f)
+ {
+ chance = 0.08f;
+ }
+
+ if (!NextChance(ref seed, chance))
+ {
+ return;
+ }
+
+ SpawnNeighborParticle(x, y, produceType, seed);
+ }
+
+ private bool TrySpawnAtOffset(int x, int y, int offsetX, int offsetY, ushort typeId)
+ {
+ var tx = x + offsetX;
+ var ty = y + offsetY;
+ if (!TryNormalizeCoordinate(ref tx, ref ty))
+ {
+ return false;
+ }
+
+ if ((_settings.OuterWall && IsBoundary(tx, ty)) || TypeId[tx, ty] != 0)
+ {
+ return false;
+ }
+
+ SetCell(tx, ty, typeId);
+ return true;
+ }
+
+ private bool IsFireLike(ushort typeId)
+ {
+ var behavior = (ParticleBehaviorKind)_behaviorKind[typeId];
+ return typeId == _idFire || behavior == ParticleBehaviorKind.Fire || behavior == ParticleBehaviorKind.Ember || behavior == ParticleBehaviorKind.Plasma;
+ }
+
+ private bool IsWaterLike(ushort typeId)
+ {
+ return typeId == _idWater || typeId == _idWetSand || typeId == _idMud;
+ }
+
+ private bool TryCondenseSteam(int x, int y, ref ushort typeId)
+ {
+ if (_idWater == 0)
+ {
+ return false;
+ }
+
+ var condensedTemperature = MathF.Max(_initialTemperature[_idWater], MathF.Min(Temperature[x, y], 95f));
+ ReplaceCell(x, y, _idWater);
+ Temperature[x, y] = condensedTemperature;
+ typeId = TypeId[x, y];
+ return true;
+ }
+
+ private void ApplyTemperatureDiffusion(int x, int y, ushort typeId)
+ {
+ var conductivity = MathF.Max(_conductivity[typeId], 0.01f);
+ var localTemperature = Temperature[x, y];
+ var airDelta = localTemperature - _settings.AmbientTemperature;
+ if (_settings.FastSim && MathF.Abs(airDelta) <= 0.5f)
+ {
+ var rightX = x + 1;
+ var rightY = y;
+ var downX = x;
+ var downY = y + 1;
+ var rightStable =
+ !TryNormalizeCoordinate(ref rightX, ref rightY) ||
+ TypeId[rightX, rightY] == 0 ||
+ MathF.Abs(localTemperature - Temperature[rightX, rightY]) <= 0.5f;
+ var downStable =
+ !TryNormalizeCoordinate(ref downX, ref downY) ||
+ TypeId[downX, downY] == 0 ||
+ MathF.Abs(localTemperature - Temperature[downX, downY]) <= 0.5f;
+
+ if (rightStable && downStable)
+ {
+ return;
+ }
+ }
+
+ if (MathF.Abs(airDelta) > 0.5f)
+ {
+ var transfer = Clamp(
+ airDelta * ((conductivity + _settings.AirConductivity) * 0.5f) * 0.1f * _ambientCoolingMultiplier[typeId] / _heatCapacity[typeId],
+ airDelta * -0.5f,
+ airDelta * 0.5f);
+ Temperature[x, y] -= transfer;
+ }
+
+ DiffuseBetween(x, y, x + 1, y, conductivity, _neighborHeatTransferMultiplier[typeId]);
+ DiffuseBetween(x, y, x, y + 1, conductivity, _neighborHeatTransferMultiplier[typeId]);
+ }
+
+ private void DiffuseBetween(int x, int y, int nx, int ny, float conductivity, float multiplier)
+ {
+ if (!TryNormalizeCoordinate(ref nx, ref ny))
+ {
+ return;
+ }
+
+ var neighborId = TypeId[nx, ny];
+ if (neighborId == 0)
+ {
+ return;
+ }
+
+ var neighborConductivity = MathF.Max(_conductivity[neighborId], 0.1f);
+ var delta = Temperature[x, y] - Temperature[nx, ny];
+ if (MathF.Abs(delta) <= 0.5f)
+ {
+ return;
+ }
+
+ var transfer = Clamp(
+ delta * ((conductivity + neighborConductivity) * 0.5f) * 0.1f * multiplier / MathF.Max(0.1f, _heatCapacity[TypeId[x, y]]),
+ delta * -0.5f,
+ delta * 0.5f);
+ Temperature[x, y] -= transfer;
+ Temperature[nx, ny] += transfer;
+ }
+}
diff --git a/Sand.Core/SandSimulation.cs b/Sand.Core/SandSimulation.cs
new file mode 100644
index 0000000..e8d97cb
--- /dev/null
+++ b/Sand.Core/SandSimulation.cs
@@ -0,0 +1,347 @@
+namespace Sand.Core;
+
+public sealed partial class SandSimulation
+{
+ private readonly ParticleLibrary _library;
+ private readonly SimulationSettings _settings;
+ private readonly byte[] _rgbBuffer;
+ private readonly byte[] _rgbaBuffer;
+ private readonly byte[] _kind;
+ private readonly byte[] _isStatic;
+ private readonly float[] _mass;
+ private readonly float[] _hardness;
+ private readonly byte[] _isMolten;
+ private readonly float[] _velocity;
+ private readonly float[] _conductivity;
+ private readonly byte[] _conductive;
+ private readonly float[] _flamability;
+ private readonly float[] _durability;
+ private readonly float[] _heatCapacity;
+ private readonly float[] _friction;
+ private readonly float[] _viscosity;
+ private readonly float[] _pressure;
+ private readonly byte[] _burningInit;
+ private readonly float[] _burnDuration;
+ private readonly float[] _burnTemperature;
+ private readonly float[] _burnRate;
+ private readonly float[] _initialTemperature;
+ private readonly ushort[] _evapTarget;
+ private readonly float[] _evapTemperature;
+ private readonly ushort[] _meltTarget;
+ private readonly float[] _meltTemperature;
+ private readonly ushort[] _solidifyTarget;
+ private readonly float[] _solidifyTemperature;
+ private readonly ushort[] _freezeTarget;
+ private readonly float[] _freezeTemperature;
+ private readonly float[] _defaultLifetime;
+ private readonly ushort[] _producesTarget;
+ private readonly ushort[] _producesOnDeathTarget;
+ private readonly float[] _heatEmission;
+ private readonly float[] _energyTransfer;
+ private readonly float[] _radius;
+ private readonly float[] _forceFalloff;
+ private readonly float[] _turbulence;
+ private readonly float[] _pressureResistance;
+ private readonly float[] _pressureTolerance;
+ private readonly float[] _pressureThreshold;
+ private readonly short[] _pressureThresholdDuration;
+ private readonly ushort[] _brokenTarget;
+ private readonly byte[] _explosive;
+ private readonly short[] _explosionRadius;
+ private readonly float[] _explosionForce;
+ private readonly Rgb24?[] _explosionColor;
+ private readonly byte[] _behaviorKind;
+ private readonly float[] _lifetimeMultiplier;
+ private readonly float[] _burnDecayPerStep;
+ private readonly float[] _ambientCoolingMultiplier;
+ private readonly float[] _neighborHeatTransferMultiplier;
+ private readonly float[] _upwardBias;
+ private readonly float[] _sideDriftBias;
+ private readonly float[] _fireSpreadChance;
+ private readonly float[] _smokeSpawnChance;
+ private readonly float[] _emberSpawnChance;
+ private readonly float[] _produceChance;
+ private readonly float[] _heatEmissionMultiplier;
+ private readonly float[] _energyTransferMultiplier;
+ private readonly float[] _pressureSensitivity;
+ private readonly float[] _pressureDecayMultiplier;
+ private readonly float[] _forceResponseMultiplier;
+ private readonly float[] _lateralFlowMultiplier;
+ private readonly float[] _diagonalFlowMultiplier;
+ private readonly float[] _phaseTransitionHysteresis;
+ private readonly float[] _minLifetimeTicks;
+ private readonly float[] _maxLifetimeTicks;
+ private readonly Rgb24[] _colorLut;
+ private readonly float[,] _windFieldX;
+ private readonly float[,] _windFieldY;
+ private readonly float[,] _forceFieldX;
+ private readonly float[,] _forceFieldY;
+ private readonly float[,] _airPressure;
+ private readonly float[,] _pressureDuration;
+ private readonly float[,] _cellAge;
+ private readonly float[,] _integrity;
+ private readonly ToolProfile _windTool;
+ private readonly ToolProfile _gravityTool;
+ private readonly ToolProfile _repulsorTool;
+ private readonly ToolProfile _airTool;
+ private readonly ushort _idFire;
+ private readonly ushort _idLava;
+ private readonly ushort _idWater;
+ private readonly ushort _idSand;
+ private readonly ushort _idWetSand;
+ private readonly ushort _idDirt;
+ private readonly ushort _idMud;
+ private readonly ushort _idStone;
+ private readonly ushort _idSteam;
+ private readonly ushort _idAcid;
+ private readonly ushort _idSpark;
+ private readonly ushort _idEnergy;
+ private readonly ushort _idWall;
+ private readonly ushort _idSmoke;
+ private readonly ushort _idGlass;
+ private readonly ushort _idBurningWood;
+ private readonly ushort _idEmber;
+ private readonly ushort _idPlasma;
+ private readonly ushort _idSnow;
+ private readonly ushort _idIce;
+ private readonly int[,] _processedFrame;
+ private readonly int[,] _explosionFrame;
+ private int _activeStepToken;
+ private bool _boundsDirty;
+ private int _staleOccupiedBoundsFrames;
+ private bool _hasOccupiedBounds;
+ private int _minOccupiedX;
+ private int _minOccupiedY;
+ private int _maxOccupiedX;
+ private int _maxOccupiedY;
+ private bool _fieldBoundsDirty;
+ private bool _hasActiveFieldBounds;
+ private int _minActiveFieldX;
+ private int _minActiveFieldY;
+ private int _maxActiveFieldX;
+ private int _maxActiveFieldY;
+ private bool _hasDirtyVisualBounds;
+ private bool _fullVisualDirty = true;
+ private int _minDirtyVisualX;
+ private int _minDirtyVisualY;
+ private int _maxDirtyVisualX;
+ private int _maxDirtyVisualY;
+ private bool _deferVisualDirtyTracking;
+ private uint _visualSettingsStamp = uint.MaxValue;
+
+ public SandSimulation(int width, int height, int particleSize, IParticleLibrary library, SimulationSettings? settings = null)
+ {
+ Width = width;
+ Height = height;
+ ParticleSize = particleSize;
+ _library = library as ParticleLibrary ?? throw new ArgumentException("Expected ParticleLibrary implementation.", nameof(library));
+ _settings = settings ?? new SimulationSettings();
+
+ TypeId = new ushort[width, height];
+ Temperature = new float[width, height];
+ BurnTime = new float[width, height];
+ Burning = new byte[width, height];
+ SparkTime = new short[width, height];
+ Lifetime = new float[width, height];
+ _pressureDuration = new float[width, height];
+ _cellAge = new float[width, height];
+ _windFieldX = new float[width, height];
+ _windFieldY = new float[width, height];
+ _forceFieldX = new float[width, height];
+ _forceFieldY = new float[width, height];
+ _airPressure = new float[width, height];
+ _processedFrame = new int[width, height];
+ _explosionFrame = new int[width, height];
+ _integrity = new float[width, height];
+ _rgbBuffer = new byte[width * height * 3];
+ _rgbaBuffer = new byte[width * height * 4];
+ FrameStats = new SimulationFrameStats();
+ var count = _library.Definitions.Count + 1;
+ _kind = new byte[count];
+ _isStatic = new byte[count];
+ _mass = new float[count];
+ _hardness = new float[count];
+ _isMolten = new byte[count];
+ _velocity = new float[count];
+ _conductivity = new float[count];
+ _conductive = new byte[count];
+ _flamability = new float[count];
+ _durability = new float[count];
+ _heatCapacity = new float[count];
+ _friction = new float[count];
+ _viscosity = new float[count];
+ _pressure = new float[count];
+ _burningInit = new byte[count];
+ _burnDuration = new float[count];
+ _burnTemperature = new float[count];
+ _burnRate = new float[count];
+ _initialTemperature = new float[count];
+ _evapTarget = new ushort[count];
+ _evapTemperature = Enumerable.Repeat(9999f, count).ToArray();
+ _meltTarget = new ushort[count];
+ _meltTemperature = Enumerable.Repeat(9999f, count).ToArray();
+ _solidifyTarget = new ushort[count];
+ _solidifyTemperature = Enumerable.Repeat(-9999f, count).ToArray();
+ _freezeTarget = new ushort[count];
+ _freezeTemperature = Enumerable.Repeat(-9999f, count).ToArray();
+ _defaultLifetime = new float[count];
+ _producesTarget = new ushort[count];
+ _producesOnDeathTarget = new ushort[count];
+ _heatEmission = new float[count];
+ _energyTransfer = new float[count];
+ _radius = new float[count];
+ _forceFalloff = new float[count];
+ _turbulence = new float[count];
+ _pressureResistance = new float[count];
+ _pressureTolerance = new float[count];
+ _pressureThreshold = new float[count];
+ _pressureThresholdDuration = new short[count];
+ _brokenTarget = new ushort[count];
+ _explosive = new byte[count];
+ _explosionRadius = new short[count];
+ _explosionForce = new float[count];
+ _explosionColor = new Rgb24?[count];
+ _behaviorKind = new byte[count];
+ _lifetimeMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _burnDecayPerStep = Enumerable.Repeat(1f, count).ToArray();
+ _ambientCoolingMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _neighborHeatTransferMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _upwardBias = new float[count];
+ _sideDriftBias = new float[count];
+ _fireSpreadChance = new float[count];
+ _smokeSpawnChance = new float[count];
+ _emberSpawnChance = new float[count];
+ _produceChance = new float[count];
+ _heatEmissionMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _energyTransferMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _pressureSensitivity = Enumerable.Repeat(1f, count).ToArray();
+ _pressureDecayMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _forceResponseMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _lateralFlowMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _diagonalFlowMultiplier = Enumerable.Repeat(1f, count).ToArray();
+ _phaseTransitionHysteresis = new float[count];
+ _minLifetimeTicks = new float[count];
+ _maxLifetimeTicks = new float[count];
+ _colorLut = new Rgb24[count];
+
+ for (ushort typeId = 1; typeId < count; typeId++)
+ {
+ var definition = _library.GetDefinition(typeId);
+ var runtime = ParticleRuntimeProfileBuilder.Build(definition);
+ _kind[typeId] = (byte)definition.Kind;
+ _isStatic[typeId] = definition.IsStatic ? (byte)1 : (byte)0;
+ _mass[typeId] = definition.Mass;
+ _hardness[typeId] = MathF.Max(0f, definition.Hardness);
+ _isMolten[typeId] = definition.Id == "lava" || definition.Id.StartsWith("molten_", StringComparison.Ordinal) ? (byte)1 : (byte)0;
+ _velocity[typeId] = definition.Velocity;
+ _conductivity[typeId] = definition.Conductivity;
+ _conductive[typeId] = definition.Conductive ? (byte)1 : (byte)0;
+ _flamability[typeId] = definition.Flamability;
+ _durability[typeId] = MathF.Max(1f, definition.Durability);
+ _heatCapacity[typeId] = MathF.Max(0.1f, definition.HeatCapacity);
+ _friction[typeId] = definition.Friction;
+ _viscosity[typeId] = definition.Viscosity;
+ _pressure[typeId] = definition.Pressure;
+ _burningInit[typeId] = definition.Burning ? (byte)1 : (byte)0;
+ _burnDuration[typeId] = definition.BurnDuration;
+ _burnTemperature[typeId] = definition.BurnTemperature;
+ _burnRate[typeId] = MathF.Max(0.05f, definition.BurnRate);
+ _initialTemperature[typeId] = definition.Temperature;
+ _evapTarget[typeId] = ResolveOptionalTypeId(definition.Evaporate);
+ _meltTarget[typeId] = ResolveOptionalTypeId(definition.Melt);
+ _solidifyTarget[typeId] = ResolveOptionalTypeId(definition.Solidify);
+ _freezeTarget[typeId] = ResolveOptionalTypeId(definition.Freeze);
+ _producesTarget[typeId] = ResolveOptionalTypeId(definition.Produces);
+ _producesOnDeathTarget[typeId] = ResolveOptionalTypeId(definition.ProducesOnDeath);
+ _heatEmission[typeId] = definition.HeatEmission;
+ _energyTransfer[typeId] = definition.EnergyTransfer;
+ _radius[typeId] = definition.Radius;
+ _forceFalloff[typeId] = definition.ForceFalloff <= 0f ? 1f : definition.ForceFalloff;
+ _turbulence[typeId] = definition.Turbulence;
+ _pressureResistance[typeId] = definition.PressureResistance;
+ _pressureTolerance[typeId] = definition.PressureTolerance;
+ _pressureThreshold[typeId] = definition.PressureThreshold;
+ _pressureThresholdDuration[typeId] = checked((short)Math.Clamp(definition.PressureThresholdDuration, 0, short.MaxValue));
+ _brokenTarget[typeId] = ResolveOptionalTypeId(definition.Broken);
+ _explosive[typeId] = definition.Explosive ? (byte)1 : (byte)0;
+ _explosionRadius[typeId] = checked((short)Math.Clamp(definition.ExplosionRadius, 0, short.MaxValue));
+ _explosionForce[typeId] = definition.ExplosionForce;
+ _explosionColor[typeId] = definition.ExplosionColor;
+ if (definition.EvaporateTemperature is not null) _evapTemperature[typeId] = definition.EvaporateTemperature.Value;
+ if (definition.MeltTemperature is not null) _meltTemperature[typeId] = definition.MeltTemperature.Value;
+ if (definition.SolidifyTemperature is not null) _solidifyTemperature[typeId] = definition.SolidifyTemperature.Value;
+ if (definition.FreezeTemperature is not null) _freezeTemperature[typeId] = definition.FreezeTemperature.Value;
+ _behaviorKind[typeId] = (byte)runtime.Balance.BehaviorKind;
+ _lifetimeMultiplier[typeId] = runtime.Balance.LifetimeMultiplier;
+ _burnDecayPerStep[typeId] = runtime.Balance.BurnDecayPerStep;
+ _ambientCoolingMultiplier[typeId] = runtime.Balance.AmbientCoolingMultiplier;
+ _neighborHeatTransferMultiplier[typeId] = runtime.Balance.NeighborHeatTransferMultiplier;
+ _upwardBias[typeId] = runtime.Balance.UpwardBias;
+ _sideDriftBias[typeId] = runtime.Balance.SideDriftBias;
+ _fireSpreadChance[typeId] = runtime.Balance.FireSpreadChance;
+ _smokeSpawnChance[typeId] = runtime.Balance.SmokeSpawnChance;
+ _emberSpawnChance[typeId] = runtime.Balance.EmberSpawnChance;
+ _produceChance[typeId] = runtime.Balance.ProduceChance;
+ _heatEmissionMultiplier[typeId] = runtime.Balance.HeatEmissionMultiplier;
+ _energyTransferMultiplier[typeId] = runtime.Balance.EnergyTransferMultiplier;
+ _pressureSensitivity[typeId] = runtime.Balance.PressureSensitivity;
+ _pressureDecayMultiplier[typeId] = runtime.Balance.PressureDecayMultiplier;
+ _forceResponseMultiplier[typeId] = runtime.Balance.ForceResponseMultiplier;
+ _lateralFlowMultiplier[typeId] = runtime.Balance.LateralFlowMultiplier;
+ _diagonalFlowMultiplier[typeId] = runtime.Balance.DiagonalFlowMultiplier;
+ _phaseTransitionHysteresis[typeId] = runtime.Balance.PhaseTransitionHysteresis;
+ _minLifetimeTicks[typeId] = runtime.Balance.MinLifetimeTicks;
+ _maxLifetimeTicks[typeId] = runtime.Balance.MaxLifetimeTicks;
+ _defaultLifetime[typeId] = ResolveDefaultLifetime(definition, runtime.Balance);
+ _colorLut[typeId] = definition.Color;
+ }
+
+ _idFire = ResolveOptionalTypeId("fire");
+ _idLava = ResolveOptionalTypeId("lava");
+ _idWater = ResolveOptionalTypeId("water");
+ _idSand = ResolveOptionalTypeId("sand");
+ _idWetSand = ResolveOptionalTypeId("wsand");
+ _idDirt = ResolveOptionalTypeId("dirt");
+ _idMud = ResolveOptionalTypeId("mud");
+ _idStone = ResolveOptionalTypeId("stone");
+ _idSteam = ResolveOptionalTypeId("steam");
+ _idAcid = ResolveOptionalTypeId("acid");
+ _idSpark = ResolveOptionalTypeId("spark");
+ _idEnergy = ResolveOptionalTypeId("energy");
+ _idWall = ResolveOptionalTypeId("wall");
+ _idSmoke = ResolveOptionalTypeId("smoke");
+ _idGlass = ResolveOptionalTypeId("glass");
+ _idBurningWood = ResolveOptionalTypeId("burning_wood");
+ _idEmber = ResolveOptionalTypeId("ember");
+ _idPlasma = ResolveOptionalTypeId("plasma");
+ _idSnow = ResolveOptionalTypeId("snow");
+ _idIce = ResolveOptionalTypeId("ice");
+ if (_idBurningWood != 0 && _producesOnDeathTarget[_idBurningWood] == 0 && _idEmber != 0)
+ {
+ _producesOnDeathTarget[_idBurningWood] = _idEmber;
+ }
+ _windTool = BuildToolProfile("wind", defaultStrength: 4f, fallbackRadiusCells: 4);
+ _gravityTool = BuildToolProfile("gravity_well", defaultStrength: 3.5f, fallbackRadiusCells: 6);
+ _repulsorTool = BuildToolProfile("repulsor", defaultStrength: 3f, fallbackRadiusCells: 5);
+ _airTool = new ToolProfile("air", 4, 3f, 1f, 0.1f, ["all"]);
+
+ Clear();
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public int ParticleSize { get; }
+ public int Frame { get; private set; }
+ public int ParticleCount { get; private set; }
+ public ushort[,] TypeId { get; }
+ public float[,] Temperature { get; }
+ public float[,] BurnTime { get; }
+ public byte[,] Burning { get; }
+ public short[,] SparkTime { get; }
+ public float[,] Lifetime { get; }
+ public SimulationSettings Settings => _settings;
+ public SimulationFrameStats FrameStats { get; }
+ public ISimulationAccelerator? Accelerator { get; set; }
+ public float GetPressureDurationAtCell(int x, int y) => _pressureDuration[x, y];
+}
+
diff --git a/Sand.Core/SimulationFrameStats.cs b/Sand.Core/SimulationFrameStats.cs
new file mode 100644
index 0000000..2932913
--- /dev/null
+++ b/Sand.Core/SimulationFrameStats.cs
@@ -0,0 +1,39 @@
+namespace Sand.Core;
+
+public sealed class SimulationFrameStats
+{
+ public int Frame { get; internal set; }
+ public int ProcessedCells { get; internal set; }
+ public int ParticleCount { get; internal set; }
+ public int MinActiveX { get; internal set; }
+ public int MinActiveY { get; internal set; }
+ public int MaxActiveX { get; internal set; }
+ public int MaxActiveY { get; internal set; }
+ public int LoadedChunkCount { get; internal set; }
+ public int ActiveChunkCount { get; internal set; }
+ public int DirtyChunkCount { get; internal set; }
+ public int SteppedChunkCount { get; internal set; }
+ public int SleepingChunkCount { get; internal set; }
+ public int FieldPageCount { get; internal set; }
+ public int MoveAttemptCount { get; internal set; }
+ public int VerticalMoveAttemptCount { get; internal set; }
+ public int DiagonalMoveAttemptCount { get; internal set; }
+ public int LateralMoveAttemptCount { get; internal set; }
+ public int SuccessfulMoveCount { get; internal set; }
+ public int SwapAttemptCount { get; internal set; }
+ public int StalledMovableCount { get; internal set; }
+ public int MovementOnlyFastPathCount { get; internal set; }
+ public int FullRuntimeStepCount { get; internal set; }
+ public int FullRuntimeSolidCount { get; internal set; }
+ public int FullRuntimeLiquidCount { get; internal set; }
+ public int FullRuntimeGasCount { get; internal set; }
+ public int MovedParticleCount { get; internal set; }
+ public int SwappedParticleCount { get; internal set; }
+ public int VisualDirtyPageCount { get; internal set; }
+ public long FrameBuildBytesTouched { get; internal set; }
+ public long ActivationTimeMicroseconds { get; internal set; }
+ public long MovementTimeMicroseconds { get; internal set; }
+ public long RuntimeTimeMicroseconds { get; internal set; }
+ public long FieldDecayTimeMicroseconds { get; internal set; }
+ public long RenderTimeMicroseconds { get; internal set; }
+}
diff --git a/Sand.Core/SimulationSettings.cs b/Sand.Core/SimulationSettings.cs
new file mode 100644
index 0000000..112fcf1
--- /dev/null
+++ b/Sand.Core/SimulationSettings.cs
@@ -0,0 +1,25 @@
+namespace Sand.Core;
+
+public sealed class SimulationSettings
+{
+ public SimulationStorageMode StorageMode { get; set; } = SimulationStorageMode.Dense;
+ public bool PauseSim { get; set; }
+ public bool EnableCursor { get; set; } = true;
+ public bool EnableGlow { get; set; }
+ public bool EnableGasEffect { get; set; } = true;
+ public bool EnableDebug { get; set; } = true;
+ public bool EnableFps { get; set; } = true;
+ public bool EnableWindVisuals { get; set; }
+ public bool EnablePressureVisuals { get; set; }
+ public bool EnableTempVisuals { get; set; }
+ public bool OuterWall { get; set; }
+ public bool EnableAcceleration { get; set; }
+ public bool FastSim { get; set; } = true;
+ public bool WrapParticles { get; set; }
+ public float TimeScale { get; set; } = 1f;
+ public float SimulationStepsPerSecond { get; set; } = 60f;
+ public float AmbientTemperature { get; set; } = 22f;
+ public float AirConductivity { get; set; } = 0.05f;
+ public float WindX { get; set; }
+ public float WindY { get; set; }
+}
diff --git a/Sand.Core/SimulationStorageMode.cs b/Sand.Core/SimulationStorageMode.cs
new file mode 100644
index 0000000..5d597c5
--- /dev/null
+++ b/Sand.Core/SimulationStorageMode.cs
@@ -0,0 +1,7 @@
+namespace Sand.Core;
+
+public enum SimulationStorageMode
+{
+ Dense = 0,
+ ChunkPrototype = 1,
+}
diff --git a/Sand.Core/ToolProfile.cs b/Sand.Core/ToolProfile.cs
new file mode 100644
index 0000000..e9b0bd0
--- /dev/null
+++ b/Sand.Core/ToolProfile.cs
@@ -0,0 +1,9 @@
+namespace Sand.Core;
+
+internal readonly record struct ToolProfile(
+ string Id,
+ int RadiusCells,
+ float Strength,
+ float Falloff,
+ float Turbulence,
+ string[] Affects);
diff --git a/Sand.Experimental.slnx b/Sand.Experimental.slnx
new file mode 100644
index 0000000..1fdbaeb
--- /dev/null
+++ b/Sand.Experimental.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/Sand.Tests/ParticleLibraryLoaderTests.cs b/Sand.Tests/ParticleLibraryLoaderTests.cs
new file mode 100644
index 0000000..51e4eaa
--- /dev/null
+++ b/Sand.Tests/ParticleLibraryLoaderTests.cs
@@ -0,0 +1,31 @@
+using FluentAssertions;
+using Sand.Core;
+
+namespace Sand.Tests;
+
+public sealed class ParticleLibraryLoaderTests
+{
+ [Fact]
+ public void LoadFromDirectory_LoadsCoreDefinitions()
+ {
+ var library = ParticleLibraryLoader.LoadFromDirectory(GetPartRoot());
+
+ library.Definitions.Should().NotBeEmpty();
+ library.GetTypeId("sand").Should().BeGreaterThan((ushort)0);
+ library.GetDefinition(library.GetTypeId("water")).Name.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void LoadFromDirectory_NormalizesIdsAndColors()
+ {
+ var library = ParticleLibraryLoader.LoadFromDirectory(GetPartRoot());
+ var stone = library.GetDefinition(library.GetTypeId("stone"));
+
+ stone.Id.Should().Be("stone");
+ stone.Color.R.Should().Be(128);
+ stone.Color.G.Should().Be(128);
+ stone.Color.B.Should().Be(128);
+ }
+
+ private static string GetPartRoot() => Path.Combine(AppContext.BaseDirectory, "Content", "part");
+}
diff --git a/Sand.Tests/Sand.Tests.csproj b/Sand.Tests/Sand.Tests.csproj
new file mode 100644
index 0000000..1aca005
--- /dev/null
+++ b/Sand.Tests/Sand.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sand.Tests/SandSimulationTests.cs b/Sand.Tests/SandSimulationTests.cs
new file mode 100644
index 0000000..f17312f
--- /dev/null
+++ b/Sand.Tests/SandSimulationTests.cs
@@ -0,0 +1,920 @@
+using FluentAssertions;
+using Sand.Core;
+
+namespace Sand.Tests;
+
+public sealed class SandSimulationTests
+{
+ private readonly ParticleLibrary _library = ParticleLibraryLoader.LoadFromDirectory(Path.Combine(AppContext.BaseDirectory, "Content", "part"));
+
+ [Fact]
+ public void SandFallsIntoEmptySpace()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(2, 2, "sand");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(2, 3).Should().Be(_library.GetTypeId("sand"));
+ }
+
+ [Fact]
+ public void WaterAndSandCreateWetSand()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(3, 3, "sand");
+ simulation.CreateParticleAtPixel(2, 3, "water");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(3, 3).Should().Be(_library.GetTypeId("wsand"));
+ }
+
+ [Fact]
+ public void LoaderParsesExtendedMetadataFields()
+ {
+ var burningWood = _library.GetDefinition(_library.GetTypeId("burning_wood"));
+ var energy = _library.GetDefinition(_library.GetTypeId("energy"));
+ var wind = _library.GetDefinition(_library.GetTypeId("wind"));
+ var stone = _library.GetDefinition(_library.GetTypeId("stone"));
+ var ultratanium = _library.GetDefinition(_library.GetTypeId("ultratanium"));
+
+ burningWood.Produces.Should().Be("smoke");
+ burningWood.HeatEmission.Should().Be(50f);
+ energy.EnergyTransfer.Should().BeGreaterThan(0f);
+ wind.Radius.Should().BeGreaterThan(0f);
+ wind.Affects.Should().Contain("steam");
+ stone.Hardness.Should().Be(0.7f);
+ stone.Durability.Should().Be(100f);
+ stone.Broken.Should().Be("brkstone");
+ ultratanium.Explosive.Should().BeTrue();
+ ultratanium.ExplosionRadius.Should().Be(15);
+ }
+
+ [Fact]
+ public void LavaAndWaterCreateStoneAndSteam()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(2, 2, "lava");
+ simulation.CreateParticleAtPixel(3, 2, "water");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(2, 2).Should().Be(_library.GetTypeId("stone"));
+ simulation.GetTypeIdAtCell(3, 2).Should().Be(_library.GetTypeId("steam"));
+ }
+
+ [Fact]
+ public void BuildRgbFrameReflectsParticleColor()
+ {
+ var simulation = CreateSimulation();
+ var sand = _library.GetDefinition(_library.GetTypeId("sand"));
+ simulation.CreateParticleAtPixel(0, 0, "sand");
+
+ var frame = simulation.BuildRgbFrame().ToArray();
+
+ frame[0].Should().Be(sand.Color.R);
+ frame[1].Should().Be(sand.Color.G);
+ frame[2].Should().Be(sand.Color.B);
+ }
+
+ [Fact]
+ public void SparkSpreadsToDiagonalConductors()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 4, "iron");
+ simulation.CreateParticleAtPixel(5, 5, "iron");
+ simulation.CreateParticleAtPixel(4, 4, "spark");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.SparkTime[5, 5].Should().BeGreaterThan((short)0);
+ }
+
+ [Fact]
+ public void OuterWallPreventsPaintingBoundaryCells()
+ {
+ var simulation = new SandSimulation(10, 10, 1, _library, new SimulationSettings { PauseSim = true, OuterWall = true });
+ simulation.RefreshSettingsState();
+
+ simulation.CreateParticleAtPixel(0, 0, "sand");
+
+ simulation.GetTypeIdAtCell(0, 0).Should().Be(_library.GetTypeId("wall"));
+ }
+
+ [Fact]
+ public void TogglingOuterWallOffRemovesBoundaryWallsFromParticleCount()
+ {
+ var settings = new SimulationSettings { PauseSim = true, OuterWall = true };
+ var simulation = new SandSimulation(10, 10, 1, _library, settings);
+ simulation.RefreshSettingsState();
+ simulation.ParticleCount.Should().BeGreaterThan(0);
+
+ settings.OuterWall = false;
+ simulation.RefreshSettingsState();
+
+ simulation.ParticleCount.Should().Be(0);
+ simulation.GetTypeIdAtCell(0, 0).Should().Be(0);
+ simulation.GetTypeIdAtCell(9, 9).Should().Be(0);
+ }
+
+ [Fact]
+ public void WrapParticlesMovesAcrossEdge()
+ {
+ var simulation = new SandSimulation(5, 5, 1, _library, new SimulationSettings { PauseSim = false, WrapParticles = true });
+ simulation.CreateParticleAtPixel(4, 0, "steam");
+
+ simulation.Step(1f / 60f);
+
+ simulation.ParticleCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void ParticleCountRemainsStableAcrossMovementSteps()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(6, 2, "sand");
+
+ for (var i = 0; i < 6; i++)
+ {
+ simulation.Step(1f / 60f);
+ simulation.ParticleCount.Should().Be(1);
+ }
+ }
+
+ [Fact]
+ public void SymmetricSandPileDoesNotDevelopStrongRightBias()
+ {
+ var simulation = new SandSimulation(60, 40, 1, _library, new SimulationSettings { PauseSim = false });
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ simulation.CreateParticleAtPixel(x, simulation.Height - 1, "wall");
+ }
+
+ for (var x = 24; x <= 36; x++)
+ {
+ for (var y = 4; y <= 16; y++)
+ {
+ simulation.CreateParticleAtPixel(x, y, "sand");
+ }
+ }
+
+ var startCenter = FindAverageX(simulation, "sand");
+ for (var i = 0; i < 120; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ var finalCenter = FindAverageX(simulation, "sand");
+ Math.Abs(finalCenter - startCenter).Should().BeLessThanOrEqualTo(1.5);
+ }
+
+ [Fact]
+ public void NeutralFireCanMoveLeftWhenOnlyLeftEscapePathIsAvailable()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(5, 5, "fire");
+ simulation.CreateParticleAtPixel(5, 4, "wall");
+ simulation.CreateParticleAtPixel(6, 4, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ var fireId = _library.GetTypeId("fire");
+ (simulation.GetTypeIdAtCell(4, 4) == fireId || simulation.GetTypeIdAtCell(4, 5) == fireId).Should().BeTrue();
+ }
+
+ [Fact]
+ public void LazyOccupiedBoundsShrinkEventuallyAfterEdgeRemoval()
+ {
+ var simulation = new SandSimulation(30, 30, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(2, 2, "wall");
+ simulation.CreateParticleAtPixel(20, 20, "wall");
+ simulation.Step(1f / 60f);
+
+ simulation.ClearParticleCircle(2, 2, 0);
+
+ for (var i = 0; i < 10; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.FrameStats.ParticleCount.Should().Be(1);
+ simulation.FrameStats.MinActiveX.Should().BeLessThanOrEqualTo(20);
+ simulation.FrameStats.MaxActiveX.Should().BeGreaterThanOrEqualTo(20);
+ simulation.FrameStats.MaxActiveX.Should().BeLessThanOrEqualTo(21);
+ simulation.FrameStats.MinActiveY.Should().BeLessThanOrEqualTo(20);
+ simulation.FrameStats.MaxActiveY.Should().BeGreaterThanOrEqualTo(20);
+ simulation.FrameStats.MaxActiveY.Should().BeLessThanOrEqualTo(21);
+ }
+
+ [Fact]
+ public void EmptySimulationClearsActiveBoundsStats()
+ {
+ var simulation = new SandSimulation(20, 20, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(10, 10, "sand");
+
+ simulation.Step(1f / 60f);
+ simulation.Clear();
+ simulation.Settings.PauseSim = false;
+ simulation.Step(1f / 60f);
+
+ simulation.FrameStats.ProcessedCells.Should().Be(0);
+ simulation.FrameStats.ParticleCount.Should().Be(0);
+ simulation.FrameStats.MinActiveX.Should().Be(0);
+ simulation.FrameStats.MinActiveY.Should().Be(0);
+ simulation.FrameStats.MaxActiveX.Should().Be(0);
+ simulation.FrameStats.MaxActiveY.Should().Be(0);
+ }
+
+ [Fact]
+ public void BuildRgbaFrameWritesExpectedLength()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(0, 0, "sand");
+ var buffer = new byte[10 * 10 * 4];
+
+ simulation.BuildRgbaFrame(buffer);
+
+ buffer.Length.Should().Be(400);
+ buffer[3].Should().Be(255);
+ }
+
+ [Fact]
+ public void BuildRgbaFrameUpdatesReusedBufferAfterCellChanges()
+ {
+ var simulation = CreateSimulation();
+ var buffer = new byte[10 * 10 * 4];
+ var sand = _library.GetDefinition(_library.GetTypeId("sand"));
+
+ simulation.CreateParticleAtPixel(2, 2, "sand");
+ simulation.BuildRgbaFrame(buffer);
+
+ var sandIndex = ((2 * simulation.Width) + 2) * 4;
+ buffer[sandIndex].Should().Be(sand.Color.R);
+
+ simulation.ClearParticleCircle(2, 2, 0);
+ simulation.BuildRgbaFrame(buffer);
+
+ buffer[sandIndex].Should().Be(0);
+ buffer[sandIndex + 1].Should().Be(0);
+ buffer[sandIndex + 2].Should().Be(0);
+ buffer[sandIndex + 3].Should().Be(255);
+ }
+
+ [Fact]
+ public void WindBrushWritesLocalWindField()
+ {
+ var simulation = CreateSimulation();
+
+ simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f);
+
+ var wind = simulation.GetWindAtCell(4, 4);
+ wind.X.Should().BeGreaterThan(0f);
+ MathF.Abs(wind.Y).Should().BeLessThan(0.2f);
+ }
+
+ [Fact]
+ public void AirBrushWritesPressureField()
+ {
+ var simulation = CreateSimulation();
+
+ simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f);
+
+ simulation.GetAirPressureAtCell(4, 4).Should().BeGreaterThan(0f);
+ }
+
+ [Fact]
+ public void FieldDecayContinuesWithoutParticles()
+ {
+ var simulation = CreateSimulation();
+ simulation.Settings.PauseSim = false;
+ simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f);
+ var initialPressure = simulation.GetAirPressureAtCell(4, 4);
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetAirPressureAtCell(4, 4).Should().BeLessThan(initialPressure);
+ simulation.ParticleCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void FreshlySpawnedFallingSandDoesNotImmediatelyCreateVisiblePressure()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 1, "sand");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+ simulation.Step(1f / 60f);
+
+ MathF.Abs(simulation.GetAirPressureAtCell(4, 3)).Should().BeLessThan(0.08f);
+ MathF.Abs(simulation.GetAirPressureAtCell(3, 3)).Should().BeLessThan(0.08f);
+ MathF.Abs(simulation.GetAirPressureAtCell(5, 3)).Should().BeLessThan(0.08f);
+ }
+
+ [Fact]
+ public void GasRespondsMoreThanLiquidToSamePressureField()
+ {
+ var steamSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false });
+ var waterSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false });
+
+ steamSimulation.CreateParticleAtPixel(6, 6, "steam");
+ waterSimulation.CreateParticleAtPixel(6, 6, "water");
+
+ for (var i = 0; i < 6; i++)
+ {
+ steamSimulation.ApplyAirBrushAtPixel(3, 6, 3, 1f, 0f);
+ waterSimulation.ApplyAirBrushAtPixel(3, 6, 3, 1f, 0f);
+ steamSimulation.Step(1f / 60f);
+ waterSimulation.Step(1f / 60f);
+ }
+
+ Math.Abs(FindParticleX(steamSimulation, "steam") - 6).Should().BeGreaterThan(0);
+ Math.Abs(FindParticleX(waterSimulation, "water") - 6).Should().BeLessThan(Math.Abs(FindParticleX(steamSimulation, "steam") - 6));
+ }
+
+ [Fact]
+ public void NewlySpawnedParticleClearsLocalForceAndPressureFields()
+ {
+ var simulation = CreateSimulation();
+
+ simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f);
+ simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f);
+ simulation.GetAirPressureAtCell(4, 4).Should().BeGreaterThan(0f);
+ simulation.GetWindAtCell(4, 4).X.Should().BeGreaterThan(0f);
+
+ simulation.CreateParticleAtPixel(4, 4, "sand");
+
+ simulation.GetAirPressureAtCell(4, 4).Should().Be(0f);
+ simulation.GetWindAtCell(4, 4).X.Should().Be(0f);
+ simulation.GetWindAtCell(4, 4).Y.Should().Be(0f);
+ simulation.GetForceAtCell(4, 4).X.Should().Be(0f);
+ simulation.GetForceAtCell(4, 4).Y.Should().Be(0f);
+ }
+
+ [Fact]
+ public void PourPaintingAddsLimitedParticlesInsteadOfFillingEntireBrush()
+ {
+ var simulation = CreateSimulation();
+
+ simulation.CreateParticlePourAtPixel(8, 8, 3, "sand", 4, 1234);
+
+ simulation.ParticleCount.Should().Be(4);
+ }
+
+ [Fact]
+ public void FireTouchingWaterIsExtinguished()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(2, 2, "fire");
+ simulation.CreateParticleAtPixel(3, 2, "water");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(2, 2).Should().Be(0);
+ var neighbor = simulation.GetTypeIdAtCell(3, 2);
+ neighbor.Should().BeOneOf((ushort)0, _library.GetTypeId("water"), _library.GetTypeId("steam"));
+ }
+
+ [Fact]
+ public void FireRisesInsteadOfBehavingLikeSolid()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(5, 5, "fire");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("fire"));
+ }
+
+ [Fact]
+ public void FireEventuallyProducesSmoke()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(5, 7, "fire");
+ simulation.Settings.PauseSim = false;
+
+ for (var i = 0; i < 12; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ CountParticles(simulation, "smoke").Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void SandAndWaterDoNotSpontaneouslyGenerateSmokeOrEmbers()
+ {
+ var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(6, 6, "sand");
+ simulation.CreateParticleAtPixel(9, 6, "water");
+
+ for (var i = 0; i < 20; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ CountParticles(simulation, "smoke").Should().Be(0);
+ CountParticles(simulation, "ember").Should().Be(0);
+ }
+
+ [Fact]
+ public void BurningWoodStaysMostlyInPlaceAndProducesEffectParticles()
+ {
+ var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(8, 8, "burning_wood");
+
+ for (var i = 0; i < 24; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(8, 8).Should().Be(_library.GetTypeId("burning_wood"));
+ (CountParticles(simulation, "smoke") + CountParticles(simulation, "ember")).Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void PlasmaTransfersEnergyToConductiveNeighbors()
+ {
+ var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(8, 8, "plasma");
+ simulation.CreateParticleAtPixel(8, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 8, "iron");
+ simulation.CreateParticleAtPixel(9, 8, "iron");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+ simulation.CreateParticleAtPixel(9, 7, "wall");
+ simulation.CreateParticleAtPixel(8, 9, "wall");
+
+ for (var i = 0; i < 12; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ (simulation.SparkTime[7, 8] != 0 || simulation.SparkTime[9, 8] != 0).Should().BeTrue();
+ }
+
+ [Fact]
+ public void SteamDoesNotImmediatelyCondenseAfterCreation()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(6, 6, "steam");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.CreateParticleAtPixel(7, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(6, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+
+ for (var i = 0; i < 6; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ CountParticles(simulation, "steam").Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void CooledSteamCondensesBackIntoWaterInsteadOfDisappearing()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(6, 6, "steam");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.CreateParticleAtPixel(7, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(6, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+
+ for (var i = 0; i < 80; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("water"));
+ }
+
+ [Fact]
+ public void SteamRisesFasterThanSmokeBecauseOfVelocity()
+ {
+ var steamSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false });
+ var smokeSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false });
+ steamSimulation.CreateParticleAtPixel(6, 9, "steam");
+ smokeSimulation.CreateParticleAtPixel(6, 9, "smoke");
+
+ for (var i = 0; i < 8; i++)
+ {
+ steamSimulation.Step(1f / 60f);
+ smokeSimulation.Step(1f / 60f);
+ }
+
+ FindParticleY(steamSimulation, "steam").Should().BeLessThan(FindParticleY(smokeSimulation, "smoke"));
+ }
+
+ [Fact]
+ public void LavaDoesNotCoolImmediatelyAtAmbient()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(6, 6, "lava");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.CreateParticleAtPixel(7, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(6, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.Temperature[6, 6] = 820f;
+
+ for (var i = 0; i < 8; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("lava"));
+ }
+
+ [Fact]
+ public void LavaDoesNotSelfDestructFromHeatAlone()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(6, 6, "lava");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.CreateParticleAtPixel(7, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(6, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+
+ for (var i = 0; i < 45; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("lava"));
+ }
+
+ [Fact]
+ public void MoltenGoldDoesNotSpreadFasterThanWaterInRuntimeTuning()
+ {
+ var waterSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ var moltenGoldSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ PrepareFloor(waterSimulation, 6);
+ PrepareFloor(moltenGoldSimulation, 6);
+ waterSimulation.CreateParticleAtPixel(8, 5, "water");
+ moltenGoldSimulation.CreateParticleAtPixel(8, 5, "molten_gold");
+
+ for (var i = 0; i < 8; i++)
+ {
+ waterSimulation.Step(1f / 60f);
+ moltenGoldSimulation.Step(1f / 60f);
+ }
+
+ var waterX = FindParticleX(waterSimulation, "water");
+ var moltenGoldX = FindParticleX(moltenGoldSimulation, "molten_gold");
+ Math.Abs(moltenGoldX - 8).Should().BeLessThanOrEqualTo(Math.Abs(waterX - 8));
+ }
+
+ [Fact]
+ public void LavaRespondsLessThanWaterToLateralForce()
+ {
+ var waterSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false });
+ var lavaSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 900f });
+ PrepareFloor(waterSimulation, 6);
+ PrepareFloor(lavaSimulation, 6);
+ waterSimulation.CreateParticleAtPixel(8, 5, "water");
+ lavaSimulation.CreateParticleAtPixel(8, 5, "lava");
+
+ for (var i = 0; i < 10; i++)
+ {
+ waterSimulation.ApplyWindBrushAtPixel(8, 5, 3, 1f, 0f);
+ lavaSimulation.ApplyWindBrushAtPixel(8, 5, 3, 1f, 0f);
+ waterSimulation.Step(1f / 60f);
+ lavaSimulation.Step(1f / 60f);
+ }
+
+ var waterX = FindParticleX(waterSimulation, "water");
+ var lavaX = FindParticleX(lavaSimulation, "lava");
+ Math.Abs(lavaX - 8).Should().BeLessThan(Math.Abs(waterX - 8));
+ }
+
+ [Fact]
+ public void MoltenGoldDoesNotSelfDestructFromHeatAlone()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(6, 6, "molten_gold");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.CreateParticleAtPixel(7, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(6, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+
+ for (var i = 0; i < 35; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("molten_gold"));
+ }
+
+ [Fact]
+ public void IceAndSnowDoNotImmediatelyMeltAtThresholdTemperatures()
+ {
+ var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f });
+ simulation.CreateParticleAtPixel(4, 6, "ice");
+ simulation.CreateParticleAtPixel(8, 6, "snow");
+ simulation.CreateParticleAtPixel(3, 6, "wall");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(3, 7, "wall");
+ simulation.CreateParticleAtPixel(4, 7, "wall");
+ simulation.CreateParticleAtPixel(5, 7, "wall");
+ simulation.CreateParticleAtPixel(7, 6, "wall");
+ simulation.CreateParticleAtPixel(9, 6, "wall");
+ simulation.CreateParticleAtPixel(7, 7, "wall");
+ simulation.CreateParticleAtPixel(8, 7, "wall");
+ simulation.CreateParticleAtPixel(9, 7, "wall");
+ simulation.Temperature[4, 6] = 8f;
+ simulation.Temperature[8, 6] = 10f;
+
+ for (var i = 0; i < 6; i++)
+ {
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(4, 6).Should().Be(_library.GetTypeId("ice"));
+ simulation.GetTypeIdAtCell(8, 6).Should().Be(_library.GetTypeId("snow"));
+ }
+
+ [Fact]
+ public void ExplosiveParticleDetonatesUnderSustainedPressure()
+ {
+ var simulation = new SandSimulation(32, 32, 1, _library, new SimulationSettings { PauseSim = false });
+ simulation.CreateParticleAtPixel(16, 16, "ultratanium");
+
+ for (var i = 0; i < 8; i++)
+ {
+ simulation.ApplyAirBrushAtPixel(16, 16, 6, 1f, 0f);
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.GetTypeIdAtCell(16, 16).Should().Be(0);
+ (CountParticles(simulation, "smoke") + CountParticles(simulation, "fire")).Should().BeGreaterThanOrEqualTo(0);
+ }
+
+ [Fact]
+ public void AdjacentExplosivesDoNotRecursivelyOverflowWhenDetonated()
+ {
+ var simulation = new SandSimulation(32, 32, 1, _library, new SimulationSettings { PauseSim = false });
+ for (var x = 12; x <= 18; x++)
+ {
+ for (var y = 12; y <= 18; y++)
+ {
+ simulation.CreateParticleAtPixel(x, y, "ultratanium");
+ }
+ }
+
+ for (var i = 0; i < 10; i++)
+ {
+ simulation.ApplyAirBrushAtPixel(15, 15, 8, 1f, 0f);
+ simulation.Step(1f / 60f);
+ }
+
+ simulation.ParticleCount.Should().BeGreaterThanOrEqualTo(0);
+ }
+
+ [Fact]
+ public void SustainedPressureBreaksStoneIntoBrokenStone()
+ {
+ var simulation = new SandSimulation(20, 20, 1, _library, new SimulationSettings { PauseSim = false });
+ var brokenStone = _library.GetTypeId("brkstone");
+ simulation.CreateParticleAtPixel(10, 10, "stone");
+ simulation.CreateParticleAtPixel(9, 9, "wall");
+ simulation.CreateParticleAtPixel(10, 9, "wall");
+ simulation.CreateParticleAtPixel(11, 9, "wall");
+ simulation.CreateParticleAtPixel(9, 10, "wall");
+ simulation.CreateParticleAtPixel(11, 10, "wall");
+ simulation.CreateParticleAtPixel(9, 11, "wall");
+ simulation.CreateParticleAtPixel(10, 11, "wall");
+ simulation.CreateParticleAtPixel(11, 11, "wall");
+
+ var broke = false;
+ for (var i = 0; i < 28; i++)
+ {
+ simulation.ApplyAirBrushAtPixel(10, 10, 6, 1f, 0f);
+ simulation.Step(1f / 60f);
+ if (simulation.GetTypeIdAtCell(10, 10) == brokenStone)
+ {
+ broke = true;
+ break;
+ }
+ }
+
+ broke.Should().BeTrue();
+ }
+
+ [Fact]
+ public void WindBiasPushesGasSidewaysWhenUpwardPathIsBlocked()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 4, "steam");
+ simulation.CreateParticleAtPixel(4, 3, "wall");
+ simulation.CreateParticleAtPixel(3, 3, "wall");
+ simulation.CreateParticleAtPixel(5, 3, "wall");
+ simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f);
+ simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f);
+ simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f);
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("steam"));
+ }
+
+ [Fact]
+ public void WindToolDoesNotPushWaterWhenMetadataExcludesIt()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 4, "water");
+ simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f);
+ simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f);
+ simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f);
+
+ var wind = simulation.GetWindAtCell(4, 4);
+ MathF.Abs(wind.X).Should().BeLessThan(0.01f);
+ MathF.Abs(wind.Y).Should().BeLessThan(0.01f);
+ }
+
+ [Fact]
+ public void GravityWellPullsGasSidewaysWhenUpwardPathIsBlocked()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(5, 4, "steam");
+ simulation.CreateParticleAtPixel(5, 3, "wall");
+ simulation.CreateParticleAtPixel(4, 3, "wall");
+ simulation.CreateParticleAtPixel(6, 3, "wall");
+ simulation.ApplyGravityBrushAtPixel(3, 4, 3, 6f);
+ simulation.ApplyGravityBrushAtPixel(3, 4, 3, 6f);
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(4, 4).Should().Be(_library.GetTypeId("steam"));
+ }
+
+ [Fact]
+ public void RepulsorPushesLiquidSidewaysWhenUpwardPathIsBlocked()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 4, "water");
+ simulation.CreateParticleAtPixel(4, 5, "wall");
+ simulation.CreateParticleAtPixel(3, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 5, "wall");
+ simulation.ApplyRepulsorBrushAtPixel(3, 4, 3, 6f);
+ simulation.ApplyRepulsorBrushAtPixel(3, 4, 3, 6f);
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("water"));
+ }
+
+ [Fact]
+ public void SolidDoesNotDiagonalSwapIntoLiquid()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(4, 4, "sand");
+ simulation.CreateParticleAtPixel(4, 5, "wall");
+ simulation.CreateParticleAtPixel(3, 5, "wall");
+ simulation.CreateParticleAtPixel(5, 5, "water");
+ simulation.CreateParticleAtPixel(5, 6, "wall");
+ simulation.CreateParticleAtPixel(4, 6, "wall");
+ simulation.CreateParticleAtPixel(6, 6, "wall");
+ simulation.CreateParticleAtPixel(6, 5, "wall");
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(4, 4).Should().Be(_library.GetTypeId("sand"));
+ simulation.GetTypeIdAtCell(5, 5).Should().Be(_library.GetTypeId("water"));
+ }
+
+ [Fact]
+ public void MoltenLiquidCanSinkThroughLighterLiquidBelow()
+ {
+ var simulation = CreateSimulation();
+ simulation.CreateParticleAtPixel(3, 3, "molten_gold");
+ simulation.CreateParticleAtPixel(3, 4, "water");
+ simulation.CreateParticleAtPixel(2, 3, "wall");
+ simulation.CreateParticleAtPixel(4, 3, "wall");
+ simulation.CreateParticleAtPixel(2, 4, "wall");
+ simulation.CreateParticleAtPixel(4, 4, "wall");
+ simulation.CreateParticleAtPixel(3, 5, "wall");
+ simulation.CreateParticleAtPixel(2, 5, "wall");
+ simulation.CreateParticleAtPixel(4, 5, "wall");
+ simulation.Temperature[3, 3] = 2000f;
+ simulation.Settings.PauseSim = false;
+
+ simulation.Step(1f / 60f);
+
+ simulation.GetTypeIdAtCell(3, 4).Should().Be(_library.GetTypeId("molten_gold"));
+ simulation.GetTypeIdAtCell(3, 3).Should().Be(_library.GetTypeId("water"));
+ }
+
+ private int CountParticles(SandSimulation simulation, string particleId)
+ {
+ var typeId = _library.GetTypeId(particleId);
+ var count = 0;
+ for (var y = 0; y < simulation.Height; y++)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ if (simulation.GetTypeIdAtCell(x, y) == typeId)
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private int FindParticleX(SandSimulation simulation, string particleId)
+ {
+ var typeId = _library.GetTypeId(particleId);
+ for (var y = 0; y < simulation.Height; y++)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ if (simulation.GetTypeIdAtCell(x, y) == typeId)
+ {
+ return x;
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ private int FindParticleY(SandSimulation simulation, string particleId)
+ {
+ var typeId = _library.GetTypeId(particleId);
+ for (var y = 0; y < simulation.Height; y++)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ if (simulation.GetTypeIdAtCell(x, y) == typeId)
+ {
+ return y;
+ }
+ }
+ }
+
+ return int.MaxValue;
+ }
+
+ private double FindAverageX(SandSimulation simulation, string particleId)
+ {
+ var typeId = _library.GetTypeId(particleId);
+ double total = 0;
+ var count = 0;
+ for (var y = 0; y < simulation.Height; y++)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ if (simulation.GetTypeIdAtCell(x, y) == typeId)
+ {
+ total += x;
+ count++;
+ }
+ }
+ }
+
+ return count == 0 ? -1 : total / count;
+ }
+
+ private void PrepareFloor(SandSimulation simulation, int y)
+ {
+ for (var x = 0; x < simulation.Width; x++)
+ {
+ simulation.CreateParticleAtPixel(x, y, "wall");
+ }
+ }
+
+ private SandSimulation CreateSimulation() => new(10, 10, 1, _library, new SimulationSettings { PauseSim = true });
+}
diff --git a/Sand.sln b/Sand.sln
new file mode 100644
index 0000000..1d2d855
--- /dev/null
+++ b/Sand.sln
@@ -0,0 +1,62 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.Core", "Sand.Core\Sand.Core.csproj", "{61ED9272-F50F-4B2A-865B-82C86C0C7284}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.App", "Sand.App\Sand.App.csproj", "{18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.Tests", "Sand.Tests\Sand.Tests.csproj", "{EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x64.Build.0 = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x86.Build.0 = Debug|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|Any CPU.Build.0 = Release|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x64.ActiveCfg = Release|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x64.Build.0 = Release|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x86.ActiveCfg = Release|Any CPU
+ {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x86.Build.0 = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x64.Build.0 = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x86.Build.0 = Debug|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|Any CPU.Build.0 = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x64.ActiveCfg = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x64.Build.0 = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x86.ActiveCfg = Release|Any CPU
+ {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x86.Build.0 = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x64.Build.0 = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x86.Build.0 = Debug|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x64.ActiveCfg = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x64.Build.0 = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x86.ActiveCfg = Release|Any CPU
+ {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/Sand.slnx b/Sand.slnx
new file mode 100644
index 0000000..bd7a9bb
--- /dev/null
+++ b/Sand.slnx
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/advchksys/CHANGELOG.md b/advchksys/CHANGELOG.md
new file mode 100644
index 0000000..a7b10c5
--- /dev/null
+++ b/advchksys/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+
diff --git a/advchksys/README.md b/advchksys/README.md
new file mode 100644
index 0000000..df1d46a
--- /dev/null
+++ b/advchksys/README.md
@@ -0,0 +1,161 @@
+# AdvChkSys
+
+**AdvChkSys** is a high-performance, extensible chunked world management library for .NET, designed for games, simulations, and voxel engines. It provides efficient memory management, chunk loading/unloading, resource tracking, event hooks, serialization, and optional constraints for 2D and 3D chunk-based worlds.
+
+---
+
+## Features
+
+- **2D and 3D Chunk Management**
+ Efficient, thread-safe managers for 2D and 3D chunks with support for custom data types.
+
+- **Memory Efficiency**
+ LRU caching, array pooling, and all-air chunk singletons minimize memory usage and maximize performance.
+
+- **Resource Tracking**
+ Built-in resource manager tracks allocated chunks and supports diagnostics and pooling.
+
+- **Event System**
+ Thread-safe events for chunk lifecycle: loading, unloading, saving, and more.
+
+- **World Constraints**
+ Optional constraints for world size and loaded chunk limits.
+
+- **Serialization**
+ Fast binary serialization/deserialization for chunk data.
+
+- **Async Utilities**
+ Helpers for running chunk tasks asynchronously.
+
+- **Python/.NET Interop**
+ Python bindings for scripting and integration.
+
+---
+
+## Getting Started
+
+### Requirements
+
+- .NET Standard 2.1 or later
+
+### Building
+
+You can build the project using the included build script or directly with dotnet CLI:
+
+#### Using build.bat (Windows)
+
+```bash
+build.bat [options]
+```
+
+Available options:
+
+- `--all`: Build for all platforms, including benchmarks and NuGet package
+- `--windows`: Build for Windows (default)
+- `--linux`: Build for Linux
+- `--mac`: Build for macOS
+- `--benchmarks`: Build the ChunkMark benchmarking tool
+- `--nuget`: Create a NuGet package
+- `--docs`: Generate documentation
+- `--debug`: Build in Debug configuration (Release is default)
+
+Examples:
+
+```bash
+# Build for all platforms
+build.bat --all
+
+# Build for Windows and Linux
+build.bat --windows --linux
+
+# Build benchmarks in debug mode
+build.bat --benchmarks --debug
+```
+
+#### Using dotnet CLI
+
+```bash
+dotnet build src/AdvChkSys/AdvChkSys.csproj
+```
+
+### Basic Usage
+
+```csharp
+using AdvChkSys;
+using AdvChkSys.Manager;
+
+// Create a 2D chunk manager
+var manager2D = AdvChkSys.AdvChkSys.Create2DManager();
+
+// Create or load a chunk
+var chunk = manager2D.LoadOrCreateChunk(0, 0, 32, 32);
+
+// Set a cell value
+chunk[0, 0] = 42;
+
+// Unload a chunk
+manager2D.UnloadChunk(0, 0);
+```
+
+---
+
+## Python Bindings
+
+Python bindings are available in `src/bindings/python/`.
+See [`src/bindings/python/README.md`](src/bindings/python/README.md) for usage.
+
+---
+
+## Documentation
+
+- API documentation can be generated with [DocFX](https://dotnet.github.io/docfx/).
+- XML documentation is included in the build.
+
+---
+
+## License
+
+This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
+
+---
+
+## Acknowledgments
+
+- Inspired by voxel engines and chunked world systems such as Minecraft.
+- Uses [DocFX](https://dotnet.github.io/docfx/) for documentation generation.
+
+---
+
+## Contributing
+
+Pull requests and issues are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) if available, or open an issue to discuss your ideas.
+
+---
+
+## System Requirements
+
+- Windows, Linux, or macOS operating system
+- .NET 5.0 or higher
+- Sufficient memory for the requested benchmark parameters (e.g., 3D benchmark with 1024 chunks of size 384×384×384 would require approximately 54-60 GB of RAM) (e.g., 3D benchmark with 1000 chunks of size 32×32×32 would require approximately 125 MB of RAM)
+- Math for 1024 chunks at a size of 384x384x384.
+
+### Memory Calculation
+
+For 1024 chunks at a size of 384×384×384:
+
+```python
+###Py used for COLORING###
+# Calculate cells per chunk
+cells_per_chunk = 384 × 384 × 384 = 56,623,104 cells
+
+# Calculate total bytes
+total_bytes = cells_per_chunk × 1024 chunks = 57,972,964,416 bytes
+
+# Convert to GB
+total_GB = total_bytes / 1024 / 1024 / 1024 = 54.2 GB
+
+# Add 5-20% overhead for metadata, references, and system overhead
+final_estimate = 54.2 GB × (1.05 to 1.20) = 56.9 to 65.0 GB
+```
+
+---
\ No newline at end of file
diff --git a/advchksys/build.bat b/advchksys/build.bat
new file mode 100644
index 0000000..ad0d58d
--- /dev/null
+++ b/advchksys/build.bat
@@ -0,0 +1,237 @@
+@echo off
+setlocal enabledelayedexpansion
+
+:: AdvChkSys Build Script
+:: ======================
+
+:: Set build parameters
+set "PROJECT_DIR=src\AdvChkSys"
+set "BENCHMARKS_DIR=src\AdvChkSys.Benchmarks"
+set "OUTPUT_DIR=build"
+set "VERSION=0.1.8"
+
+:: Create build directory if it doesn't exist
+if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%"
+
+:: Display header
+echo ╔══════════════════════════════════════════════════════════════╗
+echo ║ ADVANCED CHUNK SYSTEM BUILD ║
+echo ╚══════════════════════════════════════════════════════════════╝
+echo.
+echo Version: %VERSION%
+echo Date: %DATE% %TIME%
+echo.
+
+:: Check for .NET SDK
+echo Checking for .NET SDK...
+dotnet --version > nul 2>&1
+if %ERRORLEVEL% neq 0 (
+ echo Error: .NET SDK not found. Please install the .NET SDK.
+ exit /b 1
+)
+echo ✓ .NET SDK found
+echo.
+
+:: Parse command line arguments
+set "BUILD_WINDOWS=true"
+set "BUILD_LINUX=false"
+set "BUILD_MAC=false"
+set "BUILD_BENCHMARKS=false"
+set "BUILD_NUGET=false"
+set "BUILD_DOCS=false"
+set "CONFIG=Release"
+
+:parse_args
+if "%~1"=="" goto end_parse_args
+if /i "%~1"=="--all" (
+ set "BUILD_WINDOWS=true"
+ set "BUILD_LINUX=true"
+ set "BUILD_MAC=true"
+ set "BUILD_BENCHMARKS=true"
+ set "BUILD_NUGET=true"
+ set "BUILD_DOCS=true"
+) else if /i "%~1"=="--windows" (
+ set "BUILD_WINDOWS=true"
+) else if /i "%~1"=="--linux" (
+ set "BUILD_LINUX=true"
+) else if /i "%~1"=="--mac" (
+ set "BUILD_MAC=true"
+) else if /i "%~1"=="--benchmarks" (
+ set "BUILD_BENCHMARKS=true"
+) else if /i "%~1"=="--nuget" (
+ set "BUILD_NUGET=true"
+) else if /i "%~1"=="--docs" (
+ set "BUILD_DOCS=true"
+) else if /i "%~1"=="--debug" (
+ set "CONFIG=Debug"
+)
+shift
+goto parse_args
+:end_parse_args
+
+:: Display build configuration
+echo Build Configuration:
+echo • Configuration: %CONFIG%
+echo • Windows: %BUILD_WINDOWS%
+echo • Linux: %BUILD_LINUX%
+echo • macOS: %BUILD_MAC%
+echo • Benchmarks: %BUILD_BENCHMARKS%
+echo • NuGet Package: %BUILD_NUGET%
+echo • Documentation: %BUILD_DOCS%
+echo.
+
+:: Clean previous builds
+echo Cleaning previous builds...
+if exist "%PROJECT_DIR%\bin" rd /s /q "%PROJECT_DIR%\bin"
+if exist "%PROJECT_DIR%\obj" rd /s /q "%PROJECT_DIR%\obj"
+if exist "%BENCHMARKS_DIR%\bin" rd /s /q "%BENCHMARKS_DIR%\bin"
+if exist "%BENCHMARKS_DIR%\obj" rd /s /q "%BENCHMARKS_DIR%\obj"
+echo ✓ Clean completed
+echo.
+
+:: Restore packages
+echo Restoring packages...
+dotnet restore "%PROJECT_DIR%\AdvChkSys.csproj"
+if %ERRORLEVEL% neq 0 (
+ echo ✗ Package restore failed
+ exit /b 1
+)
+echo ✓ Packages restored
+echo.
+
+:: Build the library (platform-agnostic)
+echo Building AdvChkSys library...
+dotnet build "%PROJECT_DIR%\AdvChkSys.csproj" -c %CONFIG% --no-restore
+if %ERRORLEVEL% neq 0 (
+ echo ✗ Library build failed
+ exit /b 1
+)
+
+:: Copy library to output directories
+if not exist "%OUTPUT_DIR%\lib" mkdir "%OUTPUT_DIR%\lib"
+copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\lib\"
+copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\lib\"
+
+:: Create platform-specific directories
+if "%BUILD_WINDOWS%"=="true" (
+ if not exist "%OUTPUT_DIR%\windows" mkdir "%OUTPUT_DIR%\windows"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\windows\"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\windows\"
+ echo ✓ Windows build completed
+)
+
+if "%BUILD_LINUX%"=="true" (
+ if not exist "%OUTPUT_DIR%\linux" mkdir "%OUTPUT_DIR%\linux"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\linux\"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\linux\"
+ echo ✓ Linux build completed
+)
+
+if "%BUILD_MAC%"=="true" (
+ if not exist "%OUTPUT_DIR%\macos" mkdir "%OUTPUT_DIR%\macos"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\macos\"
+ copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\macos\"
+ echo ✓ macOS build completed
+)
+echo.
+
+:: Build benchmarks
+if "%BUILD_BENCHMARKS%"=="true" (
+ echo Building ChunkMark benchmarks...
+
+ :: Restore benchmark packages
+ dotnet restore "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj"
+
+ :: Build for Windows
+ if "%BUILD_WINDOWS%"=="true" (
+ dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+ if %ERRORLEVEL% neq 0 (
+ echo ✗ Windows benchmark build failed
+ ) else (
+ if not exist "%OUTPUT_DIR%\benchmarks\windows" mkdir "%OUTPUT_DIR%\benchmarks\windows"
+ copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\win-x64\publish\ChunkMark.exe" "%OUTPUT_DIR%\benchmarks\windows\"
+ echo ✓ Windows benchmarks built
+ )
+ )
+
+ :: Build for Linux
+ if "%BUILD_LINUX%"=="true" (
+ dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+ if %ERRORLEVEL% neq 0 (
+ echo ✗ Linux benchmark build failed
+ ) else (
+ if not exist "%OUTPUT_DIR%\benchmarks\linux" mkdir "%OUTPUT_DIR%\benchmarks\linux"
+ copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\linux-x64\publish\ChunkMark" "%OUTPUT_DIR%\benchmarks\linux\"
+ echo ✓ Linux benchmarks built
+ )
+ )
+
+ :: Build for macOS
+ if "%BUILD_MAC%"=="true" (
+ dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
+ if %ERRORLEVEL% neq 0 (
+ echo ✗ macOS benchmark build failed
+ ) else (
+ if not exist "%OUTPUT_DIR%\benchmarks\macos" mkdir "%OUTPUT_DIR%\benchmarks\macos"
+ copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\osx-x64\publish\ChunkMark" "%OUTPUT_DIR%\benchmarks\macos\"
+ echo ✓ macOS benchmarks built
+ )
+ )
+ echo.
+)
+
+:: Build NuGet package
+if "%BUILD_NUGET%"=="true" (
+ echo Building NuGet package...
+ dotnet pack "%PROJECT_DIR%\AdvChkSys.csproj" -c %CONFIG% --no-build --output "%OUTPUT_DIR%\nuget"
+ if %ERRORLEVEL% neq 0 (
+ echo ✗ NuGet package creation failed
+ exit /b 1
+ )
+ echo ✓ NuGet package created
+ echo.
+)
+
+:: Build documentation
+if "%BUILD_DOCS%"=="true" (
+ echo Building documentation...
+ echo Documentation generation not implemented yet.
+ echo.
+)
+
+:: Display summary
+echo ╔══════════════════════════════════════════════════════════════╗
+echo ║ BUILD SUMMARY ║
+echo ╚══════════════════════════════════════════════════════════════╝
+echo.
+echo Build completed successfully!
+echo.
+echo Output files:
+echo • Library: %OUTPUT_DIR%\lib\AdvChkSys.dll
+
+if "%BUILD_WINDOWS%"=="true" (
+ echo • Windows: %OUTPUT_DIR%\windows\AdvChkSys.dll
+ if "%BUILD_BENCHMARKS%"=="true" (
+ echo • Windows Benchmark: %OUTPUT_DIR%\benchmarks\windows\ChunkMark.exe
+ )
+)
+if "%BUILD_LINUX%"=="true" (
+ echo • Linux: %OUTPUT_DIR%\linux\AdvChkSys.dll
+ if "%BUILD_BENCHMARKS%"=="true" (
+ echo • Linux Benchmark: %OUTPUT_DIR%\benchmarks\linux\ChunkMark
+ )
+)
+if "%BUILD_MAC%"=="true" (
+ echo • macOS: %OUTPUT_DIR%\macos\AdvChkSys.dll
+ if "%BUILD_BENCHMARKS%"=="true" (
+ echo • macOS Benchmark: %OUTPUT_DIR%\benchmarks\macos\ChunkMark
+ )
+)
+if "%BUILD_NUGET%"=="true" (
+ echo • NuGet: %OUTPUT_DIR%\nuget\AdvChkSys.%VERSION%.nupkg
+)
+echo.
+echo Run the build script with --help for more options.
+echo.
+
+endlocal
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/CHANGELOG.md b/advchksys/scripts/track_progress/CHANGELOG.md
new file mode 100644
index 0000000..4dc68c6
--- /dev/null
+++ b/advchksys/scripts/track_progress/CHANGELOG.md
@@ -0,0 +1,2 @@
+# Changelog
+
diff --git a/advchksys/scripts/track_progress/README.md b/advchksys/scripts/track_progress/README.md
new file mode 100644
index 0000000..2733efc
--- /dev/null
+++ b/advchksys/scripts/track_progress/README.md
@@ -0,0 +1,207 @@
+# Progress Tracker
+
+A comprehensive tool for tracking project progress, feature status, and generating documentation from Git history.
+
+## Overview
+
+The Progress Tracker analyzes your Git repository and codebase to automatically generate:
+
+- Feature tracking documentation
+- Project status reports
+- Changelog based on commit messages
+- Code statistics and metrics
+
+## Installation
+
+### Prerequisites
+
+- Python 3.8 or higher
+- Git
+
+### Dependencies
+
+Install the required dependencies:
+
+```bash
+pip install -r scripts/track_progress/requirements.txt
+```
+
+For development or building the executable, install all dependencies:
+
+```bash
+pip install -r scripts/track_progress/requirements.txt[all]
+```
+
+### Quick Install
+
+Run the setup script to install the tracker to your project:
+
+```bash
+python scripts/track_progress/scripts/setup_install.py --project "Your Project Name" --dest path/to/destination
+```
+
+Options:
+- `--project name`: Set your project name
+- `--dest folder`: Set destination directory (default: "scripts")
+- `--verbose` or `-v`: Enable verbose output
+- `--force` or `-f`: Force overwrite existing files
+
+### Manual Installation
+
+1. Copy the `track_progress` directory to your project
+2. Create a `docs/status` directory in your project root
+3. Create a `docs/features.md` file if it doesn't exist
+4. Create a `CHANGELOG.md` file in your project root
+
+## Usage
+
+### Basic Usage
+
+Run the tracker with default settings:
+
+```bash
+# Using the Python script
+python scripts/track_progress/track_progress.py
+
+# Using the executable (if built)
+scripts/track_progress/track_progress
+```
+
+### Command-Line Options
+
+The tracker supports the following command-line options:
+
+```
+--config PATH Path to configuration file (default: scripts/track_progress/progress_config.json)
+--project NAME Project name (overrides config)
+--output-dir DIR Output directory for status document (overrides config)
+--repo PATH Repository path to analyze (default: current directory)
+--verbose, -v Enable verbose output
+```
+
+Examples:
+
+```bash
+# Specify a custom configuration file
+python scripts/track_progress/track_progress.py --config path/to/config.json
+
+# Override project name
+python scripts/track_progress/track_progress.py --project "My Project"
+
+# Specify output directory
+python scripts/track_progress/track_progress.py --output-dir docs/my-status
+
+# Analyze a different repository
+python scripts/track_progress/track_progress.py --repo path/to/repo
+
+# Enable verbose output
+python scripts/track_progress/track_progress.py --verbose
+```
+
+## Commit Message Tags
+
+The tracker recognizes special tags in commit messages to update feature status and documentation:
+
+| Tag | Description | Example |
+|-----|-------------|---------|
+| `[status:key]` | Update status of a component | `[status:database]` |
+| `[feature:key]` | Mark a feature as completed | `[feature:login]` |
+| `[new-feature:key:description]` | Add a new feature | `[new-feature:search:Implement search functionality]` |
+| `[changelog:message]` | Add an entry to the changelog | `[changelog:Added dark mode]` |
+| `[fix:description]` | Document a bug fix | `[fix:Fixed login issue]` |
+| `[issue:id]` | Reference an issue | `[issue:42]` |
+| `[breaking]` | Mark a breaking change | `[breaking]` |
+| `[roadmap:milestone:item]` | Add an item to the roadmap | `[roadmap:v2:Add OAuth support]` |
+| `[milestone:key]` | Reference a milestone | `[milestone:v1.0]` |
+| `[priority:level]` | Set priority level | `[priority:high]` |
+| `[owner:name]` | Assign an owner | `[owner:John]` |
+| `[tag:name]` | Add a custom tag | `[tag:security]` |
+
+Example commit message:
+```
+Implement search functionality [feature:search] [changelog:Added search with filtering]
+```
+
+## Configuration
+
+The tracker uses a JSON configuration file (`progress_config.json`) with the following settings:
+
+```json
+{
+ "project_name": "Your Project",
+ "output_dir": "docs/status",
+ "status_doc": "status.md",
+ "features_file": "docs/features.md",
+ "changelog_file": "CHANGELOG.md",
+ "git_log_limit": 100,
+ "untagged_commit_limit": 50,
+ "top_files_limit": 20,
+ "exclude_dirs": ["node_modules", "venv", ".git"],
+ "source_extensions": [".py", ".cs", ".js"],
+ "templates": {
+ "status": "status_template.md",
+ "features": "feature_template.md",
+ "changelog": "changelog_template.md"
+ }
+}
+```
+
+## Output Files
+
+The tracker generates the following files:
+
+- `docs/features.md`: Feature tracking document
+- `docs/status/status.md`: Project status document (or path specified in config)
+- `CHANGELOG.md`: Project changelog
+
+## Components
+
+The tracker consists of the following components:
+
+- `track_progress.py`: Main script that orchestrates the tracking process
+- `config.py`: Configuration management
+- `git_analyzer.py`: Git history analysis
+- `code_stats.py`: Code statistics and metrics
+- `feature_markdown.py`: Feature tracking utilities
+- `template_engine.py`: Template rendering engine
+- `changelog_generator.py`: Changelog generation
+- `roadmap_generator.py`: Roadmap generation
+
+## Building an Executable
+
+You can build a standalone executable using Nuitka:
+
+```bash
+python scripts/track_progress/scripts/build_with_nuitka.py
+```
+
+This creates an executable in the `scripts/track_progress/dist` directory.
+
+## Customizing Templates
+
+The tracker uses Markdown templates to generate documentation. You can customize these templates in the `scripts/track_progress/templates` directory:
+
+- `status_template.md`: Template for the status document
+- `feature_template.md`: Template for the feature tracking document
+- `changelog_template.md`: Template for the changelog
+
+The templates use a simple syntax:
+- `{{ variable }}`: Insert a variable
+- `{% if condition %}...{% endif %}`: Conditional block
+- `{% for item in items %}...{% endfor %}`: Loop over items
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Missing Git Repository**: Ensure you're running the tracker in a Git repository.
+2. **No Features Found**: Create a basic `docs/features.md` file with at least one feature.
+3. **Template Not Found**: Check that templates exist in the templates directory.
+
+### Debug Mode
+
+Run with `--verbose` to see detailed output:
+
+```bash
+python scripts/track_progress/track_progress.py --verbose
+```
diff --git a/advchksys/scripts/track_progress/__init__.py b/advchksys/scripts/track_progress/__init__.py
new file mode 100644
index 0000000..c2d4cd1
--- /dev/null
+++ b/advchksys/scripts/track_progress/__init__.py
@@ -0,0 +1 @@
+# This file makes the directory a Python package
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/changelog_generator.py b/advchksys/scripts/track_progress/changelog_generator.py
new file mode 100644
index 0000000..7c012b3
--- /dev/null
+++ b/advchksys/scripts/track_progress/changelog_generator.py
@@ -0,0 +1,103 @@
+"""
+Changelog generator for project documentation.
+"""
+
+import os
+from datetime import datetime
+from typing import List, Dict, Any
+
+
+def update_changelog_file(
+ changelog_entries: List[Dict[str, Any]],
+ changelog_file: str,
+ max_entries: int = 100,
+ verbose: bool = False,
+) -> bool:
+ """Update the changelog file with new entries."""
+ if not changelog_file:
+ if verbose:
+ print("No changelog file specified, skipping update")
+ return False
+
+ if verbose:
+ print(f"Updating changelog file: {changelog_file}")
+ print(f"Found {len(changelog_entries)} entries to add")
+
+ # Ensure the directory exists if the file is not in the current directory
+ changelog_dir = os.path.dirname(changelog_file)
+ if (
+ changelog_dir
+ ): # Only create directory if there is one (not empty string)
+ os.makedirs(changelog_dir, exist_ok=True)
+
+ # Create the file if it doesn't exist
+ if not os.path.exists(changelog_file):
+ with open(changelog_file, "w", encoding="utf-8") as f:
+ f.write("# Changelog\n\n")
+ if verbose:
+ print(f"Created new changelog file: {changelog_file}")
+
+ # Read existing content
+ with open(changelog_file, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Extract header (everything before the first entry)
+ header_end = content.find("## ")
+ if header_end == -1:
+ header = "# Changelog\n\n"
+ else:
+ header = content[:header_end]
+
+ # Format new entries
+ new_entries = []
+
+ # Group entries by date
+ entries_by_date = {}
+ for entry in changelog_entries:
+ date = entry.get("date", "")
+ if not date:
+ continue
+
+ if date not in entries_by_date:
+ entries_by_date[date] = []
+
+ entries_by_date[date].append(entry)
+
+ # Sort dates in reverse chronological order
+ for date in sorted(entries_by_date.keys(), reverse=True):
+ entries = entries_by_date[date]
+
+ # Format date as a section header
+ new_entries.append(f"## {date}\n")
+
+ # Add each entry
+ for entry in entries:
+ message = entry.get("message", "").strip()
+ commit = entry.get("commit", "")
+
+ if message:
+ # Clean up the message
+ if message.startswith("- "):
+ message = message[2:]
+
+ new_entries.append(f"- {message} ({commit})")
+
+ new_entries.append("") # Add an empty line after each date section
+
+ # If no new entries, just return
+ if not new_entries:
+ if verbose:
+ print("No new entries to add to changelog")
+ return False
+
+ # Combine header and new entries
+ new_content = header + "\n".join(new_entries)
+
+ # Write the updated content
+ with open(changelog_file, "w", encoding="utf-8") as f:
+ f.write(new_content)
+
+ if verbose:
+ print(f"Updated changelog with {len(changelog_entries)} new entries")
+
+ return True
diff --git a/advchksys/scripts/track_progress/code_stats.py b/advchksys/scripts/track_progress/code_stats.py
new file mode 100644
index 0000000..1fc03da
--- /dev/null
+++ b/advchksys/scripts/track_progress/code_stats.py
@@ -0,0 +1,121 @@
+import os
+import glob
+from typing import Dict, Any, List, Optional
+
+
+def count_lines(file_path: str) -> int:
+ """Count lines in a file."""
+ try:
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
+ return len(f.readlines())
+ except Exception:
+ return 0
+
+
+def get_language(file_path: str) -> str:
+ """Determine language from file extension."""
+ ext = os.path.splitext(file_path)[1].lower()
+ language_map = {
+ ".py": "Python",
+ ".cs": "C#",
+ ".js": "JavaScript",
+ ".ts": "TypeScript",
+ ".html": "HTML",
+ ".css": "CSS",
+ ".md": "Markdown",
+ ".json": "JSON",
+ ".xml": "XML",
+ ".java": "Java",
+ ".cpp": "C++",
+ ".c": "C",
+ ".h": "C/C++ Header",
+ ".go": "Go",
+ ".rs": "Rust",
+ ".php": "PHP",
+ ".rb": "Ruby",
+ ".sh": "Shell",
+ ".bat": "Batch",
+ ".ps1": "PowerShell",
+ ".sql": "SQL",
+ ".yaml": "YAML",
+ ".yml": "YAML",
+ }
+ return language_map.get(ext, "Other")
+
+
+def analyze_code_stats(
+ root_dir: str = ".",
+ exclude_dirs: List[str] = None,
+ source_extensions: List[str] = None,
+ top_files_limit: int = 20,
+) -> Dict[str, Any]:
+ """Analyze code statistics for the project."""
+ if exclude_dirs is None:
+ exclude_dirs = ["node_modules", "venv", ".git", ".vs", "bin", "obj"]
+
+ if source_extensions is None:
+ source_extensions = [
+ ".py",
+ ".cs",
+ ".js",
+ ".ts",
+ ".html",
+ ".css",
+ ".md",
+ ]
+
+ stats = {
+ "total_lines": 0,
+ "file_count": 0,
+ "languages": {},
+ "top_files": [],
+ }
+
+ file_stats = []
+
+ # Convert exclude_dirs to absolute paths
+ exclude_paths = [os.path.join(root_dir, d) for d in exclude_dirs]
+
+ # Find all source files
+ for ext in source_extensions:
+ pattern = os.path.join(root_dir, "**", f"*{ext}")
+ for file_path in glob.glob(pattern, recursive=True):
+ # Skip excluded directories
+ if any(
+ file_path.startswith(exclude_path)
+ for exclude_path in exclude_paths
+ ):
+ continue
+
+ # Count lines
+ lines = count_lines(file_path)
+
+ # Get language
+ language = get_language(file_path)
+
+ # Update statistics
+ stats["total_lines"] += lines
+ stats["file_count"] += 1
+
+ # Update language count
+ if language not in stats["languages"]:
+ stats["languages"][language] = 0
+ stats["languages"][language] += 1
+
+ # Add to file stats
+ file_stats.append(
+ {
+ "path": file_path,
+ "name": os.path.basename(file_path),
+ "lines": lines,
+ "language": language,
+ }
+ )
+
+ # Sort file stats by line count
+ file_stats.sort(key=lambda x: x["lines"], reverse=True)
+
+ # Get top files
+ stats["top_files"] = file_stats[:top_files_limit]
+
+ return stats
diff --git a/advchksys/scripts/track_progress/config.py b/advchksys/scripts/track_progress/config.py
new file mode 100644
index 0000000..bec408d
--- /dev/null
+++ b/advchksys/scripts/track_progress/config.py
@@ -0,0 +1,87 @@
+"""
+Configuration management for project status documentation.
+"""
+
+import os
+import json
+from typing import Dict, Any, List, Optional
+
+
+class Config:
+ """Configuration manager for project status documentation."""
+
+ def __init__(self, config_file: str = "scripts/progress_config.json"):
+ """Initialize with configuration file."""
+ self.config_file = config_file
+ self.config = self._load_config()
+
+ def _load_config(self) -> Dict[str, Any]:
+ """Load configuration from file."""
+ default_config = {
+ "project_name": "Project",
+ "output_dir": "docs/status",
+ "status_doc": "status.md",
+ "features_file": "docs/features.md",
+ "changelog_file": "CHANGELOG.md",
+ "git_log_limit": 100,
+ "untagged_commit_limit": 50,
+ "top_files_limit": 20,
+ "exclude_dirs": [
+ "node_modules",
+ "venv",
+ ".git",
+ ".vs",
+ "bin",
+ "obj",
+ ],
+ "source_extensions": [".py", ".cs", ".js", ".ts", ".html", ".css"],
+ "templates": {
+ "status": "status_template.md",
+ "features": "feature_template.md",
+ "changelog": "changelog_template.md",
+ },
+ }
+
+ if not os.path.exists(self.config_file):
+ return default_config
+
+ try:
+ with open(self.config_file, "r", encoding="utf-8") as f:
+ config = json.load(f)
+
+ # Merge with default config to ensure all keys exist
+ merged_config = default_config.copy()
+ merged_config.update(config)
+
+ return merged_config
+ except Exception:
+ return default_config
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Get a configuration value."""
+ if "." in key:
+ # Handle nested keys like "templates.status"
+ parts = key.split(".")
+ value = self.config
+ for part in parts:
+ if isinstance(value, dict) and part in value:
+ value = value[part]
+ else:
+ return default
+ return value
+
+ return self.config.get(key, default)
+
+ def set(self, key: str, value: Any) -> None:
+ """Set a configuration value."""
+ if "." in key:
+ # Handle nested keys
+ parts = key.split(".")
+ config = self.config
+ for part in parts[:-1]:
+ if part not in config:
+ config[part] = {}
+ config = config[part]
+ config[parts[-1]] = value
+ else:
+ self.config[key] = value
diff --git a/advchksys/scripts/track_progress/feature_markdown.py b/advchksys/scripts/track_progress/feature_markdown.py
new file mode 100644
index 0000000..4b74fda
--- /dev/null
+++ b/advchksys/scripts/track_progress/feature_markdown.py
@@ -0,0 +1,317 @@
+"""
+Feature markdown utilities for project status documentation.
+"""
+
+import os
+from typing import Dict, Any, List, Optional
+import re
+from datetime import datetime
+
+# Mapping of status to emojis
+STATUS_EMOJIS = {
+ "completed": "✅",
+ "in_progress": "🔄",
+ "planned": "📝",
+ "error": "☣️",
+ "blocked": "⛔",
+ "failed": "❌",
+ "warning": "⚠️",
+ "unknown": "❓",
+ "done": "✅",
+ "testing": "🧪",
+ "review": "👀",
+ "design": "🎨",
+ "research": "🔍",
+ "deprecated": "🗑️",
+ "postponed": "⏳",
+}
+
+
+def emoji_status(status: str) -> str:
+ """Return emoji for a status."""
+ return STATUS_EMOJIS.get(status.lower(), STATUS_EMOJIS["unknown"])
+
+
+def format_feature_md(feature_name: str, data: dict) -> str:
+ """Format a single feature into Markdown."""
+ status = data.get("status", "unknown")
+ status_emoji = emoji_status(status)
+
+ lines = [f"## {feature_name}"]
+ lines.append(f"- Status: {status_emoji} {status}")
+
+ if "description" in data:
+ lines.append(f"- Description: {data['description']}")
+
+ if "date" in data:
+ lines.append(f"- Last Update: {data['date']}")
+
+ if "author" in data:
+ lines.append(f"- Updated By: {data['author']}")
+
+ if "details" in data:
+ lines.append(f"- Details: {data['details']}")
+
+ return "\n".join(lines)
+
+
+def build_feature_md(features: dict, title: str = "# Feature Tracking") -> str:
+ """Build the full Markdown document from features dictionary."""
+ lines = [title, ""]
+
+ for feature_name, data in features.items():
+ lines.append(format_feature_md(feature_name, data))
+ lines.append("") # Add an empty line between features
+
+ return "\n".join(lines)
+
+
+def extract_features(
+ features_file: str, verbose: bool = False
+) -> Dict[str, Dict[str, Any]]:
+ """Extract all valid features from the file, focusing on descriptions."""
+ features = {}
+ seen_descriptions = set()
+
+ if not os.path.exists(features_file):
+ if verbose:
+ print(f"File not found: {features_file}")
+ return features
+
+ with open(features_file, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # First, try to extract features from the specific format
+ # This format has ### feature_name followed by status, description, date
+ status_blocks = re.findall(
+ r"### ([^\n]+)\s+\*\*Status:\*\* ([^\n]+)\s+\*\*Description:\*\* ([^\n]+)\s+\*\*Last Update:\*\* ([^\n]+)",
+ content,
+ )
+
+ for feature_key, status_line, desc, date in status_blocks:
+ desc = desc.strip()
+ date = date.strip()
+
+ if desc and len(desc) > 2 and desc not in seen_descriptions:
+ seen_descriptions.add(desc)
+
+ # Extract status without emoji
+ status_match = re.search(
+ r"[^a-zA-Z]*([a-zA-Z_]+)", status_line
+ )
+ status = (
+ status_match.group(1).lower()
+ if status_match
+ else "planned"
+ )
+
+ # Clean up the feature key
+ clean_key = re.sub(
+ r"[^a-zA-Z0-9_]", "", feature_key.replace(" ", "_").lower()
+ )
+ if not clean_key or len(clean_key) < 2:
+ # Generate key from description
+ words = desc.split()[:3]
+ clean_key = "_".join(words).lower()
+ clean_key = re.sub(r"[^a-zA-Z0-9_]", "", clean_key)
+
+ features[clean_key] = {
+ "status": status,
+ "description": desc,
+ "date": date,
+ "author": "",
+ "details": "",
+ }
+
+ # Also try the format: ### **Status:** status \n **Description:** desc \n **Last Update:** date
+ alt_blocks = re.findall(
+ r"### \*\*Status:\*\* ([^\n]+)\s+\*\*Description:\*\* ([^\n]+)\s+\*\*Last Update:\*\* ([^\n]+)",
+ content,
+ )
+
+ for status_line, desc, date in alt_blocks:
+ desc = desc.strip()
+ date = date.strip()
+
+ if desc and len(desc) > 2 and desc not in seen_descriptions:
+ seen_descriptions.add(desc)
+
+ # Extract status without emoji
+ status_match = re.search(
+ r"[^a-zA-Z]*([a-zA-Z_]+)", status_line
+ )
+ status = (
+ status_match.group(1).lower()
+ if status_match
+ else "planned"
+ )
+
+ # Generate key from description
+ words = desc.split()[:3]
+ clean_key = "_".join(words).lower()
+ clean_key = re.sub(r"[^a-zA-Z0-9_]", "", clean_key)
+
+ features[clean_key] = {
+ "status": status,
+ "description": desc,
+ "date": date,
+ "author": "",
+ "details": "",
+ }
+
+ return features
+
+
+def enrich_features(
+ features: Dict[str, Dict[str, Any]]
+) -> Dict[str, Dict[str, Any]]:
+ """Add additional information to features for display purposes."""
+ enriched = {}
+
+ for key, data in features.items():
+ enriched[key] = data.copy()
+ status = data.get("status", "unknown")
+ enriched[key]["status_emoji"] = emoji_status(status)
+
+ # Add title-cased key for display
+ enriched[key]["display_name"] = key.replace("_", " ").title()
+
+ return enriched
+
+
+def generate_features_md(
+ features: Dict[str, Dict[str, Any]], project_name: str
+) -> str:
+ """Generate a clean features markdown from the features dictionary."""
+ lines = [
+ "# Feature Tracking",
+ "",
+ "## Overview",
+ "",
+ f"This document tracks the implementation status of all features in the {project_name} project.",
+ "",
+ "## Feature Status Summary",
+ "",
+ "| Status | Description |",
+ "|--------|-------------|",
+ "| ✅ Completed | Features that are fully implemented and tested |",
+ "| 🔄 In Progress | Features currently being implemented |",
+ "| 📝 Planned | Features planned for future implementation |",
+ "",
+ "## Features",
+ "",
+ ]
+
+ # Add each feature
+ for feature_key, feature in sorted(features.items()):
+ status = feature.get("status", "planned").lower()
+ status_emoji = emoji_status(status)
+ description = feature.get("description", "")
+ date = feature.get("date", "") or datetime.now().strftime("%Y-%m-%d")
+ author = feature.get("author", "")
+ details = feature.get("details", "")
+
+ lines.append(f"### {feature_key}")
+ lines.append("")
+ lines.append(f"**Status:** {status_emoji} {status}")
+ lines.append(f"**Description:** {description}")
+ lines.append(f"**Last Update:** {date}")
+
+ if author:
+ lines.append(f"**Owner:** {author}")
+
+ if details:
+ lines.append(f"**Details:** {details}")
+
+ lines.append("")
+
+ # Add categorized lists
+ lines.append("## Feature Categories")
+ lines.append("")
+
+ # Completed features
+ lines.append("### Completed Features")
+ lines.append("")
+ completed_count = 0
+ for feature_key, feature in sorted(features.items()):
+ if feature.get("status", "").lower() in ["completed", "done"]:
+ lines.append(
+ f"- **{feature_key}**: {feature.get('description', '')}"
+ )
+ completed_count += 1
+ if completed_count == 0:
+ lines.append("*No completed features yet.*")
+ lines.append("")
+
+ # In progress features
+ lines.append("### In Progress Features")
+ lines.append("")
+ in_progress_count = 0
+ for feature_key, feature in sorted(features.items()):
+ if feature.get("status", "").lower() == "in_progress":
+ lines.append(
+ f"- **{feature_key}**: {feature.get('description', '')}"
+ )
+ in_progress_count += 1
+ if in_progress_count == 0:
+ lines.append("*No features currently in progress.*")
+ lines.append("")
+
+ # Planned features
+ lines.append("### Planned Features")
+ lines.append("")
+ planned_count = 0
+ for feature_key, feature in sorted(features.items()):
+ if feature.get("status", "").lower() == "planned":
+ lines.append(
+ f"- **{feature_key}**: {feature.get('description', '')}"
+ )
+ planned_count += 1
+ if planned_count == 0:
+ lines.append("*No planned features yet.*")
+ lines.append("")
+
+ # Add CSS styling
+ lines.append(
+ """"""
+ )
+
+ return "\n".join(lines)
diff --git a/advchksys/scripts/track_progress/git_analyzer.py b/advchksys/scripts/track_progress/git_analyzer.py
new file mode 100644
index 0000000..04b2283
--- /dev/null
+++ b/advchksys/scripts/track_progress/git_analyzer.py
@@ -0,0 +1,238 @@
+import os
+import re
+import subprocess
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Tuple
+
+
+class GitAnalyzer:
+ """Analyze Git repository for project status information."""
+
+ def __init__(self, repo_path: str = "."):
+ """Initialize with repository path."""
+ self.repo_path = repo_path
+
+ def _run_git_command(self, command: List[str]) -> str:
+ """Run a Git command and return the output."""
+ try:
+ result = subprocess.run(
+ ["git"] + command,
+ cwd=self.repo_path,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return result.stdout.strip()
+ except subprocess.CalledProcessError:
+ return ""
+
+ def get_repo_info(self) -> Dict[str, Any]:
+ """Get basic repository information."""
+ info = {}
+
+ # Get remote URL
+ remote_url = self._run_git_command(["remote", "get-url", "origin"])
+ info["remote_url"] = remote_url
+
+ # Get current branch
+ branch = self._run_git_command(["branch", "--show-current"])
+ info["branch"] = branch
+
+ # Get last commit
+ last_commit = self._run_git_command(
+ ["log", "-1", "--pretty=format:%h|%s|%ad|%an", "--date=short"]
+ )
+ if last_commit:
+ parts = last_commit.split("|")
+ if len(parts) >= 4:
+ info["last_commit"] = {
+ "hash": parts[0],
+ "message": parts[1],
+ "date": parts[2],
+ "author": parts[3],
+ }
+
+ return info
+
+ def analyze_commits(
+ self, limit: int = 100, untagged_limit: int = 50
+ ) -> Dict[str, Any]:
+ """Analyze Git commits for feature updates, changelog entries, etc."""
+ result = {
+ "feature_updates": {},
+ "new_features": {},
+ "changelog_entries": [],
+ "fixes": [],
+ "issues": [],
+ "untagged_commits": [],
+ "milestones": {},
+ "roadmap_items": {},
+ }
+
+ # Regular expressions for parsing commit messages
+ status_re = re.compile(r"\[status:(\w+)\]")
+ feature_re = re.compile(r"\[feature:(\w+)\]")
+ new_feature_re = re.compile(r"\[new-feature:(\w+):(.+?)\]")
+ changelog_re = re.compile(r"\[changelog:(.+?)\]")
+ fix_re = re.compile(r"\[fix:(.+?)\]")
+ issue_re = re.compile(r"\[issue:(.+?)\]")
+ milestone_re = re.compile(r"\[milestone:(\w+)\]")
+ roadmap_re = re.compile(r"\[roadmap:(\w+):(.+?)\]")
+
+ # Get Git log
+ git_log = self._run_git_command(
+ [
+ "log",
+ f"-n{limit}",
+ "--pretty=format:%h|%s|%ad|%an",
+ "--date=short",
+ ]
+ )
+
+ untagged_count = 0
+
+ # Parse commits
+ for line in git_log.splitlines():
+ parts = line.split("|")
+ if len(parts) < 4:
+ continue
+
+ commit_hash, subject, date, author = parts
+
+ # Check for tags
+ status_match = status_re.search(subject)
+ feature_match = feature_re.search(subject)
+ new_feature_match = new_feature_re.search(subject)
+ changelog_match = changelog_re.search(subject)
+ fix_match = fix_re.search(subject)
+ issue_match = issue_re.search(subject)
+ milestone_match = milestone_re.search(subject)
+ roadmap_match = roadmap_re.search(subject)
+
+ # Process status updates
+ if status_match:
+ status_key = status_match.group(1)
+ result["feature_updates"][status_key] = {
+ "status": "completed",
+ "date": date,
+ "author": author,
+ }
+
+ # Process feature updates
+ if feature_match:
+ feature_key = feature_match.group(1)
+ result["feature_updates"][feature_key] = {
+ "status": "completed",
+ "date": date,
+ "author": author,
+ }
+
+ # Process new features
+ if new_feature_match:
+ feature_key = new_feature_match.group(1)
+ feature_desc = new_feature_match.group(2)
+ result["new_features"][feature_key] = {
+ "status": "planned",
+ "description": feature_desc,
+ "date": date,
+ "author": author,
+ }
+
+ # Process changelog entries
+ if changelog_match:
+ result["changelog_entries"].append(
+ {
+ "message": changelog_match.group(1),
+ "date": date,
+ "commit": commit_hash,
+ "author": author,
+ }
+ )
+
+ # Process fixes
+ if fix_match:
+ result["fixes"].append(
+ {
+ "message": fix_match.group(1),
+ "date": date,
+ "commit": commit_hash,
+ "author": author,
+ }
+ )
+
+ # Process issues
+ if issue_match:
+ result["issues"].append(
+ {
+ "message": issue_match.group(1),
+ "date": date,
+ "commit": commit_hash,
+ "author": author,
+ }
+ )
+
+ # Process milestones
+ if milestone_match:
+ milestone_key = milestone_match.group(1)
+ if milestone_key not in result["milestones"]:
+ result["milestones"][milestone_key] = {
+ "first_date": date,
+ "last_date": date,
+ "commits": [],
+ }
+ else:
+ result["milestones"][milestone_key]["last_date"] = date
+
+ result["milestones"][milestone_key]["commits"].append(
+ commit_hash
+ )
+
+ # Process roadmap items
+ if roadmap_match:
+ milestone_key = roadmap_match.group(1)
+ item_desc = roadmap_match.group(2)
+
+ if milestone_key not in result["roadmap_items"]:
+ result["roadmap_items"][milestone_key] = []
+
+ result["roadmap_items"][milestone_key].append(
+ {
+ "description": item_desc,
+ "date": date,
+ "commit": commit_hash,
+ "author": author,
+ }
+ )
+
+ # Process untagged commits
+ if not any(
+ [
+ status_match,
+ feature_match,
+ new_feature_match,
+ changelog_match,
+ fix_match,
+ issue_match,
+ milestone_match,
+ roadmap_match,
+ ]
+ ):
+ if untagged_count < untagged_limit:
+ # Skip merge commits and very short messages
+ if not subject.startswith("Merge ") and len(subject) > 5:
+ # Extract the first sentence or up to 100 chars
+ commit_desc = subject.split(".")[0]
+ if len(commit_desc) > 100:
+ commit_desc = commit_desc[:97] + "..."
+
+ result["untagged_commits"].append(
+ {
+ "message": commit_desc,
+ "date": date,
+ "commit": commit_hash,
+ "author": author,
+ }
+ )
+ untagged_count += 1
+
+ return result
diff --git a/advchksys/scripts/track_progress/progress_config.json b/advchksys/scripts/track_progress/progress_config.json
new file mode 100644
index 0000000..dc927a9
--- /dev/null
+++ b/advchksys/scripts/track_progress/progress_config.json
@@ -0,0 +1,37 @@
+{
+ "project_name": "AdvChkSys",
+ "output_dir": "docs/status",
+ "status_doc": "ChunkManager-Status.md",
+ "features_file": "docs/features.md",
+ "changelog_file": "CHANGELOG.md",
+ "git_log_limit": 100,
+ "untagged_commit_limit": 50,
+ "top_files_limit": 20,
+ "exclude_dirs": [
+ "node_modules",
+ "venv",
+ ".git",
+ ".vs",
+ "bin",
+ "obj",
+ "dist",
+ "build"
+ ],
+ "source_extensions": [
+ ".py",
+ ".cs",
+ ".js",
+ ".ts",
+ ".html",
+ ".css",
+ ".rs",
+ ".go",
+ ".java",
+ ".md"
+ ],
+ "templates": {
+ "status": "status_template.md",
+ "features": "feature_template.md",
+ "changelog": "changelog_template.md"
+ }
+}
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/requirements.txt b/advchksys/scripts/track_progress/requirements.txt
new file mode 100644
index 0000000..6540481
--- /dev/null
+++ b/advchksys/scripts/track_progress/requirements.txt
@@ -0,0 +1,16 @@
+# Core dependencies
+argparse # For parsing command-line arguments
+shutil # For file and directory operations
+GitPython # For Git repository analysis
+Jinja2 # Alternative template engine (optional)
+Markdown # For Markdown processing (optional)
+
+# Build dependencies (only needed for building the executable)
+nuitka # For compiling Python to executable
+ordered-set # Required by Nuitka
+zstandard # For better compression in Nuitka builds
+
+# Development dependencies (optional)
+black # Code formatting
+pylint # Code linting
+pytest # Testing
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/roadmap_generator.py b/advchksys/scripts/track_progress/roadmap_generator.py
new file mode 100644
index 0000000..b56d766
--- /dev/null
+++ b/advchksys/scripts/track_progress/roadmap_generator.py
@@ -0,0 +1,33 @@
+"""
+Roadmap generator for project status documentation.
+"""
+
+from typing import Dict, Any, List, Optional
+
+
+def generate_roadmap(
+ milestones: Dict[str, Dict[str, Any]],
+ roadmap_items: Dict[str, List[Dict[str, Any]]],
+) -> List[Dict[str, Any]]:
+ """Generate roadmap from milestones and roadmap items."""
+ roadmap = []
+
+ # Process each milestone
+ for milestone_key, milestone_data in milestones.items():
+ # Create milestone entry
+ milestone = {
+ "name": milestone_key.replace("_", " ").title(),
+ "target_date": milestone_data.get("last_date", "TBD"),
+ "items": [],
+ }
+
+ # Add roadmap items for this milestone
+ if milestone_key in roadmap_items:
+ for item in roadmap_items[milestone_key]:
+ milestone["items"].append(
+ {"description": item["description"], "status": "planned"}
+ )
+
+ roadmap.append(milestone)
+
+ return roadmap
diff --git a/advchksys/scripts/track_progress/scripts/SCRIPTS.md b/advchksys/scripts/track_progress/scripts/SCRIPTS.md
new file mode 100644
index 0000000..cf6bbee
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/SCRIPTS.md
@@ -0,0 +1,55 @@
+# Progress Tracker Scripts
+
+This directory contains utility scripts for building, installing, and running the progress tracker.
+
+## Available Scripts
+
+### setup_install.py
+
+Installs the progress tracker to your project.
+
+```bash
+python setup_install.py --dest scripts --project "Your Project Name"
+```
+
+Options:
+- `--dest folder`: Set destination directory (default: "scripts")
+- `--project name`: Set project name (default: "Project")
+- `--verbose` or `-v`: Enable verbose output
+- `--force` or `-f`: Force overwrite existing files
+
+### build_with_nuitka.py
+
+Builds the progress tracker into a standalone executable using Nuitka.
+
+```bash
+python build_with_nuitka.py
+```
+
+Options:
+- `--output-dir`: Output directory for the build (default: "dist")
+- `--verbose` or `-v`: Enable verbose output
+
+### create_launcher_scripts.py
+
+Creates launcher scripts for running the progress tracker. This is usually called by setup_install.py.
+
+## Launcher Scripts
+
+The following launcher scripts are created during installation:
+
+- `run_progress_tracker.py`: Python launcher script
+- `run_progress_tracker.sh`: Shell launcher script (Linux/macOS)
+- `run_progress_tracker.bat`: Batch launcher script (Windows)
+
+## Usage Example
+
+1. Install the tracker:
+ ```bash
+ python setup_install.py --project "My Project" --verbose
+ ```
+
+2. Run the tracker using one of the launcher scripts:
+ - Windows: `run_progress_tracker.bat`
+ - Linux/macOS: `./run_progress_tracker.sh`
+ - Any platform: `python run_progress_tracker.py`
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/scripts/build_with_nuitka.py b/advchksys/scripts/track_progress/scripts/build_with_nuitka.py
new file mode 100644
index 0000000..f920509
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/build_with_nuitka.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""
+Build the progress tracker with Nuitka.
+"""
+import os
+import sys
+import shutil
+import subprocess
+import argparse
+
+
+def main():
+ """Build the progress tracker with Nuitka."""
+ parser = argparse.ArgumentParser(
+ description="Build the progress tracker with Nuitka"
+ )
+ parser.add_argument(
+ "--output-dir", default="dist", help="Output directory for the build"
+ )
+ parser.add_argument(
+ "--verbose", "-v", action="store_true", help="Enable verbose output"
+ )
+ parser.add_argument(
+ "--move-exe",
+ action="store_true",
+ help="Move executable to parent directory after build",
+ )
+ args = parser.parse_args()
+
+ verbose = args.verbose
+ output_dir = args.output_dir
+ move_exe = args.move_exe
+
+ # Get the directory of this script
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ parent_dir = os.path.dirname(script_dir) # scripts/track_progress
+
+ # Check if Nuitka is installed
+ try:
+ subprocess.check_call(
+ [sys.executable, "-m", "pip", "show", "nuitka"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except subprocess.CalledProcessError:
+ print("Nuitka is not installed. Installing...")
+ try:
+ subprocess.check_call(
+ [sys.executable, "-m", "pip", "install", "nuitka"]
+ )
+ except subprocess.CalledProcessError:
+ print(
+ "Failed to install Nuitka. Please install it manually with 'pip install nuitka'."
+ )
+ return 1
+
+ # Create output directory
+ dist_dir = os.path.join(parent_dir, output_dir)
+ os.makedirs(dist_dir, exist_ok=True)
+
+ # Path to the main script
+ main_script = os.path.join(parent_dir, "track_progress.py")
+
+ if not os.path.exists(main_script):
+ print(f"Error: Main script not found at {main_script}")
+ return 1
+
+ # Build command
+ cmd = [
+ sys.executable,
+ "-m",
+ "nuitka",
+ "--standalone",
+ "--onefile",
+ "--lto=auto",
+ "--jobs=4",
+ "--include-package=track_progress",
+ "--include-data-dir="
+ + os.path.join(parent_dir, "templates")
+ + "=templates",
+ "--output-dir=" + dist_dir,
+ main_script,
+ ]
+
+ # Try to enable UPX if available
+ try:
+ subprocess.check_call(
+ ["upx", "--version"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ cmd.append("--enable-plugin=upx")
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ if verbose:
+ print("UPX not found, continuing without compression")
+
+ if verbose:
+ print(f"Building with command: {' '.join(cmd)}")
+ else:
+ cmd.append("--quiet")
+
+ # Run Nuitka
+ try:
+ subprocess.check_call(cmd)
+ print(f"Build successful! Executable is in {dist_dir}")
+
+ # Move the executable to the parent directory if requested
+ if move_exe:
+ exe_ext = ".exe" if sys.platform == "win32" else ""
+ exe_name = f"track_progress{exe_ext}"
+ src_exe = os.path.join(dist_dir, exe_name)
+ dst_exe = os.path.join(parent_dir, exe_name)
+
+ if os.path.exists(src_exe):
+ # Remove existing executable if it exists
+ if os.path.exists(dst_exe):
+ os.remove(dst_exe)
+
+ # Move the executable
+ shutil.move(src_exe, dst_exe)
+ if verbose:
+ print(f"Moved executable from {src_exe} to {dst_exe}")
+ else:
+ print(f"Warning: Built executable not found at {src_exe}")
+
+ return 0
+ except subprocess.CalledProcessError as e:
+ print(f"Build failed: {e}")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py b/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py
new file mode 100644
index 0000000..89c1e47
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py
@@ -0,0 +1,217 @@
+"""
+Create launcher scripts for the progress tracker.
+"""
+
+import os
+import sys
+from typing import List, Tuple
+
+
+def create_python_launcher(
+ output_path: str, dest_dir: str, project_name: str
+) -> bool:
+ """Create a Python launcher script."""
+ content = f"""#!/usr/bin/env python3
+\"\"\"
+Launcher for the progress tracker.
+Tries to use the executable version first, then falls back to the Python version.
+\"\"\"
+import os
+import sys
+import subprocess
+
+def main():
+ \"\"\"Main function to run the tracker.\"\"\"
+ # Get the directory of this script
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ tracker_dir = os.path.dirname(script_dir)
+
+ # Paths to executable and Python script
+ exe_ext = ".exe" if sys.platform == "win32" else ""
+ exe_path = os.path.join(tracker_dir, f"track_progress{{exe_ext}}")
+ py_path = os.path.join(tracker_dir, "track_progress.py")
+
+ # Command-line arguments to pass through
+ args = sys.argv[1:]
+
+ # Try executable first
+ if os.path.exists(exe_path) and (sys.platform == "win32" or os.access(exe_path, os.X_OK)):
+ print("Running Progress Tracker (executable version)...")
+ try:
+ return subprocess.call([exe_path] + args)
+ except Exception as e:
+ print(f"Error running executable: {{e}}")
+ print("Falling back to Python version...")
+ else:
+ print("Executable not found, trying Python version...")
+
+ # Fall back to Python script
+ if os.path.exists(py_path):
+ print("Running Progress Tracker (Python version)...")
+ try:
+ # Add the tracker directory to the Python path
+ sys.path.insert(0, tracker_dir)
+
+ # Change to the tracker directory
+ os.chdir(tracker_dir)
+
+ # Run the Python script
+ return subprocess.call([sys.executable, py_path] + args)
+ except Exception as e:
+ print(f"Error running Python script: {{e}}")
+ return 1
+ else:
+ print("Error: Neither executable nor Python script found at:")
+ print(f"- {{exe_path}}")
+ print(f"- {{py_path}}")
+ return 1
+
+if __name__ == "__main__":
+ exit_code = main()
+ if exit_code == 0:
+ print("Progress tracking completed successfully!")
+ else:
+ print(f"Error running progress tracker! (Exit code: {{exit_code}})")
+
+ # On Windows, pause to see the output
+ if sys.platform == "win32":
+ input("Press Enter to continue...")
+
+ sys.exit(exit_code)
+"""
+
+ try:
+ with open(output_path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(content)
+ return True
+ except Exception as e:
+ print(f"Error creating Python launcher: {e}")
+ return False
+
+
+def create_shell_launcher(output_path: str) -> bool:
+ """Create a shell launcher script."""
+ content = """#!/bin/bash
+
+# Get the directory of this script
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TRACKER_DIR="$(dirname "$SCRIPT_DIR")"
+EXE_PATH="$TRACKER_DIR/track_progress"
+PY_PATH="$TRACKER_DIR/track_progress.py"
+
+# Check if executable exists and is executable
+if [ -x "$EXE_PATH" ]; then
+ echo "Running Progress Tracker (executable version)..."
+ "$EXE_PATH" "$@"
+elif [ -f "$PY_PATH" ]; then
+ echo "Executable not found, trying Python version..."
+ echo "Running Progress Tracker (Python version)..."
+ python "$PY_PATH" "$@"
+else
+ echo "Error: Neither executable nor Python script found at:"
+ echo "- $EXE_PATH"
+ echo "- $PY_PATH"
+ exit 1
+fi
+
+EXIT_CODE=$?
+if [ $EXIT_CODE -eq 0 ]; then
+ echo "Progress tracking completed successfully!"
+else
+ echo "Error running progress tracker! (Exit code: $EXIT_CODE)"
+fi
+"""
+
+ try:
+ with open(output_path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(content)
+
+ # Make the script executable on Unix-like systems
+ if sys.platform != "win32":
+ os.chmod(output_path, 0o755)
+
+ return True
+ except Exception as e:
+ print(f"Error creating shell launcher: {e}")
+ return False
+
+
+def create_batch_launcher(output_path: str) -> bool:
+ """Create a batch launcher script."""
+ content = """@echo off
+setlocal enabledelayedexpansion
+
+REM Get the directory of this script
+set "SCRIPT_DIR=%~dp0"
+set "TRACKER_DIR=%SCRIPT_DIR%.."
+set "EXE_PATH=%TRACKER_DIR%\\track_progress.exe"
+set "PY_PATH=%TRACKER_DIR%\\track_progress.py"
+
+REM Check if executable exists
+if exist "%EXE_PATH%" (
+ echo Running Progress Tracker (executable version)...
+ "%EXE_PATH%" %*
+) else (
+ echo Executable not found, trying Python version...
+ if exist "%PY_PATH%" (
+ echo Running Progress Tracker (Python version)...
+ python "%PY_PATH%" %*
+ ) else (
+ echo Error: Neither executable nor Python script found at:
+ echo - %EXE_PATH%
+ echo - %PY_PATH%
+ exit /b 1
+ )
+)
+
+if %ERRORLEVEL% EQU 0 (
+ echo Progress tracking completed successfully!
+) else (
+ echo Error running progress tracker! (Exit code: %ERRORLEVEL%)
+)
+pause
+"""
+
+ try:
+ with open(output_path, "w", encoding="utf-8", newline="\n") as f:
+ f.write(content)
+ return True
+ except Exception as e:
+ print(f"Error creating batch launcher: {e}")
+ return False
+
+
+def ensure_launcher_scripts(
+ script_dir: str,
+ dest_dir: str,
+ project_name: str,
+ verbose: bool = False,
+ force: bool = False,
+) -> List[str]:
+ """Ensure all launcher scripts exist in the script directory."""
+ created_scripts = []
+
+ # Define the scripts to check/create
+ scripts_to_ensure = [
+ ("run_progress_tracker.py", create_python_launcher),
+ ("run_progress_tracker.sh", create_shell_launcher),
+ ("run_progress_tracker.bat", create_batch_launcher),
+ ]
+
+ for script_name, create_func in scripts_to_ensure:
+ script_path = os.path.join(script_dir, script_name)
+
+ if not os.path.exists(script_path) or force:
+ if verbose:
+ print(f"Creating launcher script: {script_path}")
+
+ if create_func(script_path, dest_dir, project_name):
+ created_scripts.append(script_path)
+ if verbose:
+ print(f"Created {script_path}")
+ else:
+ print(f"Failed to create {script_path}")
+ elif verbose:
+ print(f"Launcher script already exists: {script_path}")
+
+ return created_scripts
diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat b/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat
new file mode 100644
index 0000000..6bfcef8
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat
@@ -0,0 +1,32 @@
+@echo off
+setlocal enabledelayedexpansion
+
+REM Get the directory of this script
+set "SCRIPT_DIR=%~dp0"
+set "TRACKER_DIR=%SCRIPT_DIR%.."
+set "EXE_PATH=%TRACKER_DIR%\track_progress.exe"
+set "PY_PATH=%TRACKER_DIR%\track_progress.py"
+
+REM Check if executable exists
+if exist "%EXE_PATH%" (
+ echo Running Progress Tracker (executable version)...
+ "%EXE_PATH%" %*
+) else (
+ echo Executable not found, trying Python version...
+ if exist "%PY_PATH%" (
+ echo Running Progress Tracker (Python version)...
+ python "%PY_PATH%" %*
+ ) else (
+ echo Error: Neither executable nor Python script found at:
+ echo - %EXE_PATH%
+ echo - %PY_PATH%
+ exit /b 1
+ )
+)
+
+if %ERRORLEVEL% EQU 0 (
+ echo Progress tracking completed successfully!
+) else (
+ echo Error running progress tracker! (Exit code: %ERRORLEVEL%)
+)
+pause
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.py b/advchksys/scripts/track_progress/scripts/run_progress_tracker.py
new file mode 100644
index 0000000..b96f27e
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""
+Launcher for the progress tracker.
+Tries to use the executable version first, then falls back to the Python version.
+"""
+import os
+import sys
+import subprocess
+
+
+def main():
+ """Main function to run the tracker."""
+ # Get the directory of this script
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ tracker_dir = os.path.dirname(script_dir)
+
+ # Paths to executable and Python script
+ exe_ext = ".exe" if sys.platform == "win32" else ""
+ exe_path = os.path.join(tracker_dir, f"track_progress{exe_ext}")
+ py_path = os.path.join(tracker_dir, "track_progress.py")
+
+ # Command-line arguments to pass through
+ args = sys.argv[1:]
+
+ # Try executable first
+ if os.path.exists(exe_path) and (
+ sys.platform == "win32" or os.access(exe_path, os.X_OK)
+ ):
+ print("Running Progress Tracker (executable version)...")
+ try:
+ return subprocess.call([exe_path] + args)
+ except Exception as e:
+ print(f"Error running executable: {e}")
+ print("Falling back to Python version...")
+ else:
+ print("Executable not found, trying Python version...")
+
+ # Fall back to Python script
+ if os.path.exists(py_path):
+ print("Running Progress Tracker (Python version)...")
+ try:
+ # Add the tracker directory to the Python path
+ sys.path.insert(0, tracker_dir)
+
+ # Change to the tracker directory
+ os.chdir(tracker_dir)
+
+ # Run the Python script
+ return subprocess.call([sys.executable, py_path] + args)
+ except Exception as e:
+ print(f"Error running Python script: {e}")
+ return 1
+ else:
+ print("Error: Neither executable nor Python script found at:")
+ print(f"- {exe_path}")
+ print(f"- {py_path}")
+ return 1
+
+
+if __name__ == "__main__":
+ exit_code = main()
+ if exit_code == 0:
+ print("Progress tracking completed successfully!")
+ else:
+ print(f"Error running progress tracker! (Exit code: {exit_code})")
+
+ # On Windows, pause to see the output
+ if sys.platform == "win32":
+ input("Press Enter to continue...")
+
+ sys.exit(exit_code)
diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh b/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh
new file mode 100644
index 0000000..5dc513b
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+# Get the directory of this script
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TRACKER_DIR="$(dirname "$SCRIPT_DIR")"
+EXE_PATH="$TRACKER_DIR/track_progress"
+PY_PATH="$TRACKER_DIR/track_progress.py"
+
+# Check if executable exists and is executable
+if [ -x "$EXE_PATH" ]; then
+ echo "Running Progress Tracker (executable version)..."
+ "$EXE_PATH" "$@"
+elif [ -f "$PY_PATH" ]; then
+ echo "Executable not found, trying Python version..."
+ echo "Running Progress Tracker (Python version)..."
+ python "$PY_PATH" "$@"
+else
+ echo "Error: Neither executable nor Python script found at:"
+ echo "- $EXE_PATH"
+ echo "- $PY_PATH"
+ exit 1
+fi
+
+EXIT_CODE=$?
+if [ $EXIT_CODE -eq 0 ]; then
+ echo "Progress tracking completed successfully!"
+else
+ echo "Error running progress tracker! (Exit code: $EXIT_CODE)"
+fi
diff --git a/advchksys/scripts/track_progress/scripts/setup_install.py b/advchksys/scripts/track_progress/scripts/setup_install.py
new file mode 100644
index 0000000..edbd619
--- /dev/null
+++ b/advchksys/scripts/track_progress/scripts/setup_install.py
@@ -0,0 +1,285 @@
+""" Run this to install to your projects
+ | "--dest folder" |to set destination dir
+ | "--project name" |to set project name
+ | "--verbose" or "-v"|to enable verbose output
+ | "--force" or "-f"|to force overwrite existing files
+"""
+
+import os
+import sys
+import shutil
+import argparse
+import subprocess
+from pathlib import Path
+
+# Try to import our script creation module
+try:
+ from create_launcher_scripts import ensure_launcher_scripts
+except ImportError:
+ # If we can't import it, we'll define a simple version here
+ def ensure_launcher_scripts(
+ script_dir, dest_dir, project_name, verbose=False, force=False
+ ):
+ """Fallback function if the module can't be imported."""
+ print(
+ "Warning: create_launcher_scripts module not found. Using fallback."
+ )
+ return []
+
+
+def main():
+ """Main installation function."""
+ parser = argparse.ArgumentParser(
+ description="Install the progress tracking system."
+ )
+ parser.add_argument(
+ "--dest", default="scripts", help="Destination directory"
+ )
+ parser.add_argument("--project", default="Project", help="Project name")
+ parser.add_argument(
+ "--verbose", "-v", action="store_true", help="Enable verbose output"
+ )
+ parser.add_argument(
+ "--force",
+ "-f",
+ action="store_true",
+ help="Force overwrite existing files",
+ )
+ args = parser.parse_args()
+
+ verbose = args.verbose
+ force = args.force
+
+ if verbose:
+ print(
+ f"Installing progress tracking system to {args.dest} for project {args.project}"
+ )
+
+ # Create destination directories
+ os.makedirs(
+ os.path.join(args.dest, "track_progress", "templates"), exist_ok=True
+ )
+ os.makedirs("docs/status", exist_ok=True)
+
+ # Get the directory of this script
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ parent_dir = os.path.dirname(script_dir) # scripts/track_progress
+
+ # Look for compiled executable
+ exe_ext = ".exe" if sys.platform == "win32" else ""
+ compiled_exe = os.path.join(parent_dir, f"track_progress{exe_ext}")
+ dist_exe = os.path.join(parent_dir, "dist", f"track_progress{exe_ext}")
+
+ # Check if we have the executable in either location
+ if not os.path.exists(compiled_exe) and not os.path.exists(dist_exe):
+ # Try to build it
+ if verbose:
+ print(f"Compiled executable not found, trying to build it...")
+
+ build_script = os.path.join(script_dir, "build_with_nuitka.py")
+ if os.path.exists(build_script):
+ build_cmd = [sys.executable, build_script]
+ if verbose:
+ build_cmd.append("--verbose")
+
+ try:
+ subprocess.check_call(build_cmd)
+ # The executable should now be in the dist directory
+
+ # Move the executable from dist to parent directory
+ if os.path.exists(dist_exe):
+ if os.path.exists(compiled_exe):
+ os.remove(compiled_exe)
+ shutil.move(dist_exe, compiled_exe)
+ if verbose:
+ print(
+ f"Moved executable from {dist_exe} to {compiled_exe}"
+ )
+ except subprocess.CalledProcessError:
+ print(
+ "Failed to build the executable. Falling back to Python files."
+ )
+ else:
+ print("Build script not found. Falling back to Python files.")
+
+ # Check if we have the executable now
+ have_executable = os.path.exists(compiled_exe)
+
+ if have_executable:
+ # Copy the compiled executable
+ dst_exe = os.path.join(
+ args.dest, "track_progress", f"track_progress{exe_ext}"
+ )
+ if not os.path.exists(dst_exe) or force:
+ shutil.copy2(compiled_exe, dst_exe)
+ if verbose:
+ print(
+ f"Copied compiled executable from {compiled_exe} to {dst_exe}"
+ )
+ elif verbose:
+ print(f"Skipping existing executable: {dst_exe}")
+ else:
+ # Copy individual Python files
+ python_files = [
+ "track_progress.py",
+ "config.py",
+ "git_analyzer.py",
+ "code_stats.py",
+ "feature_markdown.py",
+ "template_engine.py",
+ "changelog_generator.py",
+ "roadmap_generator.py",
+ "__init__.py", # Add this to make it a proper package
+ "README.md",
+ ]
+
+ for file in python_files:
+ src = os.path.join(parent_dir, file)
+ dst = os.path.join(args.dest, "track_progress", file)
+
+ # Skip existing files unless force is specified
+ if os.path.exists(dst) and not force:
+ if verbose:
+ print(f"Skipping existing file: {dst}")
+ continue
+
+ if os.path.exists(src):
+ shutil.copy2(src, dst)
+ if verbose:
+ print(f"Copied {file} to {dst}")
+ elif file == "__init__.py":
+ # Create empty __init__.py if it doesn't exist
+ with open(dst, "w", encoding="utf-8") as f:
+ f.write(
+ "# This file makes the directory a Python package\n"
+ )
+ if verbose:
+ print(f"Created {dst}")
+
+ # Copy templates (always copy templates, they should remain exposed)
+ template_files = [
+ "status_template.md",
+ "feature_template.md",
+ "changelog_template.md",
+ ]
+
+ for file in template_files:
+ src = os.path.join(parent_dir, "templates", file)
+ dst = os.path.join(args.dest, "track_progress", "templates", file)
+
+ # Skip existing files unless force is specified
+ if os.path.exists(dst) and not force:
+ if verbose:
+ print(f"Skipping existing template: {dst}")
+ continue
+
+ if os.path.exists(src):
+ shutil.copy2(src, dst)
+ if verbose:
+ print(f"Copied template {file} to {dst}")
+
+ # Copy and customize configuration
+ config_src = os.path.join(parent_dir, "progress_config.json")
+ config_dst = os.path.join(
+ args.dest, "track_progress", "progress_config.json"
+ )
+
+ # Skip existing config unless force is specified
+ if not os.path.exists(config_dst) or force:
+ if os.path.exists(config_src):
+ # Read the config file
+ with open(config_src, "r", encoding="utf-8") as f:
+ config_content = f.read()
+
+ # Replace project name
+ config_content = config_content.replace(
+ '"project_name": "AdvChkSys"',
+ f'"project_name": "{args.project}"',
+ )
+
+ # Ensure paths are relative to the project root
+ config_content = config_content.replace(
+ '"features_file": "docs/features.md"',
+ '"features_file": "docs/features.md"',
+ )
+ config_content = config_content.replace(
+ '"output_dir": "docs/status"',
+ '"output_dir": "docs/status"',
+ )
+ config_content = config_content.replace(
+ '"changelog_file": "CHANGELOG.md"',
+ '"changelog_file": "CHANGELOG.md"',
+ )
+
+ # Write the customized config
+ with open(config_dst, "w", encoding="utf-8") as f:
+ f.write(config_content)
+
+ if verbose:
+ print(f"Created customized configuration at {config_dst}")
+ elif verbose:
+ print(f"Skipping existing configuration: {config_dst}")
+
+ # Ensure launcher scripts exist in the scripts directory
+ created_scripts = ensure_launcher_scripts(
+ script_dir, args.dest, args.project, verbose, force
+ )
+ if created_scripts and verbose:
+ print(
+ f"Created {len(created_scripts)} launcher scripts in {script_dir}"
+ )
+
+ # Create an empty CHANGELOG.md if it doesn't exist
+ if not os.path.exists("CHANGELOG.md"):
+ with open("CHANGELOG.md", "w", encoding="utf-8") as f:
+ f.write("# Changelog\n\n")
+ if verbose:
+ print("Created empty CHANGELOG.md")
+
+ # Copy the scripts directory to the destination if it doesn't exist
+ scripts_dst = os.path.join(args.dest, "track_progress", "scripts")
+ if not os.path.exists(scripts_dst) or force:
+ os.makedirs(scripts_dst, exist_ok=True)
+
+ # Copy script files
+ script_files = [
+ "build_with_nuitka.py",
+ "create_launcher_scripts.py",
+ "setup_install.py",
+ ]
+
+ for file in script_files:
+ src = os.path.join(script_dir, file)
+ dst = os.path.join(scripts_dst, file)
+
+ if os.path.exists(src) and (not os.path.exists(dst) or force):
+ shutil.copy2(src, dst)
+ if verbose:
+ print(f"Copied script {file} to {dst}")
+
+ print("\nInstallation complete!")
+ if have_executable:
+ print(
+ f"Compiled tracker installed to: {os.path.join(args.dest, 'track_progress')}"
+ )
+ else:
+ print(
+ f"Python tracker installed to: {os.path.join(args.dest, 'track_progress')}"
+ )
+
+ # Check which launcher scripts exist
+ py_launcher = os.path.join(script_dir, "run_progress_tracker.py")
+ sh_launcher = os.path.join(script_dir, "run_progress_tracker.sh")
+ bat_launcher = os.path.join(script_dir, "run_progress_tracker.bat")
+
+ print("\nTo run the tracker, use one of these commands:")
+ if os.path.exists(py_launcher):
+ print(f"- Python: python {py_launcher}")
+ if os.path.exists(sh_launcher) and sys.platform != "win32":
+ print(f"- Shell: {sh_launcher}")
+ if os.path.exists(bat_launcher) and sys.platform == "win32":
+ print(f"- Batch: {bat_launcher}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/advchksys/scripts/track_progress/template_engine.py b/advchksys/scripts/track_progress/template_engine.py
new file mode 100644
index 0000000..b83849a
--- /dev/null
+++ b/advchksys/scripts/track_progress/template_engine.py
@@ -0,0 +1,239 @@
+"""
+Template engine for generating Markdown documentation from templates.
+"""
+
+import os
+import re
+import sys
+from typing import Dict, Any, List, Optional
+
+
+class TemplateEngine:
+ """Simple template engine for generating Markdown files."""
+
+ def __init__(
+ self, templates_dir: str = "scripts/track_progress/templates"
+ ):
+ """Initialize the template engine with the templates directory."""
+ self.templates_dir = templates_dir
+
+ def _load_template(self, template_name: str) -> str:
+ """Load a template file."""
+ # Try multiple possible template locations
+ possible_paths = [
+ # Original path
+ os.path.join(self.templates_dir, template_name),
+ # Path relative to current working directory
+ os.path.join("templates", template_name),
+ # Path relative to executable directory
+ os.path.join(
+ os.path.dirname(sys.executable), "templates", template_name
+ ),
+ # Path relative to script directory
+ os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "templates",
+ template_name,
+ ),
+ # Path with 'scripts/track_progress' removed (for compiled version)
+ template_name.replace("scripts/track_progress/", ""),
+ # Just the filename
+ template_name,
+ ]
+
+ # Try each path
+ for template_path in possible_paths:
+ if os.path.exists(template_path):
+ with open(template_path, "r", encoding="utf-8") as f:
+ return f.read()
+
+ # If we get here, none of the paths worked
+ error_message = f"Template not found: {template_name}\nTried paths: {possible_paths}"
+ raise FileNotFoundError(error_message)
+
+ def _replace_variables(
+ self, template: str, context: Dict[str, Any]
+ ) -> str:
+ """Replace {{ variable }} with values from context."""
+
+ def replace_var(match):
+ var_name = match.group(1).strip()
+
+ # Handle nested variables with dot notation
+ if "." in var_name:
+ parts = var_name.split(".")
+ value = context
+ for part in parts:
+ if isinstance(value, dict) and part in value:
+ value = value[part]
+ else:
+ return match.group(0) # Return original if not found
+ return str(value) if value is not None else ""
+
+ # Handle simple variables
+ if var_name in context:
+ return (
+ str(context[var_name])
+ if context[var_name] is not None
+ else ""
+ )
+
+ return match.group(0) # Return original if not found
+
+ # Replace {{ variable }}
+ pattern = r"{{(.*?)}}"
+ return re.sub(pattern, replace_var, template)
+
+ def _process_conditionals(
+ self, template: str, context: Dict[str, Any]
+ ) -> str:
+ """Process {% if condition %} ... {% endif %} blocks."""
+
+ def replace_conditional(match):
+ condition_var = match.group(1).strip()
+ content = match.group(2)
+
+ # Handle nested conditions with dot notation
+ if "." in condition_var:
+ parts = condition_var.split(".")
+ value = context
+ for part in parts:
+ if isinstance(value, dict) and part in value:
+ value = value[part]
+ else:
+ return "" # Condition not met
+
+ if value:
+ return content
+ return ""
+
+ # Handle simple conditions
+ if condition_var in context and context[condition_var]:
+ return content
+ return ""
+
+ # Process {% if condition %} ... {% endif %}
+ pattern = r"{%\s*if\s+(.*?)\s*%}(.*?){%\s*endif\s*%}"
+ return re.sub(pattern, replace_conditional, template, flags=re.DOTALL)
+
+ def _process_loops(self, template: str, context: Dict[str, Any]) -> str:
+ """Process {% for item_var, item in items.items() %} ... {% endfor %} blocks."""
+
+ # First, process dictionary iteration with items()
+ def replace_dict_loop(match):
+ item_key_var = match.group(1).strip()
+ item_val_var = match.group(2).strip()
+ collection_var = match.group(3).strip()
+ content_template = match.group(4)
+
+ # Handle nested collections with dot notation
+ if "." in collection_var:
+ parts = collection_var.split(".")
+ collection = context
+ for part in parts:
+ if isinstance(collection, dict) and part in collection:
+ collection = collection[part]
+ else:
+ return "" # Collection not found
+ else:
+ collection = context.get(collection_var, {})
+
+ if not isinstance(collection, dict):
+ return "" # Not a dictionary
+
+ result = []
+ for key, value in collection.items():
+ # Create a new context for each iteration
+ loop_context = context.copy()
+ loop_context[item_key_var] = key
+ loop_context[item_val_var] = value
+
+ # Process the content template with the loop context
+ item_content = self._replace_variables(
+ content_template, loop_context
+ )
+ item_content = self._process_conditionals(
+ item_content, loop_context
+ )
+ result.append(item_content)
+
+ return "".join(result)
+
+ # Process {% for key, value in dict.items() %} ... {% endfor %}
+ dict_pattern = r"{%\s*for\s+(.*?),\s*(.*?)\s+in\s+(.*?)\.items\(\)\s*%}(.*?){%\s*endfor\s*%}"
+ template = re.sub(
+ dict_pattern, replace_dict_loop, template, flags=re.DOTALL
+ )
+
+ # Then, process regular list iteration
+ def replace_list_loop(match):
+ item_var = match.group(1).strip()
+ collection_var = match.group(2).strip()
+ content_template = match.group(3)
+
+ # Handle nested collections with dot notation
+ if "." in collection_var:
+ parts = collection_var.split(".")
+ collection = context
+ for part in parts:
+ if isinstance(collection, dict) and part in collection:
+ collection = collection[part]
+ else:
+ return "" # Collection not found
+ else:
+ collection = context.get(collection_var, [])
+
+ if not isinstance(collection, (list, dict)):
+ return "" # Not a collection
+
+ result = []
+ if isinstance(collection, dict):
+ # If it's a dict, iterate over keys
+ for key in collection:
+ # Create a new context for each iteration
+ loop_context = context.copy()
+ loop_context[item_var] = key
+
+ # Process the content template with the loop context
+ item_content = self._replace_variables(
+ content_template, loop_context
+ )
+ item_content = self._process_conditionals(
+ item_content, loop_context
+ )
+ result.append(item_content)
+ else: # list
+ for item in collection:
+ # Create a new context for each iteration
+ loop_context = context.copy()
+ loop_context[item_var] = item
+
+ # Process the content template with the loop context
+ item_content = self._replace_variables(
+ content_template, loop_context
+ )
+ item_content = self._process_conditionals(
+ item_content, loop_context
+ )
+ result.append(item_content)
+
+ return "".join(result)
+
+ # Process {% for item in items %} ... {% endfor %}
+ list_pattern = (
+ r"{%\s*for\s+(.*?)\s+in\s+(.*?)\s*%}(.*?){%\s*endfor\s*%}"
+ )
+ return re.sub(
+ list_pattern, replace_list_loop, template, flags=re.DOTALL
+ )
+
+ def render(self, template_name: str, context: Dict[str, Any]) -> str:
+ """Render a template with the given context."""
+ template = self._load_template(template_name)
+
+ # Process template directives in order
+ template = self._process_loops(template, context)
+ template = self._process_conditionals(template, context)
+ template = self._replace_variables(template, context)
+
+ return template
diff --git a/advchksys/scripts/track_progress/templates/changelog_template.md b/advchksys/scripts/track_progress/templates/changelog_template.md
new file mode 100644
index 0000000..ce46bc3
--- /dev/null
+++ b/advchksys/scripts/track_progress/templates/changelog_template.md
@@ -0,0 +1,7 @@
+# {{ project_name }} Changelog
+
+Last updated: {{ current_date }}
+
+{% for entry in changelog_entries %}
+{{ entry }}
+{% endfor %}
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/templates/feature_template.md b/advchksys/scripts/track_progress/templates/feature_template.md
new file mode 100644
index 0000000..31ada23
--- /dev/null
+++ b/advchksys/scripts/track_progress/templates/feature_template.md
@@ -0,0 +1,92 @@
+# {{ title }}
+
+## Overview
+
+This document tracks the implementation status of all features in the {{ project_name }} project.
+
+## Feature Status Summary
+
+| Status | Description |
+|--------|-------------|
+| ✅ Completed | Features that are fully implemented and tested |
+| 🔄 In Progress | Features currently being implemented |
+| 📝 Planned | Features planned for future implementation |
+
+## Features
+
+{% for feature_key, feature in features.items() %}
+### {{ feature_key }}
+
+**Status:** {{ feature.status_emoji }} {{ feature.status }}
+**Description:** {{ feature.description }}
+**Last Update:** {{ feature.date }}
+{% if feature.author %}**Owner:** {{ feature.author }}{% endif %}
+{% if feature.details %}**Details:** {{ feature.details }}{% endif %}
+
+{% endfor %}
+
+## Feature Categories
+
+### Completed Features
+
+{% for feature_key, feature in features.items() %}
+{% if feature.status == 'completed' %}
+- **{{ feature_key }}**: {{ feature.description }}
+{% endif %}
+{% endfor %}
+
+### In Progress Features
+
+{% for feature_key, feature in features.items() %}
+{% if feature.status == 'in_progress' %}
+- **{{ feature_key }}**: {{ feature.description }}
+{% endif %}
+{% endfor %}
+
+### Planned Features
+
+{% for feature_key, feature in features.items() %}
+{% if feature.status == 'planned' %}
+- **{{ feature_key }}**: {{ feature.description }}
+{% endif %}
+{% endfor %}
+
+
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/templates/status_template.md b/advchksys/scripts/track_progress/templates/status_template.md
new file mode 100644
index 0000000..5d8f1d3
--- /dev/null
+++ b/advchksys/scripts/track_progress/templates/status_template.md
@@ -0,0 +1,57 @@
+# {{ project_name }} Development Status
+
+Last updated: {{ current_date }}
+
+## Code Statistics
+
+{% if stats %}
+Total lines of code: **{{ stats.total_lines }}**
+
+Number of source files: **{{ stats.file_count }}**
+
+### Files by Language
+{% for lang, count in stats.languages.items() %}
+- **{{ lang }}**: {{ count }} files
+{% endfor %}
+
+### Top Files by Line Count
+
+| File | Lines | Language | Path |
+|------|------:|----------|------|
+{% for file in stats.top_files %}
+| {{ file.name }} | {{ file.lines }} | {{ file.language }} | {{ file.path }} |
+{% endfor %}
+{% endif %}
+
+## Feature Status
+
+| Feature | Status | Description | Last Update | Owner |
+|---------|--------|-------------|-------------|-------|
+{% for feature_key, feature in features.items() %}
+| {{ feature.display_name }} | {{ feature.status_emoji }} {{ feature.status }} | {{ feature.description }} | {{ feature.date }} | {{ feature.author }} |
+{% endfor %}
+
+## Recent Updates
+
+{% for entry in changelog_entries %}
+{{ entry }}
+{% endfor %}
+
+{% if known_issues %}
+## Known Issues
+
+{% for issue in known_issues %}
+- {{ issue.description }} {% if issue.status %}({{ issue.status }}){% endif %}
+{% endfor %}
+{% endif %}
+
+{% if roadmap %}
+## Roadmap
+
+{% for milestone in roadmap %}
+### {{ milestone.name }} ({{ milestone.target_date }})
+{% for item in milestone.items %}
+- {{ item.description }} {% if item.status %}[{{ item.status }}]{% endif %}
+{% endfor %}
+{% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/advchksys/scripts/track_progress/track_progress.py b/advchksys/scripts/track_progress/track_progress.py
new file mode 100644
index 0000000..55deeea
--- /dev/null
+++ b/advchksys/scripts/track_progress/track_progress.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+"""
+Project progress tracker that generates status documentation from Git history.
+"""
+import os
+import sys
+import argparse
+from datetime import datetime
+from typing import Dict, Any, List, Optional
+
+# Add the current directory to the Python path
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+# Import our modules
+from config import Config
+from code_stats import analyze_code_stats
+from git_analyzer import GitAnalyzer
+from feature_markdown import (
+ enrich_features,
+ emoji_status,
+ extract_features,
+ generate_features_md,
+)
+from template_engine import TemplateEngine
+from changelog_generator import update_changelog_file
+from roadmap_generator import generate_roadmap
+
+
+def load_features(features_file: str) -> Dict[str, Dict[str, Any]]:
+ """Load features from a Markdown file."""
+ # Use the improved extraction function
+ return extract_features(features_file)
+
+
+def update_features(
+ features: Dict[str, Dict[str, Any]],
+ feature_updates: Dict[str, Dict[str, Any]],
+ new_features: Dict[str, Dict[str, Any]],
+) -> Dict[str, Dict[str, Any]]:
+ """Update features with new information from Git history."""
+ updated_features = features.copy()
+
+ # Update existing features
+ for feature_key, update in feature_updates.items():
+ if feature_key in updated_features:
+ updated_features[feature_key].update(update)
+
+ # Add new features
+ for feature_key, feature_data in new_features.items():
+ if feature_key not in updated_features:
+ updated_features[feature_key] = feature_data
+
+ return updated_features
+
+
+def format_changelog_entries(git_data: Dict[str, Any]) -> List[str]:
+ """Format changelog entries from Git data."""
+ entries = []
+
+ # Add changelog entries
+ for entry in git_data.get("changelog_entries", []):
+ entries.append(
+ f"- {entry['date']}: {entry['message']} ({entry['commit']})"
+ )
+
+ # Add fix entries
+ for fix in git_data.get("fixes", []):
+ entries.append(
+ f"- {fix['date']}: Fixed: {fix['message']} ({fix['commit']})"
+ )
+
+ # Add untagged commits
+ for commit in git_data.get("untagged_commits", []):
+ entries.append(
+ f"- {commit['date']}: {commit['message']} ({commit['commit']})"
+ )
+
+ return entries
+
+
+def extract_known_issues(git_data: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Extract known issues from Git data."""
+ issues = []
+
+ # Look for issues in the Git data
+ for issue in git_data.get("issues", []):
+ issues.append(
+ {"description": issue.get("message", ""), "status": "open"}
+ )
+
+ return issues
+
+
+def enrich_features(
+ features: Dict[str, Dict[str, Any]]
+) -> Dict[str, Dict[str, Any]]:
+ """Add additional information to features for display purposes."""
+ enriched = {}
+
+ for key, data in features.items():
+ enriched[key] = data.copy()
+ status = data.get("status", "unknown")
+ enriched[key]["status_emoji"] = emoji_status(status)
+
+ # Add title-cased key for display
+ enriched[key]["display_name"] = key.replace("_", " ").title()
+
+ return enriched
+
+
+def main():
+ """Main function to run the progress tracker."""
+ parser = argparse.ArgumentParser(
+ description="Track project progress and generate status documentation."
+ )
+ parser.add_argument(
+ "--config",
+ default="scripts/track_progress/progress_config.json",
+ help="Path to configuration file",
+ )
+ parser.add_argument("--project", help="Project name (overrides config)")
+ parser.add_argument(
+ "--output-dir", help="Output directory (overrides config)"
+ )
+ parser.add_argument("--repo", default=".", help="Repository path")
+ parser.add_argument(
+ "--verbose", "-v", action="store_true", help="Enable verbose output"
+ )
+ args = parser.parse_args()
+
+ verbose = args.verbose
+
+ # Load configuration
+ config = Config(args.config)
+
+ # Override config with command-line arguments
+ if args.project:
+ config.set("project_name", args.project)
+ if args.output_dir:
+ config.set("output_dir", args.output_dir)
+
+ # Ensure output directory exists
+ output_dir = config.get("output_dir")
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Initialize components
+ git_analyzer = GitAnalyzer(args.repo)
+ template_engine = TemplateEngine()
+
+ # Get repository information
+ repo_info = git_analyzer.get_repo_info()
+
+ # Analyze Git history
+ git_data = git_analyzer.analyze_commits(
+ limit=config.get("git_log_limit"),
+ untagged_limit=config.get("untagged_commit_limit"),
+ )
+
+ # Analyze code statistics
+ code_stats = analyze_code_stats(
+ root_dir=args.repo,
+ exclude_dirs=config.get("exclude_dirs"),
+ source_extensions=config.get("source_extensions"),
+ top_files_limit=config.get("top_files_limit"),
+ )
+
+ # Load existing features
+ features_file = config.get("features_file")
+ if not os.path.isabs(features_file):
+ # If it's not an absolute path, make it relative to the repo root, not the output dir
+ features_file = os.path.join(args.repo, features_file)
+
+ if verbose:
+ print(f"Loading features from: {features_file}")
+
+ features = load_features(features_file)
+
+ if verbose:
+ print(f"Loaded {len(features)} features")
+
+ # Update features with Git data
+ features = update_features(
+ features,
+ git_data.get("feature_updates", {}),
+ git_data.get("new_features", {}),
+ )
+
+ if verbose:
+ print(f"Updated features count: {len(features)}")
+
+ # Enrich features with emojis and other display information
+ enriched_features = enrich_features(features)
+
+ # Format changelog entries
+ changelog_entries = format_changelog_entries(git_data)
+
+ # Extract known issues
+ known_issues = extract_known_issues(git_data)
+
+ # Generate roadmap
+ roadmap = generate_roadmap(
+ git_data.get("milestones", {}), git_data.get("roadmap_items", {})
+ )
+
+ # Prepare template context
+ context = {
+ "project_name": config.get("project_name"),
+ "current_date": datetime.now().strftime("%Y-%m-%d"),
+ "repo_info": repo_info,
+ "stats": code_stats,
+ "features": enriched_features,
+ "changelog_entries": changelog_entries,
+ "known_issues": known_issues,
+ "roadmap": roadmap,
+ }
+
+ # Generate status document
+ status_template = config.get("templates", {}).get(
+ "status", "status_template.md"
+ )
+ status_content = template_engine.render(status_template, context)
+
+ status_file = os.path.join(args.repo, output_dir, config.get("status_doc"))
+ os.makedirs(os.path.dirname(status_file), exist_ok=True)
+ with open(status_file, "w", encoding="utf-8") as f:
+ f.write(status_content)
+
+ # Generate features document using the improved function
+ features_content = generate_features_md(
+ features, config.get("project_name")
+ )
+
+ # Ensure directory exists for features file
+ os.makedirs(os.path.dirname(features_file), exist_ok=True)
+
+ # Create backup of original features file
+ if os.path.exists(features_file):
+ backup_file = (
+ f"{features_file}.bak.{datetime.now().strftime('%Y%m%d%H%M%S')}"
+ )
+ with open(features_file, "r", encoding="utf-8") as src:
+ with open(backup_file, "w", encoding="utf-8") as dst:
+ dst.write(src.read())
+ if verbose:
+ print(f"Created backup: {backup_file}")
+
+ # Write the new features file
+ with open(features_file, "w", encoding="utf-8") as f:
+ f.write(features_content)
+
+ # Update changelog
+ changelog_file = config.get(
+ "changelog_file", "CHANGELOG.md"
+ ) # Default to CHANGELOG.md
+
+ if not os.path.isabs(changelog_file):
+ # If it's a relative path, make it relative to the repo root, not the output dir
+ changelog_file = os.path.join(args.repo, changelog_file)
+
+ if verbose:
+ print(f"Updating changelog file: {changelog_file}")
+
+ # Ensure the directory exists
+ changelog_dir = os.path.dirname(changelog_file)
+ if changelog_dir: # Only create directory if there is one
+ os.makedirs(changelog_dir, exist_ok=True)
+
+ update_changelog_file(
+ git_data.get("changelog_entries", []),
+ changelog_file,
+ max_entries=config.get("git_log_limit"),
+ verbose=verbose,
+ )
+
+ print(f"Progress tracking updated:")
+ print(f"- Status document: {status_file}")
+ print(f"- Features document: {features_file}")
+ print(f"- Changelog: {changelog_file}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj b/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj
new file mode 100644
index 0000000..bca0de2
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ ChunkMark
+ ChunkMark
+
+
+
+
+
+
+
+ true
+ true
+ win-x64
+ true
+ false
+ true
+
+
+
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs b/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs
new file mode 100644
index 0000000..f09c5c3
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs
@@ -0,0 +1,952 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Text;
+using AdvChkSys;
+using AdvChkSys.Chunk;
+using AdvChkSys.Manager;
+using AdvChkSys.Constraints;
+using AdvChkSys.Diagnostics;
+
+namespace ChunkMark
+{
+ public static class Program
+ {
+ private static readonly ConsoleColor _headerColor = ConsoleColor.Cyan;
+ private static readonly ConsoleColor _resultColor = ConsoleColor.Green;
+ private static readonly ConsoleColor _memoryColor = ConsoleColor.Yellow;
+ private static readonly ConsoleColor _errorColor = ConsoleColor.Red;
+ private static readonly ConsoleColor _infoColor = ConsoleColor.Gray;
+
+ private static List _results = new List();
+ private static bool _verboseMode = false;
+
+ public static void Main(string[] args)
+ {
+ int cpuCount = Environment.ProcessorCount;
+ int num2DChunks = 10000;
+ int chunk2DSize = 32;
+ int num3DChunks = 500;
+ int chunk3DWidth = 16, chunk3DHeight = 16, chunk3DDepth = 16;
+ int maxLoaded = 100000;
+ bool run2D = true, run3D = true;
+ bool runMemoryTest = false;
+ bool runStressTest = false;
+
+ // Parse args
+ foreach (var arg in args)
+ {
+ if (arg.StartsWith("--cpus=")) cpuCount = int.Parse(arg.Substring(7));
+ if (arg.StartsWith("--2d-chunks=")) num2DChunks = int.Parse(arg.Substring(12));
+ if (arg.StartsWith("--2d-size=")) chunk2DSize = int.Parse(arg.Substring(10));
+ if (arg.StartsWith("--3d-chunks=")) num3DChunks = int.Parse(arg.Substring(12));
+ if (arg.StartsWith("--3d-width=")) chunk3DWidth = int.Parse(arg.Substring(11));
+ if (arg.StartsWith("--3d-height=")) chunk3DHeight = int.Parse(arg.Substring(12));
+ if (arg.StartsWith("--3d-depth=")) chunk3DDepth = int.Parse(arg.Substring(11));
+ if (arg.StartsWith("--3d-size="))
+ {
+ int val = int.Parse(arg.Substring(10));
+ chunk3DWidth = chunk3DHeight = chunk3DDepth = val;
+ }
+ if (arg.StartsWith("--max-loaded=")) maxLoaded = int.Parse(arg.Substring(13));
+ if (arg == "--2d-only") { run3D = false; }
+ if (arg == "--3d-only") { run2D = false; }
+ if (arg == "--memory-test") { runMemoryTest = true; }
+ if (arg == "--stress-test") { runStressTest = true; }
+ if (arg == "--verbose") { _verboseMode = true; }
+ }
+
+ PrintHeader();
+
+ if (run2D)
+ Benchmark2D(cpuCount, num2DChunks, chunk2DSize, maxLoaded);
+
+ if (run3D)
+ {
+ while (true)
+ {
+ if (!CanAllocateChunks(num3DChunks, chunk3DWidth, chunk3DHeight, chunk3DDepth, 0.82))
+ {
+ long bytesPerChunk = (long)chunk3DWidth * chunk3DHeight * chunk3DDepth;
+ long totalBytes = bytesPerChunk * num3DChunks;
+ long available = GetAvailableMemoryBytes();
+ long safeLimit = (long)(available * 0.82);
+
+ Console.ForegroundColor = _errorColor;
+ Console.WriteLine($"ERROR: Requested 3D chunks would use {totalBytes / (1024 * 1024):N0} MB, but only {safeLimit / (1024 * 1024):N0} MB is safely available.");
+ Console.WriteLine("Please input new values for 3D chunks and size within your system's memory capacity.");
+ Console.ResetColor();
+
+ Console.ForegroundColor = _infoColor; // Using _infoColor here
+ Console.Write("Enter number of 3D chunks (or blank to exit): ");
+ Console.ResetColor();
+
+ var chunksInput = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(chunksInput))
+ {
+ Console.WriteLine("Exiting benchmark.");
+ break;
+ }
+ if (!int.TryParse(chunksInput, out num3DChunks) || num3DChunks <= 0)
+ {
+ Console.WriteLine("Invalid input. Exiting.");
+ break;
+ }
+
+ Console.ForegroundColor = _infoColor; // Using _infoColor here
+ Console.Write("Enter 3D chunk width: ");
+ Console.ResetColor();
+
+ var widthInput = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(widthInput) || !int.TryParse(widthInput, out chunk3DWidth) || chunk3DWidth <= 0)
+ {
+ Console.WriteLine("Invalid input. Exiting.");
+ break;
+ }
+
+ Console.ForegroundColor = _infoColor; // Using _infoColor here
+ Console.Write("Enter 3D chunk height: ");
+ Console.ResetColor();
+
+ var heightInput = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(heightInput) || !int.TryParse(heightInput, out chunk3DHeight) || chunk3DHeight <= 0)
+ {
+ Console.WriteLine("Invalid input. Exiting.");
+ break;
+ }
+
+ Console.ForegroundColor = _infoColor; // Using _infoColor here
+ Console.Write("Enter 3D chunk depth: ");
+ Console.ResetColor();
+
+ var depthInput = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(depthInput) || !int.TryParse(depthInput, out chunk3DDepth) || chunk3DDepth <= 0)
+ {
+ Console.WriteLine("Invalid input. Exiting.");
+ break;
+ }
+ Console.WriteLine();
+ // Loop will re-check with new values
+ }
+ else
+ {
+ try
+ {
+ Benchmark3D(cpuCount, num3DChunks, chunk3DWidth, chunk3DHeight, chunk3DDepth, maxLoaded);
+ }
+ catch (OutOfMemoryException)
+ {
+ Console.ForegroundColor = _errorColor;
+ Console.WriteLine("ERROR: Out of memory during 3D chunk allocation. Reduce chunk size or count.");
+ Console.ResetColor();
+ }
+ break;
+ }
+ }
+ }
+
+ if (runMemoryTest)
+ {
+ RunMemoryTest(cpuCount, chunk2DSize, chunk3DWidth, chunk3DHeight, chunk3DDepth);
+ }
+
+ if (runStressTest)
+ {
+ RunStressTest(cpuCount, chunk2DSize, chunk3DWidth, chunk3DHeight, chunk3DDepth);
+ }
+
+ PrintSummary();
+ PrintDetailedChunkInfo();
+ // Save benchmark results to log file
+ SaveBenchmarkLog();
+
+ Console.ForegroundColor = _infoColor; // Using _infoColor here
+ Console.WriteLine("\nDone. Press any key to exit.");
+ Console.ResetColor();
+ Console.ReadKey();
+ }
+
+ private static void PrintHeader()
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗");
+ Console.WriteLine("║ ChunkMark: AdvChkSys Benchmark ║");
+ Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+
+ Console.WriteLine($"Library version: {AdvChkSys.AdvChkSys.Version}");
+ Console.WriteLine($"CPU threads: {Environment.ProcessorCount}");
+ Console.WriteLine($"System memory: {FormatByteSize(AdvChkSys.AdvChkSys.GetMemoryUsage().TotalSystemMemoryBytes)}");
+ Console.WriteLine($"Available memory: {FormatByteSize(AdvChkSys.AdvChkSys.GetMemoryUsage().AvailableSystemMemoryBytes)}");
+ Console.WriteLine();
+ }
+
+ private static void PrintSummary()
+ {
+ Console.WriteLine();
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗");
+ Console.WriteLine("║ Benchmark Summary ║");
+ Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+
+ if (_results.Count == 0)
+ {
+ Console.WriteLine("No benchmark results to display.");
+ return;
+ }
+
+ // Standard benchmark results table
+ var table = new StringBuilder();
+ table.AppendLine("┌────────────────┬─────────────┬────────────┬────────────┬────────────┬────────────┐");
+ table.AppendLine("│ Test │ Chunks │ Create │ Access │ Unload │ Memory │");
+ table.AppendLine("├────────────────┼─────────────┼────────────┼────────────┼────────────┼────────────┤");
+
+ foreach (var result in _results)
+ {
+ string testName = result.TestName.PadRight(14).Substring(0, 14);
+ string chunkInfo = result.ChunkCount.ToString("N0").PadRight(11).Substring(0, 11);
+ string createTime = $"{result.CreateTime:F3} s".PadRight(10).Substring(0, 10);
+ string accessTime = $"{result.AccessTime:F3} s".PadRight(10).Substring(0, 10);
+ string unloadTime = $"{result.UnloadTime:F3} s".PadRight(10).Substring(0, 10);
+ string memoryUsed = FormatByteSize(result.MemoryUsed).PadRight(10).Substring(0, 10);
+
+ table.AppendLine($"│ {testName} │ {chunkInfo} │ {createTime} │ {accessTime} │ {unloadTime} │ {memoryUsed} │");
+ }
+
+ table.AppendLine("└────────────────┴─────────────┴────────────┴────────────┴────────────┴────────────┘");
+ Console.WriteLine(table.ToString());
+
+ // Calculate and display performance metrics
+ if (_results.Count > 0)
+ {
+ Console.ForegroundColor = _resultColor;
+ Console.WriteLine("Performance Metrics:");
+ Console.ResetColor();
+
+ var create2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault();
+ var create3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault();
+ var access2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault();
+ var access3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault();
+
+ if (create2DRate > 0)
+ Console.WriteLine($"2D Chunk Creation Rate: {create2DRate:N0} chunks/second");
+ if (create3DRate > 0)
+ Console.WriteLine($"3D Chunk Creation Rate: {create3DRate:N0} chunks/second");
+ if (access2DRate > 0)
+ Console.WriteLine($"2D Chunk Access Rate: {access2DRate:N0} chunks/second");
+ if (access3DRate > 0)
+ Console.WriteLine($"3D Chunk Access Rate: {access3DRate:N0} chunks/second");
+ }
+
+ // Memory usage summary
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine("\nMemory Usage Summary:");
+ Console.ResetColor();
+
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ Console.WriteLine($"Current Memory Usage: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}");
+ Console.WriteLine($"Active Chunks: {memoryReport.ActiveChunkCount:N0}");
+ Console.WriteLine($"Available System Memory: {FormatByteSize(memoryReport.AvailableSystemMemoryBytes)}");
+ Console.WriteLine($"Memory Usage: {memoryReport.MemoryUsagePercentage:F2}% of total system memory");
+
+ // System information
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\nSystem Information:");
+ Console.ResetColor();
+ Console.WriteLine($"CPU Threads: {Environment.ProcessorCount}");
+ Console.WriteLine($"Total System Memory: {FormatByteSize(memoryReport.TotalSystemMemoryBytes)}");
+ Console.WriteLine($"AdvChkSys Version: {AdvChkSys.AdvChkSys.Version}");
+
+ // Memory efficiency metrics
+ if (_results.Count > 0)
+ {
+ var result2D = _results.FirstOrDefault(r => r.TestName.Contains("2D"));
+ var result3D = _results.FirstOrDefault(r => r.TestName.Contains("3D"));
+
+ if (result2D != null && result2D.ChunkCount > 0)
+ {
+ ulong bytesPerChunk2D = result2D.MemoryUsed / (ulong)result2D.ChunkCount;
+ Console.WriteLine($"2D Memory Efficiency: {FormatByteSize(bytesPerChunk2D)}/chunk");
+ }
+
+ if (result3D != null && result3D.ChunkCount > 0)
+ {
+ ulong bytesPerChunk3D = result3D.MemoryUsed / (ulong)result3D.ChunkCount;
+ Console.WriteLine($"3D Memory Efficiency: {FormatByteSize(bytesPerChunk3D)}/chunk");
+ }
+ }
+
+ // If stress test was run, show its results
+ var stressResult = _results.FirstOrDefault(r => r.TestName.Contains("Stress"));
+ if (stressResult != null)
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\nStress Test Results:");
+ Console.ResetColor();
+ Console.WriteLine($"Chunks Processed: {stressResult.ChunkCount:N0}");
+ Console.WriteLine($"Processing Time: {stressResult.CreateTime:F2} seconds");
+ Console.WriteLine($"Processing Rate: {stressResult.ChunkCount / stressResult.CreateTime:N0} chunks/second");
+ Console.WriteLine($"Peak Memory Usage: {FormatByteSize(stressResult.MemoryUsed)}");
+ }
+
+
+ }
+
+ private static void Benchmark2D(int cpuCount, int numChunks, int chunkSize, int maxLoaded)
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine($"[2D] {numChunks:N0} chunks of size {chunkSize}x{chunkSize}, maxLoaded={maxLoaded:N0}");
+ Console.ResetColor();
+
+ var constraints = new WorldConstraints
+ {
+ MinChunkX = 0,
+ MaxChunkX = 9999,
+ MinChunkY = 0,
+ MaxChunkY = 9999,
+ MaxLoadedChunks = maxLoaded
+ };
+ var manager = new ChunkManager2D(constraints, maxLoaded);
+
+ // Memory before test
+ var memoryBefore = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory before: {FormatByteSize(memoryBefore.EstimatedChunkMemoryBytes)}");
+ Console.ResetColor();
+ }
+
+ // Create and fill
+ var sw = Stopwatch.StartNew();
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 1000, y = i / 1000;
+ var chunk = manager.LoadOrCreateChunk(x, y, chunkSize, chunkSize);
+ for (int cx = 0; cx < chunkSize; cx++)
+ for (int cy = 0; cy < chunkSize; cy++)
+ chunk[cx, cy] = (byte)((cx + cy) % 256);
+ });
+ sw.Stop();
+ double createTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Created and filled {numChunks:N0} chunks in {createTime:F3} s");
+
+ // Memory after creation
+ var memoryAfterCreate = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory after creation: {FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes)} " +
+ $"(+{FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})");
+ Console.ResetColor();
+ }
+
+ // Access (sum all cells)
+ sw.Restart();
+ long total = 0;
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, () => 0L,
+ (i, state, localSum) =>
+ {
+ int x = i % 1000, y = i / 1000;
+ var chunk = manager.GetChunk(x, y);
+ if (chunk != null)
+ {
+ for (int cx = 0; cx < chunkSize; cx++)
+ for (int cy = 0; cy < chunkSize; cy++)
+ localSum += chunk[cx, cy];
+ }
+ return localSum;
+ },
+ localSum => Interlocked.Add(ref total, localSum)
+ );
+ sw.Stop();
+ double accessTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Accessed {numChunks:N0} chunks in {accessTime:F3} s (sum: {total:N0})");
+
+ // Unload
+ sw.Restart();
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 1000, y = i / 1000;
+ manager.UnloadChunk(x, y);
+ });
+ sw.Stop();
+ double unloadTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Unloaded {numChunks:N0} chunks in {unloadTime:F3} s");
+
+ // Memory after test
+ var memoryAfter = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory after unload: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes)} " +
+ $"(change: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})");
+ Console.ResetColor();
+ }
+
+ // Store results
+ _results.Add(new BenchmarkResult
+ {
+ TestName = "2D Chunks",
+ ChunkCount = numChunks,
+ ChunkSize = $"{chunkSize}x{chunkSize}",
+ CreateTime = createTime,
+ AccessTime = accessTime,
+ UnloadTime = unloadTime,
+ MemoryUsed = memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes
+ });
+
+ // Force GC to clean up
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ private static void Benchmark3D(int cpuCount, int numChunks, int width, int height, int depth, int maxLoaded)
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine($"[3D] {numChunks:N0} chunks of size {width}x{height}x{depth}, maxLoaded={maxLoaded:N0}");
+ Console.ResetColor();
+
+ var constraints = new WorldConstraints
+ {
+ MinChunkX = 0,
+ MaxChunkX = 50000,
+ MinChunkY = 0,
+ MaxChunkY = 50000,
+ MinChunkZ = 0,
+ MaxChunkZ = 50000,
+ MaxLoadedChunks = maxLoaded
+ };
+ var manager = new ChunkManager3D(constraints, maxLoaded);
+
+ // Memory before test
+ var memoryBefore = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory before: {FormatByteSize(memoryBefore.EstimatedChunkMemoryBytes)}");
+ Console.ResetColor();
+ }
+
+ // Create and fill
+ var sw = Stopwatch.StartNew();
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 100, y = i / 100 % 100, z = i / 10000;
+ var chunk = manager.LoadOrCreateChunk(x, y, z, width, height, depth);
+ for (int cx = 0; cx < width; cx++)
+ for (int cy = 0; cy < height; cy++)
+ for (int cz = 0; cz < depth; cz++)
+ chunk[cx, cy, cz] = (byte)((cx + cy + cz) % 256);
+ });
+ sw.Stop();
+ double createTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Created and filled {numChunks:N0} chunks in {createTime:F3} s");
+
+ // Memory after creation
+ var memoryAfterCreate = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory after creation: {FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes)} " +
+ $"(+{FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})");
+ Console.ResetColor();
+ }
+
+ // Access (sum all cells)
+ sw.Restart();
+ long total = 0;
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, () => 0L,
+ (i, state, localSum) =>
+ {
+ int x = i % 100, y = i / 100 % 100, z = i / 10000;
+ var chunk = manager.GetChunk(x, y, z);
+ if (chunk != null)
+ {
+ for (int cx = 0; cx < width; cx++)
+ for (int cy = 0; cy < height; cy++)
+ for (int cz = 0; cz < depth; cz++)
+ localSum += chunk[cx, cy, cz];
+ }
+ return localSum;
+ },
+ localSum => Interlocked.Add(ref total, localSum)
+ );
+ sw.Stop();
+ double accessTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Accessed {numChunks:N0} chunks in {accessTime:F3} s (sum: {total:N0})");
+
+ // Unload
+ sw.Restart();
+ Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 100, y = i / 100 % 100, z = i / 10000;
+ manager.UnloadChunk(x, y, z);
+ });
+ sw.Stop();
+ double unloadTime = sw.Elapsed.TotalSeconds;
+ Console.WriteLine($" Unloaded {numChunks:N0} chunks in {unloadTime:F3} s");
+
+ // Memory after test
+ var memoryAfter = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ if (_verboseMode)
+ {
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory after unload: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes)} " +
+ $"(change: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})");
+ Console.ResetColor();
+ }
+
+ // Store results
+ _results.Add(new BenchmarkResult
+ {
+ TestName = "3D Chunks",
+ ChunkCount = numChunks,
+ ChunkSize = $"{width}x{height}x{depth}",
+ CreateTime = createTime,
+ AccessTime = accessTime,
+ UnloadTime = unloadTime,
+ MemoryUsed = memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes
+ });
+
+ // Force GC to clean up
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ private static void RunMemoryTest(int cpuCount, int chunk2DSize, int chunk3DWidth, int chunk3DHeight, int chunk3DDepth)
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗");
+ Console.WriteLine("║ Memory Usage Test ║");
+ Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+
+ // Initial memory state
+ var initialMemory = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ Console.WriteLine($"Initial memory state: {FormatByteSize(initialMemory.EstimatedChunkMemoryBytes)}");
+
+ // Test 2D memory scaling
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\n[2D Memory Scaling Test]");
+ Console.ResetColor();
+
+ var manager2D = new ChunkManager2D(null, 1000000);
+ var memoryPoints2D = new List<(int chunkCount, ulong memoryUsed)>();
+
+ for (int i = 1; i <= 10; i++)
+ {
+ int chunkCount = i * 1000;
+ Console.WriteLine($" Loading {chunkCount:N0} 2D chunks...");
+
+ for (int j = (i - 1) * 1000; j < i * 1000; j++)
+ {
+ int x = j % 1000, y = j / 1000;
+ var chunk = manager2D.LoadOrCreateChunk(x, y, chunk2DSize, chunk2DSize);
+ // Fill with some data
+ for (int cx = 0; cx < chunk2DSize; cx++)
+ for (int cy = 0; cy < chunk2DSize; cy++)
+ chunk[cx, cy] = (byte)((cx + cy) % 256);
+ }
+
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ memoryPoints2D.Add((chunkCount, memoryReport.EstimatedChunkMemoryBytes));
+ Console.WriteLine($" Memory used: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}");
+ }
+
+ // Clean up 2D chunks
+ manager2D = null;
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+
+ // Test 3D memory scaling
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\n[3D Memory Scaling Test]");
+ Console.ResetColor();
+
+ var manager3D = new ChunkManager3D(null, 1000000);
+ var memoryPoints3D = new List<(int chunkCount, ulong memoryUsed)>();
+
+ for (int i = 1; i <= 5; i++)
+ {
+ int chunkCount = i * 10;
+ Console.WriteLine($" Loading {chunkCount:N0} 3D chunks...");
+
+ for (int j = (i - 1) * 10; j < i * 10; j++)
+ {
+ int x = j % 10, y = j / 10 % 10, z = j / 100;
+ var chunk = manager3D.LoadOrCreateChunk(x, y, z, chunk3DWidth, chunk3DHeight, chunk3DDepth);
+ // Fill with some data
+ for (int cx = 0; cx < chunk3DWidth; cx++)
+ for (int cy = 0; cy < chunk3DHeight; cy++)
+ for (int cz = 0; cz < chunk3DDepth; cz++)
+ chunk[cx, cy, cz] = (byte)((cx + cy + cz) % 256);
+ }
+
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ memoryPoints3D.Add((chunkCount, memoryReport.EstimatedChunkMemoryBytes));
+ Console.WriteLine($" Memory used: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}");
+ }
+
+ // Clean up 3D chunks
+ manager3D = null;
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+
+ // Display memory scaling results
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\n[Memory Scaling Results]");
+ Console.ResetColor();
+
+ Console.WriteLine("2D Memory Scaling:");
+ for (int i = 0; i < memoryPoints2D.Count; i++)
+ {
+ var point = memoryPoints2D[i];
+ ulong bytesPerChunk = i > 0
+ ? (point.memoryUsed - memoryPoints2D[i - 1].memoryUsed) / (ulong)(point.chunkCount - memoryPoints2D[i - 1].chunkCount)
+ : point.memoryUsed / (ulong)point.chunkCount;
+
+ Console.WriteLine($" {point.chunkCount:N0} chunks: {FormatByteSize(point.memoryUsed)} " +
+ $"(~{FormatByteSize(bytesPerChunk)}/chunk)");
+ }
+
+ Console.WriteLine("\n3D Memory Scaling:");
+ for (int i = 0; i < memoryPoints3D.Count; i++)
+ {
+ var point = memoryPoints3D[i];
+ ulong bytesPerChunk = i > 0
+ ? (point.memoryUsed - memoryPoints3D[i - 1].memoryUsed) / (ulong)(point.chunkCount - memoryPoints3D[i - 1].chunkCount)
+ : point.memoryUsed / (ulong)point.chunkCount;
+
+ Console.WriteLine($" {point.chunkCount:N0} chunks: {FormatByteSize(point.memoryUsed)} " +
+ $"(~{FormatByteSize(bytesPerChunk)}/chunk)");
+ }
+
+ // Final memory state
+ var finalMemory = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ Console.WriteLine($"\nFinal memory state: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes)}");
+ Console.WriteLine($"Memory change: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes - initialMemory.EstimatedChunkMemoryBytes)}");
+ }
+
+ private static void RunStressTest(int cpuCount, int chunk2DSize, int chunk3DWidth, int chunk3DHeight, int chunk3DDepth)
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗");
+ Console.WriteLine("║ Stress Test ║");
+ Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+
+ // Initial memory state
+ var initialMemory = AdvChkSys.AdvChkSys.GetMemoryUsage();
+
+ // Create managers with small cache size to force evictions
+ var manager2D = new ChunkManager2D(null, 100);
+ var manager3D = new ChunkManager3D(null, 50);
+
+ Console.WriteLine("Running load/unload stress test (rapid chunk cycling)...");
+
+ var sw = Stopwatch.StartNew();
+ int iterations = 5;
+ int chunksPerIteration = 500;
+
+ for (int iter = 0; iter < iterations; iter++)
+ {
+ Console.WriteLine($" Iteration {iter + 1}/{iterations}...");
+
+ // 2D stress
+ Parallel.For(0, chunksPerIteration, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 100, y = i / 100;
+ // Load chunk
+ var chunk = manager2D.LoadOrCreateChunk(x, y, chunk2DSize, chunk2DSize);
+ // Fill with data
+ for (int cx = 0; cx < chunk2DSize; cx++)
+ for (int cy = 0; cy < chunk2DSize; cy++)
+ chunk[cx, cy] = (byte)((cx + cy + i) % 256);
+
+ // Access chunk
+ long sum = 0;
+ for (int cx = 0; cx < chunk2DSize; cx++)
+ for (int cy = 0; cy < chunk2DSize; cy++)
+ sum += chunk[cx, cy];
+
+ // Force some evictions by loading other chunks
+ for (int j = 0; j < 3; j++)
+ {
+ int otherX = (i + j * 200) % 1000;
+ int otherY = (i + j * 200) / 1000;
+ var otherChunk = manager2D.LoadOrCreateChunk(otherX, otherY, chunk2DSize, chunk2DSize);
+ otherChunk[0, 0] = (byte)(i + j);
+ }
+ });
+
+ // 3D stress
+ Parallel.For(0, chunksPerIteration / 10, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i =>
+ {
+ int x = i % 10, y = i / 10 % 10, z = i / 100;
+ // Load chunk
+ var chunk = manager3D.LoadOrCreateChunk(x, y, z, chunk3DWidth, chunk3DHeight, chunk3DDepth);
+ // Fill with data (just a portion to save time)
+ for (int cx = 0; cx < chunk3DWidth; cx++)
+ for (int cy = 0; cy < chunk3DHeight / 4; cy++)
+ for (int cz = 0; cz < chunk3DDepth; cz++)
+ chunk[cx, cy, cz] = (byte)((cx + cy + cz + i) % 256);
+
+ // Access chunk
+ long sum = 0;
+ for (int cx = 0; cx < chunk3DWidth; cx++)
+ for (int cy = 0; cy < chunk3DHeight / 4; cy++)
+ for (int cz = 0; cz < chunk3DDepth; cz++)
+ sum += chunk[cx, cy, cz];
+
+ // Force some evictions by loading other chunks
+ for (int j = 0; j < 2; j++)
+ {
+ int otherX = (i + j * 20) % 30;
+ int otherY = (i + j * 20) / 30 % 30;
+ int otherZ = (i + j * 20) / 900;
+ var otherChunk = manager3D.LoadOrCreateChunk(otherX, otherY, otherZ, chunk3DWidth, chunk3DHeight, chunk3DDepth);
+ otherChunk[0, 0, 0] = (byte)(i + j);
+ }
+ });
+
+ // Check memory after each iteration
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ Console.ForegroundColor = _memoryColor;
+ Console.WriteLine($" Memory: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)} " +
+ $"({memoryReport.MemoryUsagePercentage:F2}% of system memory)");
+ Console.ResetColor();
+
+ // Force GC between iterations
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ sw.Stop();
+
+ Console.WriteLine($"Stress test completed in {sw.Elapsed.TotalSeconds:F2} seconds");
+
+ // Final memory state
+ var finalMemory = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ Console.WriteLine($"Final memory state: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes)}");
+ Console.WriteLine($"Memory change: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes - initialMemory.EstimatedChunkMemoryBytes)}");
+
+ // Clean up
+ manager2D = null;
+ manager3D = null;
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ private static bool CanAllocateChunks(int numChunks, int width, int height, int depth, double safetyFactor)
+ {
+ long bytesPerChunk = (long)width * height * depth;
+ long totalBytes = bytesPerChunk * numChunks;
+ long available = GetAvailableMemoryBytes();
+ long safeLimit = (long)(available * safetyFactor);
+
+ return totalBytes <= safeLimit;
+ }
+
+ private static long GetAvailableMemoryBytes()
+ {
+ return (long)AdvChkSys.AdvChkSys.GetMemoryUsage().AvailableSystemMemoryBytes;
+ }
+
+ private static string FormatByteSize(ulong bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ double formattedSize = bytes;
+ int order = 0;
+
+ while (formattedSize >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ formattedSize /= 1024;
+ }
+
+ return $"{formattedSize:F2} {sizes[order]}";
+ }
+
+ private static void SaveBenchmarkLog()
+ {
+ // Create logs directory if it doesn't exist
+ string logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
+ Directory.CreateDirectory(logsDirectory);
+
+ // Generate a unique filename with timestamp
+ string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ string logFileName = $"ChunkMark_{timestamp}.log";
+ string logFilePath = Path.Combine(logsDirectory, logFileName);
+
+ // Create a string builder to hold the log content
+ var logContent = new StringBuilder();
+
+ // Add header
+ logContent.AppendLine("=================================================================");
+ logContent.AppendLine($"ChunkMark Benchmark Results - {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}");
+ logContent.AppendLine("=================================================================");
+ logContent.AppendLine();
+
+ // Add system information
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ logContent.AppendLine("System Information:");
+ logContent.AppendLine($"CPU Threads: {Environment.ProcessorCount}");
+ logContent.AppendLine($"Total System Memory: {FormatByteSize(memoryReport.TotalSystemMemoryBytes)}");
+ logContent.AppendLine($"Available System Memory: {FormatByteSize(memoryReport.AvailableSystemMemoryBytes)}");
+ logContent.AppendLine($"AdvChkSys Version: {AdvChkSys.AdvChkSys.Version}");
+ logContent.AppendLine();
+
+ // Add benchmark results
+ logContent.AppendLine("Benchmark Results:");
+ logContent.AppendLine("------------------------------------------------------------------");
+ if (_results.Count == 0)
+ {
+ logContent.AppendLine("No benchmark results to display.");
+ }
+ else
+ {
+ // Header row
+ logContent.AppendLine(
+ $"{"Test",-15} | {"Chunks",-10} | {"Size",-12} | {"Create (s)",-10} | {"Access (s)",-10} | {"Unload (s)",-10} | {"Memory",-12}");
+ logContent.AppendLine(new string('-', 90));
+
+ // Data rows
+ foreach (var result in _results)
+ {
+ logContent.AppendLine(
+ $"{result.TestName,-15} | {result.ChunkCount,-10:N0} | {result.ChunkSize,-12} | " +
+ $"{result.CreateTime,-10:F3} | {result.AccessTime,-10:F3} | {result.UnloadTime,-10:F3} | " +
+ $"{FormatByteSize(result.MemoryUsed),-12}");
+ }
+ }
+ logContent.AppendLine();
+
+ // Add performance metrics
+ logContent.AppendLine("Performance Metrics:");
+ logContent.AppendLine("------------------------------------------------------------------");
+ if (_results.Count > 0)
+ {
+ var create2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault();
+ var create3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault();
+ var access2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault();
+ var access3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault();
+
+ if (create2DRate > 0)
+ logContent.AppendLine($"2D Chunk Creation Rate: {create2DRate:N0} chunks/second");
+ if (create3DRate > 0)
+ logContent.AppendLine($"3D Chunk Creation Rate: {create3DRate:N0} chunks/second");
+ if (access2DRate > 0)
+ logContent.AppendLine($"2D Chunk Access Rate: {access2DRate:N0} chunks/second");
+ if (access3DRate > 0)
+ logContent.AppendLine($"3D Chunk Access Rate: {access3DRate:N0} chunks/second");
+ }
+ logContent.AppendLine();
+
+ // Add memory usage summary
+ logContent.AppendLine("Memory Usage Summary:");
+ logContent.AppendLine("------------------------------------------------------------------");
+ logContent.AppendLine($"Current Memory Usage: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}");
+ logContent.AppendLine($"Active Chunks: {memoryReport.ActiveChunkCount:N0}");
+ logContent.AppendLine($"Memory Usage: {memoryReport.MemoryUsagePercentage:F2}% of total system memory");
+
+ // Memory efficiency metrics
+ if (_results.Count > 0)
+ {
+ var result2D = _results.FirstOrDefault(r => r.TestName.Contains("2D"));
+ var result3D = _results.FirstOrDefault(r => r.TestName.Contains("3D"));
+
+ if (result2D != null && result2D.ChunkCount > 0)
+ {
+ ulong bytesPerChunk2D = result2D.MemoryUsed / (ulong)result2D.ChunkCount;
+ logContent.AppendLine($"2D Memory Efficiency: {FormatByteSize(bytesPerChunk2D)}/chunk");
+ }
+
+ if (result3D != null && result3D.ChunkCount > 0)
+ {
+ ulong bytesPerChunk3D = result3D.MemoryUsed / (ulong)result3D.ChunkCount;
+ logContent.AppendLine($"3D Memory Efficiency: {FormatByteSize(bytesPerChunk3D)}/chunk");
+ }
+ }
+ // Detailed Chunk Info
+ logContent.AppendLine();
+ logContent.AppendLine("Detailed Chunk Information:");
+ logContent.AppendLine("------------------------------------------------------------------");
+ logContent.AppendLine($"Active Chunks (reported by ChunkResourceManager): {memoryReport.ActiveChunkCount:N0}");
+
+ // Get counts from results
+ int total2DChunks = _results.Where(r => r.TestName.Contains("2D")).Sum(r => r.ChunkCount);
+ int total3DChunks = _results.Where(r => r.TestName.Contains("3D")).Sum(r => r.ChunkCount);
+ int totalChunks = total2DChunks + total3DChunks;
+
+ logContent.AppendLine($"Total 2D Chunks Created: {total2DChunks:N0}");
+ logContent.AppendLine($"Total 3D Chunks Created: {total3DChunks:N0}");
+ logContent.AppendLine($"Total Chunks Created: {totalChunks:N0}");
+
+ if (memoryReport.ActiveChunkCount != totalChunks)
+ {
+ logContent.AppendLine();
+ logContent.AppendLine("Possible reasons for the difference:");
+ logContent.AppendLine("1. Chunk eviction due to LRU cache capacity limits");
+ logContent.AppendLine("2. Chunks unloaded during benchmark cleanup");
+ logContent.AppendLine("3. All-air chunks using flyweight pattern (counted once)");
+ logContent.AppendLine("4. Garbage collection removing unreferenced chunks");
+ }
+
+
+ // Write the log to file
+ File.WriteAllText(logFilePath, logContent.ToString());
+
+ Console.ForegroundColor = _infoColor;
+ Console.WriteLine($"\nBenchmark results saved to: {logFilePath}");
+ Console.ResetColor();
+ }
+
+ private static void PrintDetailedChunkInfo()
+ {
+ Console.ForegroundColor = _headerColor;
+ Console.WriteLine("\nDetailed Chunk Information:");
+ Console.ResetColor();
+
+ var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage();
+
+ Console.WriteLine($"Active Chunks (reported by ChunkResourceManager): {memoryReport.ActiveChunkCount:N0}");
+
+ // Get counts from results
+ int total2DChunks = _results.Where(r => r.TestName.Contains("2D")).Sum(r => r.ChunkCount);
+ int total3DChunks = _results.Where(r => r.TestName.Contains("3D")).Sum(r => r.ChunkCount);
+ int totalChunks = total2DChunks + total3DChunks;
+
+ Console.WriteLine($"Total 2D Chunks Created: {total2DChunks:N0}");
+ Console.WriteLine($"Total 3D Chunks Created: {total3DChunks:N0}");
+ Console.WriteLine($"Total Chunks Created: {totalChunks:N0}");
+
+ if (memoryReport.ActiveChunkCount != totalChunks)
+ {
+ Console.ForegroundColor = _infoColor;
+ Console.WriteLine("\nPossible reasons for the difference:");
+ Console.WriteLine("1. Chunk eviction due to LRU cache capacity limits");
+ Console.WriteLine("2. Chunks unloaded during benchmark cleanup");
+ Console.WriteLine("3. All-air chunks using flyweight pattern (counted once)");
+ Console.WriteLine("4. Garbage collection removing unreferenced chunks");
+ Console.ResetColor();
+ }
+ }
+ }
+
+ public class BenchmarkResult
+ {
+ public string TestName { get; set; } = "";
+ public int ChunkCount { get; set; }
+ public string ChunkSize { get; set; } = "";
+ public double CreateTime { get; set; }
+ public double AccessTime { get; set; }
+ public double UnloadTime { get; set; }
+ public ulong MemoryUsed { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs b/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs
new file mode 100644
index 0000000..868f0f9
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using AdvChkSys.Diagnostics;
+
+namespace ChunkMark
+{
+ ///
+ /// Provides detailed memory reporting and visualization for benchmarks.
+ ///
+ public class MemoryReporter
+ {
+ private readonly List _snapshots = new List();
+ private readonly Stopwatch _timer = new Stopwatch();
+ private readonly string _reportName;
+
+ public MemoryReporter(string reportName)
+ {
+ _reportName = reportName;
+ _timer.Start();
+ }
+
+ ///
+ /// Takes a memory snapshot with the given label.
+ ///
+ public void TakeSnapshot(string label)
+ {
+ var report = AdvChkSys.AdvChkSys.GetMemoryUsage();
+ _snapshots.Add(new MemorySnapshot
+ {
+ Label = label,
+ TimeSeconds = _timer.Elapsed.TotalSeconds,
+ Report = report
+ });
+ }
+
+ ///
+ /// Generates a detailed memory report as a string.
+ ///
+ public string GenerateReport()
+ {
+ if (_snapshots.Count == 0)
+ return "No memory snapshots recorded.";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Memory Report: {_reportName}");
+ sb.AppendLine("=".PadRight(50, '='));
+ sb.AppendLine();
+
+ sb.AppendLine("Snapshots:");
+ sb.AppendLine("┌─────────────────┬──────────┬─────────────────┬─────────────┬───────────┐");
+ sb.AppendLine("│ Label │ Time (s) │ Chunk Memory │ Active │ Usage % │");
+ sb.AppendLine("├─────────────────┼──────────┼─────────────────┼─────────────┼───────────┤");
+
+ foreach (var snapshot in _snapshots)
+ {
+ string label = snapshot.Label.PadRight(15).Substring(0, 15);
+ string time = $"{snapshot.TimeSeconds:F2}".PadRight(8).Substring(0, 8);
+ string memory = FormatByteSize(snapshot.Report.EstimatedChunkMemoryBytes).PadRight(15).Substring(0, 15);
+ string active = $"{snapshot.Report.ActiveChunkCount:N0}".PadRight(11).Substring(0, 11);
+ string usage = $"{snapshot.Report.MemoryUsagePercentage:F2}%".PadRight(9).Substring(0, 9);
+
+ sb.AppendLine($"│ {label} │ {time} │ {memory} │ {active} │ {usage} │");
+ }
+ sb.AppendLine("└─────────────────┴──────────┴─────────────────┴─────────────┴───────────┘");
+ sb.AppendLine();
+
+ // Memory change analysis
+ if (_snapshots.Count >= 2)
+ {
+ var first = _snapshots.First();
+ var last = _snapshots.Last();
+ var maxMem = _snapshots.Max(s => s.Report.EstimatedChunkMemoryBytes);
+ var minMem = _snapshots.Min(s => s.Report.EstimatedChunkMemoryBytes);
+
+ sb.AppendLine("Memory Analysis:");
+ sb.AppendLine($" Initial Memory: {FormatByteSize(first.Report.EstimatedChunkMemoryBytes)}");
+ sb.AppendLine($" Final Memory: {FormatByteSize(last.Report.EstimatedChunkMemoryBytes)}");
+ sb.AppendLine($" Net Change: {FormatByteSize(last.Report.EstimatedChunkMemoryBytes - first.Report.EstimatedChunkMemoryBytes)}");
+ sb.AppendLine($" Peak Memory: {FormatByteSize(maxMem)}");
+ sb.AppendLine($" Minimum Memory: {FormatByteSize(minMem)}");
+ sb.AppendLine($" Memory Volatility: {FormatByteSize(maxMem - minMem)}");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Saves the memory report to a file.
+ ///
+ public void SaveReportToFile(string filePath)
+ {
+ string report = GenerateReport();
+ File.WriteAllText(filePath, report);
+ }
+
+ ///
+ /// Formats a byte size into a human-readable string (KB, MB, GB).
+ ///
+ private static string FormatByteSize(ulong bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ double formattedSize = bytes;
+ int order = 0;
+
+ while (formattedSize >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ formattedSize /= 1024;
+ }
+
+ return $"{formattedSize:F2} {sizes[order]}";
+ }
+
+ private class MemorySnapshot
+ {
+ public string Label { get; set; } = "";
+ public double TimeSeconds { get; set; }
+ public MemoryUsageReport Report { get; set; } = null!;
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys.Benchmarks/README.md b/advchksys/src/AdvChkSys.Benchmarks/README.md
new file mode 100644
index 0000000..6b7e861
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Benchmarks/README.md
@@ -0,0 +1,123 @@
+# ChunkMark - AdvChkSys Benchmarking Tool
+
+ChunkMark is a comprehensive benchmarking and diagnostic tool for the AdvChkSys chunk management library. It provides detailed performance metrics and memory usage analysis to help developers optimize their chunk-based applications.
+
+## Features
+
+- **Performance Benchmarking**: Measures chunk creation, access, and unloading speeds
+- **Memory Analysis**: Tracks memory consumption patterns during chunk operations
+- **Stress Testing**: Simulates high-load scenarios with rapid chunk cycling
+- **Memory Scaling Tests**: Shows how memory usage scales with increasing chunk counts
+- **Detailed Reporting**: Provides formatted tables and summaries of benchmark results
+
+## Usage
+
+```
+ChunkMark.exe [options]
+```
+
+### Command Line Options
+
+| Option | Description |
+|--------|-------------|
+| `--cpus=N` | Set the number of CPU threads to use (default: all available) |
+| `--2d-chunks=N` | Number of 2D chunks to benchmark (default: 10000) |
+| `--2d-size=N` | Size of 2D chunks (NxN) (default: 32) |
+| `--3d-chunks=N` | Number of 3D chunks to benchmark (default: 500) |
+| `--3d-width=N` | Width of 3D chunks (default: 16) |
+| `--3d-height=N` | Height of 3D chunks (default: 16) |
+| `--3d-depth=N` | Depth of 3D chunks (default: 16) |
+| `--3d-size=N` | Set all 3D dimensions to N (overrides individual settings) |
+| `--max-loaded=N` | Maximum chunks to keep in memory (default: 100000) |
+| `--2d-only` | Run only 2D benchmarks |
+| `--3d-only` | Run only 3D benchmarks |
+| `--memory-test` | Run memory scaling tests |
+| `--stress-test` | Run chunk cycling stress tests |
+| `--verbose` | Show detailed memory information during tests |
+
+### Examples
+
+Basic benchmark with default settings:
+```
+ChunkMark.exe
+```
+
+Run only 2D benchmarks with 50,000 chunks of size 64x64:
+```
+ChunkMark.exe --2d-only --2d-chunks=50000 --2d-size=64
+```
+
+Run 3D benchmarks with custom dimensions:
+```
+ChunkMark.exe --3d-only --3d-chunks=100 --3d-width=32 --3d-height=128 --3d-depth=32
+```
+
+Run memory scaling tests with verbose output:
+```
+ChunkMark.exe --memory-test --verbose
+```
+
+## Benchmark Types
+
+### Standard Benchmarks
+
+The standard benchmarks measure three key operations:
+
+1. **Creation**: Time to create and fill chunks with data
+2. **Access**: Time to read all data from chunks
+3. **Unload**: Time to unload chunks from memory
+
+These benchmarks are run for both 2D and 3D chunks (unless limited by command line options).
+
+### Memory Scaling Test
+
+This test incrementally loads more chunks and measures memory consumption at each step. It helps visualize how memory usage scales with chunk count and calculates the average memory per chunk.
+
+### Stress Test
+
+The stress test rapidly cycles chunks in and out of the cache to test the LRU eviction mechanism and memory stability under high load. It's useful for identifying memory leaks or performance degradation during extended use.
+
+## Output
+
+ChunkMark provides formatted output with:
+
+- Benchmark results in tabular format
+- Memory usage statistics
+- Performance metrics (chunks/second)
+- Summary of all tests
+
+In verbose mode, it also shows detailed memory snapshots before and after each operation.
+
+## Memory Safety
+
+ChunkMark includes memory safety checks to prevent out-of-memory errors. If a benchmark would require more memory than is safely available, ChunkMark will prompt for alternative parameters or skip the test.
+
+## Interpreting Results
+
+- **Creation Time**: Lower is better. Measures how quickly chunks can be created and filled.
+- **Access Time**: Lower is better. Measures how quickly data can be read from chunks.
+- **Unload Time**: Lower is better. Measures how quickly chunks can be removed from memory.
+- **Memory Usage**: Lower is better for a given number of chunks.
+- **Chunks/Second**: Higher is better. Derived metric showing throughput.
+
+## System Requirements
+- Windows, Linux, or macOS operating system
+- .NET 5.0 or higher
+- Sufficient memory for the requested benchmark parameters (e.g., 3D benchmark with 1024 chunks of size 384×384×384 would require approximately 54-60 GB of RAM) (e.g., 3D benchmark with 1000 chunks of size 32×32×32 would require approximately 125 MB of RAM)
+- Math for 1024 chunks at a size of 384x384x384.
+
+### Memory Calculation
+For 1024 chunks at a size of 384×384×384:
+```python
+# Calculate cells per chunk
+cells_per_chunk = 384 × 384 × 384 = 56,623,104 cells
+
+# Calculate total bytes
+total_bytes = cells_per_chunk × 1024 chunks = 57,972,964,416 bytes
+
+# Convert to GB
+total_GB = total_bytes / 1024 / 1024 / 1024 = 54.2 GB
+
+# Add 5-20% overhead for metadata, references, and system overhead
+final_estimate = 54.2 GB × (1.05 to 1.20) = 56.9 to 65.0 GB
+```
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py b/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py
new file mode 100644
index 0000000..e5057de
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py
@@ -0,0 +1,164 @@
+# noqa: E501
+import time
+import advchksys
+from concurrent.futures import ThreadPoolExecutor
+
+
+def parallel_benchmark_2d(manager, num_chunks, chunk_size, max_workers=None):
+ print(
+ f"\n[2D] Parallel Benchmark: {num_chunks} chunks of size {chunk_size}x{chunk_size} (max_workers={max_workers})"
+ )
+ t0 = time.perf_counter()
+
+ def create_and_fill(i):
+ x, y = i % 100, i // 100
+ chunk = manager.LoadOrCreateChunk(x, y, chunk_size, chunk_size)
+ for cx in range(chunk_size):
+ for cy in range(chunk_size):
+ chunk[cx, cy] = (cx + cy) % 256
+
+ # Parallel chunk creation and filling
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ list(executor.map(create_and_fill, range(num_chunks)))
+
+ t1 = time.perf_counter()
+ print(
+ f" Created and filled {num_chunks} chunks in {t1-t0:.3f} seconds (parallel)"
+ )
+
+ # Parallel chunk access (sum all cells)
+ t2 = time.perf_counter()
+
+ def sum_chunk(i):
+ x, y = i % 100, i // 100
+ chunk = manager.GetChunk(x, y)
+ subtotal = 0
+ if chunk is not None:
+ for cx in range(chunk_size):
+ for cy in range(chunk_size):
+ subtotal += chunk[cx, cy]
+ return subtotal
+
+ total = 0
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ for subtotal in executor.map(sum_chunk, range(num_chunks)):
+ total += subtotal
+
+ t3 = time.perf_counter()
+ print(
+ f" Accessed {num_chunks} chunks in {t3-t2:.3f} seconds (sum: {total}, parallel)"
+ )
+
+ # Parallel chunk unload
+ t4 = time.perf_counter()
+
+ def unload_chunk(i):
+ x, y = i % 100, i // 100
+ manager.UnloadChunk(x, y)
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ list(executor.map(unload_chunk, range(num_chunks)))
+
+ t5 = time.perf_counter()
+ print(f" Unloaded {num_chunks} chunks in {t5-t4:.3f} seconds (parallel)")
+
+
+def parallel_benchmark_3d(
+ manager,
+ num_chunks,
+ chunk_size_x,
+ chunk_size_y,
+ chunk_size_z,
+ max_workers=None,
+):
+ print(
+ f"\n[3D] Parallel Benchmark: {num_chunks} chunks of size {chunk_size_x}x{chunk_size_y}x{chunk_size_z} (max_workers={max_workers})"
+ )
+ t0 = time.perf_counter()
+
+ def create_and_fill(i):
+ x, y, z = i % 10, (i // 10) % 10, i // 100
+ chunk = manager.LoadOrCreateChunk(
+ x, y, z, chunk_size_x, chunk_size_y, chunk_size_z
+ )
+ for cx in range(chunk_size_x):
+ for cy in range(chunk_size_y):
+ for cz in range(chunk_size_z):
+ chunk[cx, cy, cz] = (cx + cy + cz) % 256
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ list(executor.map(create_and_fill, range(num_chunks)))
+
+ t1 = time.perf_counter()
+ print(
+ f" Created and filled {num_chunks} chunks in {t1-t0:.3f} seconds (parallel)"
+ )
+
+ # Parallel chunk access (sum all cells)
+ t2 = time.perf_counter()
+
+ def sum_chunk(i):
+ x, y, z = i % 10, (i // 10) % 10, i // 100
+ chunk = manager.GetChunk(x, y, z)
+ subtotal = 0
+ if chunk is not None:
+ for cx in range(chunk_size_x):
+ for cy in range(chunk_size_y):
+ for cz in range(chunk_size_z):
+ subtotal += chunk[cx, cy, cz]
+ return subtotal
+
+ total = 0
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ for subtotal in executor.map(sum_chunk, range(num_chunks)):
+ total += subtotal
+
+ t3 = time.perf_counter()
+ print(
+ f" Accessed {num_chunks} chunks in {t3-t2:.3f} seconds (sum: {total}, parallel)"
+ )
+
+ # Parallel chunk unload
+ t4 = time.perf_counter()
+
+ def unload_chunk(i):
+ x, y, z = i % 10, (i // 10) % 10, i // 100
+ manager.UnloadChunk(x, y, z)
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ list(executor.map(unload_chunk, range(num_chunks)))
+
+ t5 = time.perf_counter()
+ print(f" Unloaded {num_chunks} chunks in {t5-t4:.3f} seconds (parallel)")
+
+
+def main():
+ print("AdvChkSys Python Parallel Benchmark")
+ print("Library version:", advchksys.get_version())
+
+ # 2D Parallel Benchmark
+ constraints2d = advchksys.create_constraints(
+ min_x=0, max_x=99, min_y=0, max_y=200, max_loaded=10000000
+ )
+ manager2d = advchksys.create_2d_manager(constraints2d)
+ parallel_benchmark_2d(
+ manager2d, num_chunks=20000, chunk_size=32, max_workers=4
+ )
+
+ # 3D Parallel Benchmark
+ constraints3d = advchksys.create_constraints(
+ min_x=0, max_x=9, min_y=0, max_y=9, max_loaded=10000
+ )
+ manager3d = advchksys.create_3d_manager(constraints3d)
+ parallel_benchmark_3d(
+ manager3d,
+ num_chunks=100,
+ chunk_size_x=16,
+ chunk_size_y=16,
+ chunk_size_z=32,
+ max_workers=4,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj b/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj
new file mode 100644
index 0000000..1b056cf
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs b/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs
new file mode 100644
index 0000000..590b30a
--- /dev/null
+++ b/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs
@@ -0,0 +1,88 @@
+using AdvChkSys.Chunk;
+using AdvChkSys.Manager;
+using AdvChkSys.Serialization;
+
+namespace AdvChkSys.Tests;
+
+public sealed class ChunkManagerTests
+{
+ [Fact]
+ public void AllAirChunksDoNotAliasAcrossCoordinates()
+ {
+ var manager = new ChunkManager2D(capacity: 8, chunkWidth: 8, chunkHeight: 8);
+ manager.SetAirCheckDelegate(static (_, _, _, _) => true);
+
+ var first = manager.LoadOrCreateChunk(0, 0, 8, 8);
+ var second = manager.LoadOrCreateChunk(1, 0, 8, 8);
+
+ Assert.NotSame(first, second);
+ Assert.Equal(0, first.X);
+ Assert.Equal(0, first.Y);
+ Assert.Equal(1, second.X);
+ Assert.Equal(0, second.Y);
+ Assert.True(first.IsAllAir);
+ Assert.True(second.IsAllAir);
+ }
+
+ [Fact]
+ public void EvictionReloadCreatesFreshChunkInstance()
+ {
+ var manager = new ChunkManager2D(capacity: 1, chunkWidth: 4, chunkHeight: 4);
+ var original = manager.LoadOrCreateChunk(0, 0, 4, 4);
+ original[1, 1] = 42;
+
+ _ = manager.LoadOrCreateChunk(1, 0, 4, 4);
+ var reloaded = manager.LoadOrCreateChunk(0, 0, 4, 4);
+
+ Assert.NotSame(original, reloaded);
+ Assert.Equal((byte)0, reloaded[1, 1]);
+ }
+
+ [Fact]
+ public void SerializeDeserialize2DRoundTripsDataAndMetadata()
+ {
+ var chunk = new Chunk2D(2, 3, 4, 4);
+ chunk.Metadata["tag"] = "test";
+ chunk[1, 2] = 99;
+
+ var bytes = ChunkSerializer.Serialize2D(chunk);
+ var clone = ChunkSerializer.Deserialize2D(bytes);
+
+ Assert.Equal(2, clone.X);
+ Assert.Equal(3, clone.Y);
+ Assert.Equal(4, clone.Width);
+ Assert.Equal(4, clone.Height);
+ Assert.Equal("test", clone.Metadata["tag"]);
+ Assert.Equal(99, clone[1, 2]);
+ }
+
+ [Fact]
+ public void SerializeDeserialize3DRoundTripsDataAndMetadata()
+ {
+ var chunk = new Chunk3D(1, 2, 3, 4, 4, 4);
+ chunk.Metadata["biome"] = "ash";
+ chunk[1, 1, 1] = 7;
+
+ var bytes = ChunkSerializer.Serialize3D(chunk);
+ var clone = ChunkSerializer.Deserialize3D(bytes);
+
+ Assert.Equal(1, clone.X);
+ Assert.Equal(2, clone.Y);
+ Assert.Equal(3, clone.Z);
+ Assert.Equal("ash", clone.Metadata["biome"]);
+ Assert.Equal((short)7, clone[1, 1, 1]);
+ }
+
+ [Fact]
+ public async Task ConcurrentLoadRequestsReturnSameChunkInstance()
+ {
+ var manager = new ChunkManager2D(capacity: 16, chunkWidth: 8, chunkHeight: 8);
+ var tasks = Enumerable.Range(0, 16)
+ .Select(_ => manager.LoadOrCreateChunkAsync(5, 7, 8, 8))
+ .ToArray();
+
+ var chunks = await Task.WhenAll(tasks);
+
+ Assert.All(chunks, chunk => Assert.Same(chunks[0], chunk));
+ }
+}
diff --git a/advchksys/src/AdvChkSys/AdvChkSys.cs b/advchksys/src/AdvChkSys/AdvChkSys.cs
new file mode 100644
index 0000000..9774819
--- /dev/null
+++ b/advchksys/src/AdvChkSys/AdvChkSys.cs
@@ -0,0 +1,162 @@
+#nullable enable
+using AdvChkSys.Constraints;
+using AdvChkSys.Manager;
+using AdvChkSys.Chunk;
+using AdvChkSys.Resources;
+using AdvChkSys.Diagnostics;
+using AdvChkSys.Threading;
+using AdvChkSys.Spatial;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AdvChkSys
+{
+ ///
+ /// Entry point and static facade for the AdvChkSys library.
+ /// Provides version info, helpers for creating managers and constraints, and a basic self-test.
+ ///
+ public static class AdvChkSys
+ {
+ ///
+ /// The current version of the AdvChkSys library.
+ ///
+ public static string Version => "0.2.0";
+
+ ///
+ /// Creates a new WorldConstraints object.
+ ///
+ public static WorldConstraints CreateDefaultConstraints() => new();
+
+ ///
+ /// Creates a new 2D chunk manager with optional constraints.
+ ///
+ public static ChunkManager2D Create2DManager(WorldConstraints? constraints = null) =>
+ new(constraints);
+
+ ///
+ /// Creates a new 3D chunk manager with optional constraints.
+ ///
+ public static ChunkManager3D Create3DManager(WorldConstraints? constraints = null) =>
+ new(constraints);
+
+ ///
+ /// Gets a detailed report of the current memory usage by the chunk system.
+ ///
+ /// A report containing memory usage statistics
+ public static MemoryUsageReport GetMemoryUsage() => MemoryUsageReporter.GetMemoryUsage();
+
+ ///
+ /// Logs the current memory usage to the provided logging action.
+ ///
+ /// Action that will receive the log messages
+ public static void LogMemoryUsage(Action logAction) =>
+ MemoryUsageReporter.LogMemoryUsage(logAction);
+
+ ///
+ /// Gets the threading manager for advanced threading operations.
+ ///
+ public static ChunkThreadingManager ThreadingManager => ChunkThreadingManager.Instance;
+
+ ///
+ /// Runs a batch of chunk operations in parallel.
+ ///
+ public static Task RunParallelChunkOperationsAsync(IEnumerable operations,
+ int? maxDegreeOfParallelism = null,
+ CancellationToken cancellationToken = default)
+ {
+ return ChunkTaskScheduler.RunBatchParallelAsync(operations, maxDegreeOfParallelism, cancellationToken);
+ }
+
+ ///
+ /// Creates a spatial index for 2D chunks.
+ ///
+ public static SpatialChunkIndex> CreateSpatialIndex2D() =>
+ new();
+
+ ///
+ /// Creates a spatial index for 3D chunks.
+ ///
+ public static SpatialChunkIndex> CreateSpatialIndex3D() =>
+ new();
+
+ ///
+ /// Configures the threading system for high throughput.
+ ///
+ public static void ConfigureForHighThroughput() =>
+ ChunkThreadingConfiguration.ConfigureForHighThroughput();
+
+ ///
+ /// Configures the threading system for low latency.
+ ///
+ public static void ConfigureForLowLatency() =>
+ ChunkThreadingConfiguration.ConfigureForLowLatency();
+
+ ///
+ /// Configures the threading system for memory efficiency.
+ ///
+ public static void ConfigureForMemoryEfficiency() =>
+ ChunkThreadingConfiguration.ConfigureForMemoryEfficiency();
+
+ ///
+ /// Performs a basic self-test of the AdvChkSys core functionality.
+ /// Returns true if all core systems are operational.
+ ///
+ public static bool SelfTest()
+ {
+ // Create constraints
+ var constraints = new WorldConstraints
+ {
+ MinChunkX = 0,
+ MinChunkY = 0,
+ MaxChunkX = 2,
+ MaxChunkY = 2,
+ MaxLoadedChunks = 4
+ };
+
+ // Create managers
+ var manager2D = new ChunkManager2D(constraints);
+ var manager3D = new ChunkManager3D(constraints);
+
+ // Test 2D chunk creation
+ var chunk2D = manager2D.LoadOrCreateChunk(1, 1, 8, 8);
+ chunk2D[0, 0] = 42;
+
+ // Test 3D chunk creation
+ var chunk3D = manager3D.LoadOrCreateChunk(1, 1, 1, 4, 4, 4);
+ chunk3D[0, 0, 0] = 99;
+
+ // Check resource manager tracking
+ bool resourceTracking2D = ChunkResourceManager.AllocatedChunkCount >= 1;
+ bool resourceTracking3D = ChunkResourceManager.AllocatedChunkCount >= 2;
+
+ // Check constraints enforcement
+ bool constraintsWork = constraints.IsWithinBounds(1, 1) && constraints.IsWithinChunkLimit(2);
+
+ // Test spatial indexing
+ var spatialIndex2D = CreateSpatialIndex2D();
+ spatialIndex2D.AddChunk(chunk2D);
+ var chunksInRegion = spatialIndex2D.FindChunksInRegion(0, 0, 2, 2);
+ bool spatialIndexWorks = false;
+ foreach (var c in chunksInRegion)
+ {
+ if (c.Equals(chunk2D))
+ {
+ spatialIndexWorks = true;
+ break;
+ }
+ }
+
+ // Test threading
+ bool threadingWorks = ThreadingManager != null;
+
+ // Clean up
+ manager2D.UnloadChunk(1, 1);
+ manager3D.UnloadChunk(1, 1, 1);
+
+ return resourceTracking2D && resourceTracking3D && constraintsWork &&
+ spatialIndexWorks && threadingWorks;
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/AdvChkSys.csproj b/advchksys/src/AdvChkSys/AdvChkSys.csproj
new file mode 100644
index 0000000..41e37aa
--- /dev/null
+++ b/advchksys/src/AdvChkSys/AdvChkSys.csproj
@@ -0,0 +1,9 @@
+
+
+ netstandard2.1
+ 9.0
+ enable
+ AdvChkSys
+ true
+
+
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs b/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs
new file mode 100644
index 0000000..b4c1a52
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs
@@ -0,0 +1,165 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using AdvChkSys.Interfaces;
+
+namespace AdvChkSys.Chunk
+{
+ ///
+ /// Represents a 2D chunk of data in the world.
+ /// Supports all-air singleton for memory efficiency and chunk array pooling.
+ ///
+ public class Chunk2D : IChunk, IDisposable
+ {
+ // Array pool for chunk data arrays
+ private static readonly ConcurrentBag _arrayPool = new();
+
+ ///
+ /// Returns a singleton all-air chunk for the given size and position.
+ ///
+ public static Chunk2D AllAir(int x, int y, int width, int height)
+ {
+ return new Chunk2D(x, y, width, height, isAllAir: true);
+ }
+
+ private readonly bool _isAllAir;
+ private T[,]? _data;
+
+ // Properties required by IChunk
+ ///
+ /// The chunk's X position in chunk coordinates.
+ ///
+ public int X { get; private set; }
+ ///
+ /// The chunk's Y position in chunk coordinates.
+ ///
+ public int Y { get; private set; }
+ ///
+ /// The width of the chunk in cells.
+ ///
+ public int Width { get; }
+ ///
+ /// The height of the chunk in cells.
+ ///
+ public int Height { get; }
+ ///
+ /// Metadata dictionary for arbitrary chunk information.
+ ///
+ public Dictionary Metadata { get; }
+
+ private bool _disposed;
+
+ ///
+ /// Normal constructor (private for singleton/factory).
+ ///
+ public Chunk2D(int x, int y, int width, int height, bool isAllAir = false)
+ {
+ X = x;
+ Y = y;
+ Width = width;
+ Height = height;
+ _isAllAir = isAllAir;
+ _data = isAllAir ? null : RentArray(width, height);
+ Metadata = new Dictionary();
+ }
+
+ ///
+ /// Explicitly sets the chunk's position.
+ ///
+ internal void SetPosition(int x, int y)
+ {
+ X = x;
+ Y = y;
+ }
+
+ ///
+ /// Gets or sets the value at the given local chunk coordinates.
+ ///
+ public T this[int localX, int localY]
+ {
+ get
+ {
+ if (localX < 0 || localX >= Width || localY < 0 || localY >= Height)
+ throw new ArgumentOutOfRangeException($"Coordinates ({localX}, {localY}) out of bounds [0-{Width - 1}, 0-{Height - 1}]");
+ return _isAllAir ? default! : _data![localX, localY];
+ }
+ set
+ {
+ if (localX < 0 || localX >= Width || localY < 0 || localY >= Height)
+ throw new ArgumentOutOfRangeException($"Coordinates ({localX}, {localY}) out of bounds [0-{Width - 1}, 0-{Height - 1}]");
+ if (_isAllAir)
+ throw new InvalidOperationException("Cannot set cell in all-air chunk.");
+ _data![localX, localY] = value;
+ }
+ }
+
+ ///
+ /// Fills the chunk with a specified value (no-op for all-air).
+ ///
+ public void Fill(T value)
+ {
+ if (_isAllAir) return;
+ var data = _data!;
+ for (int x = 0; x < Width; x++)
+ for (int y = 0; y < Height; y++)
+ data[x, y] = value;
+ }
+
+ ///
+ /// Returns true if this chunk is the all-air singleton.
+ ///
+ public bool IsAllAir => _isAllAir;
+
+ ///
+ /// Returns the underlying data array (for pooling).
+ ///
+ internal T[,]? GetDataArray() => _data;
+
+ ///
+ /// Releases the data array back to the pool (for chunk manager).
+ ///
+ internal void ReleaseDataArray()
+ {
+ if (_data != null)
+ {
+ if (!_isAllAir)
+ {
+ ReturnArray(_data);
+ }
+ _data = null;
+ }
+ }
+
+ ///
+ /// Rents an array from the pool or creates a new one.
+ ///
+ private static T[,] RentArray(int width, int height)
+ {
+ if (_arrayPool.TryTake(out var arr) && arr.GetLength(0) == width && arr.GetLength(1) == height)
+ return arr;
+ return new T[width, height];
+ }
+
+ ///
+ /// Returns an array to the pool.
+ ///
+ internal static void ReturnArray(T[,] arr)
+ {
+ Array.Clear(arr, 0, arr.Length);
+ _arrayPool.Add(arr);
+ }
+
+ ///
+ /// Disposes the chunk and returns its resources to the pool.
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ ReleaseDataArray();
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs b/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs
new file mode 100644
index 0000000..643bf98
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs
@@ -0,0 +1,206 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using AdvChkSys.Interfaces;
+
+namespace AdvChkSys.Chunk
+{
+ ///
+ /// Represents a 3D chunk of data in the world.
+ /// Perspective-agnostic and supports arbitrary data types and metadata.
+ ///
+ public class Chunk3D : IChunk, IDisposable
+ {
+ // Array pool for chunk data arrays
+ private static readonly ConcurrentBag _arrayPool = new();
+
+ ///
+ /// Returns a singleton all-air chunk for the given size and position.
+ ///
+ public static Chunk3D AllAir(int x, int y, int z, int width, int height, int depth)
+ {
+ return new Chunk3D(x, y, z, width, height, depth, isAllAir: true);
+ }
+
+ ///
+ /// The chunk's X position in chunk coordinates.
+ ///
+ public int X { get; private set; }
+
+ ///
+ /// The chunk's Y position in chunk coordinates.
+ ///
+ public int Y { get; private set; }
+
+ ///
+ /// The chunk's Z position in chunk coordinates.
+ ///
+ public int Z { get; private set; }
+
+ ///
+ /// The width of the chunk in cells.
+ ///
+ public int Width { get; }
+
+ ///
+ /// The height of the chunk in cells.
+ ///
+ public int Height { get; }
+
+ ///
+ /// The depth of the chunk in cells.
+ ///
+ public int Depth { get; }
+
+ ///
+ /// The chunk's data array.
+ ///
+ private readonly T[,,]? _data;
+
+ ///
+ /// Metadata dictionary for arbitrary chunk information (e.g., biome, tags).
+ ///
+ public Dictionary Metadata { get; }
+
+ ///
+ /// Returns true if this chunk is the all-air singleton.
+ ///
+ public bool IsAllAir { get; private set; } = false;
+
+ ///
+ /// Tracks whether the chunk has been disposed.
+ ///
+ private bool _disposed;
+
+ ///
+ /// Creates a new 3D chunk at the specified position with the given dimensions.
+ ///
+ /// X coordinate in chunk space
+ /// Y coordinate in chunk space
+ /// Z coordinate in chunk space
+ /// Width of the chunk in cells
+ /// Height of the chunk in cells
+ /// Depth of the chunk in cells
+ public Chunk3D(int x, int y, int z, int width, int height, int depth)
+ : this(x, y, z, width, height, depth, false)
+ {
+ }
+
+ ///
+ /// Creates a new 3D chunk at the specified position with the given dimensions.
+ ///
+ /// X coordinate in chunk space
+ /// Y coordinate in chunk space
+ /// Z coordinate in chunk space
+ /// Width of the chunk in cells
+ /// Height of the chunk in cells
+ /// Depth of the chunk in cells
+ /// Whether this is an all-air chunk
+ public Chunk3D(int x, int y, int z, int width, int height, int depth, bool isAllAir = false)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ Width = width;
+ Height = height;
+ Depth = depth;
+ IsAllAir = isAllAir;
+ _data = isAllAir ? null : RentArray(width, height, depth);
+ Metadata = new Dictionary();
+ }
+
+ ///
+ /// Explicitly sets the chunk's position.
+ ///
+ internal void SetPosition(int x, int y, int z)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ }
+
+ ///
+ /// Gets or sets the value at the given local chunk coordinates.
+ ///
+ public T this[int localX, int localY, int localZ]
+ {
+ get
+ {
+ if (IsAllAir)
+ return default!;
+ return _data![localX, localY, localZ];
+ }
+ set
+ {
+ if (IsAllAir)
+ throw new InvalidOperationException("Cannot modify an all-air chunk.");
+ _data![localX, localY, localZ] = value;
+ }
+ }
+
+ ///
+ /// Fills the chunk with a specified value.
+ ///
+ public void Fill(T value)
+ {
+ if (IsAllAir)
+ throw new InvalidOperationException("Cannot fill an all-air chunk.");
+
+ for (int x = 0; x < Width; x++)
+ for (int y = 0; y < Height; y++)
+ for (int z = 0; z < Depth; z++)
+ _data![x, y, z] = value;
+ }
+
+ ///
+ /// Rents an array from the pool or creates a new one.
+ ///
+ private static T[,,] RentArray(int width, int height, int depth)
+ {
+ if (_arrayPool.TryTake(out var arr) &&
+ arr.GetLength(0) == width &&
+ arr.GetLength(1) == height &&
+ arr.GetLength(2) == depth)
+ return arr;
+ return new T[width, height, depth];
+ }
+
+ ///
+ /// Returns an array to the pool.
+ ///
+ internal static void ReturnArray(T[,,] arr)
+ {
+ Array.Clear(arr, 0, arr.Length);
+ _arrayPool.Add(arr);
+ }
+
+ ///
+ /// Returns the underlying data array (for pooling).
+ ///
+ internal T[,,]? GetDataArray() => _data;
+
+ ///
+ /// Releases the data array back to the pool.
+ ///
+ internal void ReleaseDataArray()
+ {
+ if (_data != null && !IsAllAir)
+ {
+ ReturnArray(_data);
+ }
+ }
+
+ ///
+ /// Disposes the chunk and returns its resources to the pool.
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ ReleaseDataArray();
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs b/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs
new file mode 100644
index 0000000..5362272
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs
@@ -0,0 +1,67 @@
+using System;
+
+namespace AdvChkSys.Constraints
+{
+ ///
+ /// Represents constraints and limits for the chunk world.
+ /// Can be used to restrict world size, chunk counts, and other resource limits.
+ ///
+ public class WorldConstraints
+ {
+ ///
+ /// If set, the minimum allowed chunk X coordinate (inclusive).
+ ///
+ public int? MinChunkX { get; set; }
+
+ ///
+ /// If set, the maximum allowed chunk X coordinate (inclusive).
+ ///
+ public int? MaxChunkX { get; set; }
+
+ ///
+ /// If set, the minimum allowed chunk Y coordinate (inclusive).
+ ///
+ public int? MinChunkY { get; set; }
+
+ ///
+ /// If set, the maximum allowed chunk Y coordinate (inclusive).
+ ///
+ public int? MaxChunkY { get; set; }
+
+ ///
+ /// If set, the minimum allowed chunk Z coordinate (inclusive).
+ ///
+ public int? MinChunkZ { get; set; }
+
+ ///
+ /// If set, the maximum allowed chunk Z coordinate (inclusive).
+ ///
+ public int? MaxChunkZ { get; set; }
+
+ ///
+ /// If set, the maximum number of chunks allowed to be loaded in memory at once.
+ ///
+ public int? MaxLoadedChunks { get; set; }
+
+ ///
+ /// Checks if the given chunk coordinates are within the allowed world bounds.
+ ///
+ public bool IsWithinBounds(int chunkX, int chunkY)
+ {
+ if (MinChunkX.HasValue && chunkX < MinChunkX.Value) return false;
+ if (MaxChunkX.HasValue && chunkX > MaxChunkX.Value) return false;
+ if (MinChunkY.HasValue && chunkY < MinChunkY.Value) return false;
+ if (MaxChunkY.HasValue && chunkY > MaxChunkY.Value) return false;
+ return true;
+ }
+
+ ///
+ /// Checks if the current number of loaded chunks is within the allowed limit.
+ ///
+ public bool IsWithinChunkLimit(int loadedChunkCount)
+ {
+ if (MaxLoadedChunks.HasValue && loadedChunkCount > MaxLoadedChunks.Value) return false;
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs b/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs
new file mode 100644
index 0000000..0b3be04
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs
@@ -0,0 +1,325 @@
+#nullable enable
+using System.Collections.Generic;
+using AdvChkSys.Interfaces;
+
+namespace AdvChkSys.Dependencies
+{
+ ///
+ /// Tracks dependencies between chunks.
+ ///
+ public class ChunkDependencyTracker
+ {
+ ///
+ /// Defines the type of dependency between chunks.
+ ///
+ public enum DependencyType
+ {
+ ///
+ /// Indicates a neighboring chunk relationship.
+ ///
+ Neighbor,
+
+ ///
+ /// Indicates a reference relationship between chunks.
+ ///
+ Reference,
+
+ ///
+ /// Indicates an update relationship between chunks.
+ ///
+ Update,
+
+ ///
+ /// Indicates a custom dependency relationship.
+ ///
+ Custom
+ }
+
+ // Dictionary to track dependencies: source chunk -> (target chunk, dependency type)
+ private readonly Dictionary> _dependencies = new();
+
+ // Dictionary to track dependents: target chunk -> (source chunk, dependency type)
+ private readonly Dictionary> _dependents = new();
+
+ // Lock object for thread safety
+ private readonly object _lock = new();
+
+ ///
+ /// Registers a dependency between two chunks.
+ ///
+ public void RegisterDependency(IChunk source, IChunk target, DependencyType type)
+ {
+ if (source == null || target == null)
+ return;
+
+ lock (_lock)
+ {
+ // Add to dependencies
+ if (!_dependencies.TryGetValue(source, out var targetSet))
+ {
+ targetSet = new HashSet<(IChunk, DependencyType)>();
+ _dependencies[source] = targetSet;
+ }
+ targetSet.Add((target, type));
+
+ // Add to dependents
+ if (!_dependents.TryGetValue(target, out var sourceSet))
+ {
+ sourceSet = new HashSet<(IChunk, DependencyType)>();
+ _dependents[target] = sourceSet;
+ }
+ sourceSet.Add((source, type));
+ }
+ }
+
+ ///
+ /// Removes a dependency between two chunks.
+ ///
+ public void RemoveDependency(IChunk source, IChunk target)
+ {
+ if (source == null || target == null)
+ return;
+
+ lock (_lock)
+ {
+ // Remove from dependencies
+ if (_dependencies.TryGetValue(source, out var targetSet))
+ {
+ targetSet.RemoveWhere(t => t.Target.Equals(target));
+ if (targetSet.Count == 0)
+ {
+ _dependencies.Remove(source);
+ }
+ }
+
+ // Remove from dependents
+ if (_dependents.TryGetValue(target, out var sourceSet))
+ {
+ sourceSet.RemoveWhere(s => s.Source.Equals(source));
+ if (sourceSet.Count == 0)
+ {
+ _dependents.Remove(target);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets all chunks that depend on the specified chunk.
+ ///
+ public IEnumerable GetDependents(IChunk chunk)
+ {
+ if (chunk == null)
+ return new List();
+
+ lock (_lock)
+ {
+ if (!_dependents.TryGetValue(chunk, out var sourceSet))
+ {
+ return new List();
+ }
+
+ var result = new List(sourceSet.Count);
+ foreach (var (source, _) in sourceSet)
+ {
+ result.Add(source);
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Gets all chunks that the specified chunk depends on.
+ ///
+ public IEnumerable GetDependencies(IChunk chunk)
+ {
+ if (chunk == null)
+ return new List();
+
+ lock (_lock)
+ {
+ if (!_dependencies.TryGetValue(chunk, out var targetSet))
+ {
+ return new List();
+ }
+
+ var result = new List(targetSet.Count);
+ foreach (var (target, _) in targetSet)
+ {
+ result.Add(target);
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Gets all chunks that depend on the specified chunk with their dependency types.
+ ///
+ public IEnumerable<(IChunk Chunk, DependencyType Type)> GetDependentsWithTypes(IChunk chunk)
+ {
+ if (chunk == null)
+ return new List<(IChunk, DependencyType)>();
+
+ lock (_lock)
+ {
+ if (!_dependents.TryGetValue(chunk, out var sourceDependents))
+ {
+ return new List<(IChunk, DependencyType)>();
+ }
+
+ var result = new List<(IChunk, DependencyType)>(sourceDependents.Count);
+ foreach (var (source, type) in sourceDependents)
+ {
+ result.Add((source, type));
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Gets all chunks that the specified chunk depends on with their dependency types.
+ ///
+ public IEnumerable<(IChunk Chunk, DependencyType Type)> GetDependenciesWithTypes(IChunk chunk)
+ {
+ if (chunk == null)
+ return new List<(IChunk, DependencyType)>();
+
+ lock (_lock)
+ {
+ if (!_dependencies.TryGetValue(chunk, out var targetSet))
+ {
+ return new List<(IChunk, DependencyType)>();
+ }
+
+ var result = new List<(IChunk, DependencyType)>(targetSet.Count);
+ foreach (var (target, type) in targetSet)
+ {
+ result.Add((target, type));
+ }
+ return result;
+ }
+ }
+
+ ///
+ /// Checks if a dependency exists between source and target chunks.
+ ///
+ public bool HasDependency(IChunk source, IChunk target)
+ {
+ if (source == null || target == null)
+ return false;
+
+ lock (_lock)
+ {
+ if (!_dependencies.TryGetValue(source, out var targetSet))
+ {
+ return false;
+ }
+
+ foreach (var (t, _) in targetSet)
+ {
+ if (t.Equals(target))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the dependency type between source and target chunks, or null if no dependency exists.
+ ///
+ public DependencyType? GetDependencyType(IChunk source, IChunk target)
+ {
+ if (source == null || target == null)
+ return null;
+
+ lock (_lock)
+ {
+ if (!_dependencies.TryGetValue(source, out var targetSet))
+ {
+ return null;
+ }
+
+ foreach (var (t, type) in targetSet)
+ {
+ if (t.Equals(target))
+ {
+ return type;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Clears all dependencies for a specific chunk.
+ ///
+ public void ClearDependencies(IChunk chunk)
+ {
+ if (chunk == null)
+ return;
+
+ lock (_lock)
+ {
+ // Remove all dependencies where this chunk is the source
+ if (_dependencies.TryGetValue(chunk, out var targetSet))
+ {
+ foreach (var (target, _) in targetSet)
+ {
+ if (_dependents.TryGetValue(target, out var depSources))
+ {
+ depSources.RemoveWhere(s => s.Source.Equals(chunk));
+ if (depSources.Count == 0)
+ {
+ _dependents.Remove(target);
+ }
+ }
+ }
+ _dependencies.Remove(chunk);
+ }
+
+ // Remove all dependencies where this chunk is the target
+ if (_dependents.TryGetValue(chunk, out var sourceSets))
+ {
+ foreach (var (source, _) in sourceSets)
+ {
+ if (_dependencies.TryGetValue(source, out var depTargets))
+ {
+ depTargets.RemoveWhere(t => t.Target.Equals(chunk));
+ if (depTargets.Count == 0)
+ {
+ _dependencies.Remove(source);
+ }
+ }
+ }
+ _dependents.Remove(chunk);
+ }
+ }
+ }
+
+ ///
+ /// Gets all chunks that have any dependencies.
+ ///
+ public IEnumerable GetAllChunksWithDependencies()
+ {
+ lock (_lock)
+ {
+ return new HashSet(_dependencies.Keys);
+ }
+ }
+
+ ///
+ /// Gets all chunks that have any dependents.
+ ///
+ public IEnumerable GetAllChunksWithDependents()
+ {
+ lock (_lock)
+ {
+ return new HashSet(_dependents.Keys);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs b/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs
new file mode 100644
index 0000000..ce42c3a
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs
@@ -0,0 +1,142 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using AdvChkSys.Resources;
+using AdvChkSys.Util;
+
+namespace AdvChkSys.Diagnostics
+{
+ ///
+ /// Provides memory usage statistics and reporting for the AdvChkSys library.
+ /// Helps client code monitor memory consumption of chunks and related resources.
+ ///
+ public static class MemoryUsageReporter
+ {
+ ///
+ /// Gets the current memory usage statistics for the AdvChkSys library.
+ ///
+ /// A MemoryUsageReport containing detailed memory statistics
+ public static MemoryUsageReport GetMemoryUsage()
+ {
+ var report = new MemoryUsageReport
+ {
+ ActiveChunkCount = ChunkResourceManager.GetActiveChunkCount(),
+ TotalSystemMemoryBytes = GetTotalSystemMemory(),
+ AvailableSystemMemoryBytes = MemoryHelper.GetAvailableMemoryBytes(),
+ Timestamp = DateTime.UtcNow
+ };
+
+ // Calculate estimated chunk memory usage
+ report.EstimatedChunkMemoryBytes = EstimateChunkMemoryUsage(report.ActiveChunkCount);
+
+ return report;
+ }
+
+ ///
+ /// Estimates the memory usage of chunks based on the active chunk count and average chunk size.
+ ///
+ private static ulong EstimateChunkMemoryUsage(int chunkCount)
+ {
+ // This is an estimate based on typical chunk sizes
+ // For more accurate reporting, we would need to track actual sizes
+ const int averageChunkSizeBytes = 32 * 32 * 4; // Assuming 32x32 chunks with 4 bytes per cell on average
+ return (ulong)chunkCount * (ulong)averageChunkSizeBytes;
+ }
+
+ ///
+ /// Gets the total system memory in bytes.
+ ///
+ private static ulong GetTotalSystemMemory()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var memStatus = new MemoryHelper.MEMORYSTATUSEX
+ {
+ dwLength = (uint)Marshal.SizeOf(typeof(MemoryHelper.MEMORYSTATUSEX))
+ };
+ if (MemoryHelper.GlobalMemoryStatusEx(ref memStatus))
+ {
+ return memStatus.ullTotalPhys;
+ }
+ }
+
+ // For non-Windows platforms or if Windows call fails, use a fallback
+ return 8UL * 1024 * 1024 * 1024; // Assume 8GB as fallback
+ }
+
+ ///
+ /// Logs memory usage information to the provided action.
+ ///
+ /// Action to handle the log message
+ public static void LogMemoryUsage(Action logAction)
+ {
+ var report = GetMemoryUsage();
+
+ logAction($"--- AdvChkSys Memory Report ({report.Timestamp:yyyy-MM-dd HH:mm:ss}) ---");
+ logAction($"Active Chunks: {report.ActiveChunkCount:N0}");
+ logAction($"Estimated Chunk Memory: {FormatByteSize(report.EstimatedChunkMemoryBytes)}");
+ logAction($"Available System Memory: {FormatByteSize(report.AvailableSystemMemoryBytes)}");
+ logAction($"Total System Memory: {FormatByteSize(report.TotalSystemMemoryBytes)}");
+ logAction($"Memory Usage %: {report.MemoryUsagePercentage:F2}%");
+ logAction("-------------------------------------------");
+ }
+
+ ///
+ /// Formats a byte size into a human-readable string (KB, MB, GB).
+ ///
+ private static string FormatByteSize(ulong bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ double formattedSize = bytes;
+ int order = 0;
+
+ while (formattedSize >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ formattedSize /= 1024;
+ }
+
+ return $"{formattedSize:F2} {sizes[order]}";
+ }
+ }
+
+ ///
+ /// Contains detailed memory usage statistics for the AdvChkSys library.
+ ///
+ public class MemoryUsageReport
+ {
+ ///
+ /// The number of active chunks currently being managed.
+ ///
+ public int ActiveChunkCount { get; internal set; }
+
+ ///
+ /// Estimated memory usage of all chunks in bytes.
+ ///
+ public ulong EstimatedChunkMemoryBytes { get; internal set; }
+
+ ///
+ /// Available system memory in bytes.
+ ///
+ public ulong AvailableSystemMemoryBytes { get; internal set; }
+
+ ///
+ /// Total system memory in bytes.
+ ///
+ public ulong TotalSystemMemoryBytes { get; internal set; }
+
+ ///
+ /// Timestamp when this report was generated.
+ ///
+ public DateTime Timestamp { get; internal set; }
+
+ ///
+ /// Percentage of total system memory used by chunks.
+ ///
+ public double MemoryUsagePercentage =>
+ TotalSystemMemoryBytes > 0
+ ? (double)EstimatedChunkMemoryBytes / TotalSystemMemoryBytes * 100
+ : 0;
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Events/ChunkEvents.cs b/advchksys/src/AdvChkSys/Events/ChunkEvents.cs
new file mode 100644
index 0000000..5afd54e
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Events/ChunkEvents.cs
@@ -0,0 +1,126 @@
+#nullable enable
+using System;
+using System.Threading;
+
+namespace AdvChkSys.Events
+{
+ ///
+ /// Provides events for chunk lifecycle operations such as loading, unloading, saving, and more.
+ /// Thread-safe event subscription and invocation.
+ ///
+ public static class ChunkEvents
+ {
+ // Backing fields for thread-safe event handling
+ private static Action? _chunkLoaded;
+ private static Action? _chunkUnloaded;
+ private static Action? _chunkLoading;
+ private static Action? _chunkUnloading;
+ private static Action? _chunkSaving;
+ private static Action? _chunkSaved;
+
+ ///
+ /// Occurs when a chunk has been loaded into memory.
+ ///
+ public static event Action ChunkLoaded
+ {
+ add => AddHandler(ref _chunkLoaded, value);
+ remove => RemoveHandler(ref _chunkLoaded, value);
+ }
+
+ ///
+ /// Occurs when a chunk is about to be loaded into memory.
+ ///
+ public static event Action ChunkLoading
+ {
+ add => AddHandler(ref _chunkLoading, value);
+ remove => RemoveHandler(ref _chunkLoading, value);
+ }
+
+ ///
+ /// Occurs when a chunk is about to be unloaded from memory.
+ ///
+ public static event Action ChunkUnloading
+ {
+ add => AddHandler(ref _chunkUnloading, value);
+ remove => RemoveHandler(ref _chunkUnloading, value);
+ }
+
+ ///
+ /// Occurs when a chunk has been unloaded from memory.
+ ///
+ public static event Action ChunkUnloaded
+ {
+ add => AddHandler(ref _chunkUnloaded, value);
+ remove => RemoveHandler(ref _chunkUnloaded, value);
+ }
+
+ ///
+ /// Occurs when a chunk is about to be saved.
+ ///
+ public static event Action ChunkSaving
+ {
+ add => AddHandler(ref _chunkSaving, value);
+ remove => RemoveHandler(ref _chunkSaving, value);
+ }
+
+ ///
+ /// Occurs when a chunk has been saved.
+ ///
+ public static event Action ChunkSaved
+ {
+ add => AddHandler(ref _chunkSaved, value);
+ remove => RemoveHandler(ref _chunkSaved, value);
+ }
+
+ ///
+ /// Raises the ChunkLoading event.
+ ///
+ public static void OnChunkLoading(Interfaces.IChunk chunk) => _chunkLoading?.Invoke(chunk);
+
+ ///
+ /// Raises the ChunkLoaded event.
+ ///
+ public static void OnChunkLoaded(Interfaces.IChunk chunk) => _chunkLoaded?.Invoke(chunk);
+
+ ///
+ /// Raises the ChunkUnloading event.
+ ///
+ public static void OnChunkUnloading(Interfaces.IChunk chunk) => _chunkUnloading?.Invoke(chunk);
+
+ ///
+ /// Raises the ChunkUnloaded event.
+ ///
+ public static void OnChunkUnloaded(Interfaces.IChunk chunk) => _chunkUnloaded?.Invoke(chunk);
+
+ ///
+ /// Raises the ChunkSaving event.
+ ///
+ public static void OnChunkSaving(Interfaces.IChunk chunk) => _chunkSaving?.Invoke(chunk);
+
+ ///
+ /// Raises the ChunkSaved event.
+ ///
+ public static void OnChunkSaved(Interfaces.IChunk chunk) => _chunkSaved?.Invoke(chunk);
+
+ // Thread-safe add/remove for event handlers
+ private static void AddHandler(ref Action? field, Action handler)
+ {
+ Action? prevHandler, newHandler;
+ do
+ {
+ prevHandler = field;
+ newHandler = (Action?)Delegate.Combine(prevHandler, handler);
+ } while (Interlocked.CompareExchange(ref field, newHandler, prevHandler) != prevHandler);
+ }
+
+ private static void RemoveHandler(ref Action? field, Action handler)
+ {
+ Action? prevHandler, newHandler;
+ do
+ {
+ prevHandler = field;
+ newHandler = (Action?)Delegate.Remove(prevHandler, handler);
+ } while (Interlocked.CompareExchange(ref field, newHandler, prevHandler) != prevHandler);
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Interfaces/IChunk.cs b/advchksys/src/AdvChkSys/Interfaces/IChunk.cs
new file mode 100644
index 0000000..b83440e
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Interfaces/IChunk.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace AdvChkSys.Interfaces
+{
+ ///
+ /// Represents a generic chunk in the world.
+ /// Provides basic properties for position, size, and metadata.
+ ///
+ public interface IChunk
+ {
+ ///
+ /// The chunk's X position in chunk-space coordinates.
+ ///
+ int X { get; }
+
+ ///
+ /// The chunk's Y position in chunk-space coordinates.
+ ///
+ int Y { get; }
+
+ ///
+ /// The width of the chunk (in cells/tiles/units).
+ ///
+ int Width { get; }
+
+ ///
+ /// The height of the chunk (in cells/tiles/units).
+ ///
+ int Height { get; }
+
+ ///
+ /// Metadata dictionary for arbitrary chunk information (e.g., biome, tags).
+ ///
+ Dictionary Metadata { get; }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs b/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs
new file mode 100644
index 0000000..dd097e9
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs
@@ -0,0 +1,38 @@
+#nullable enable
+using System.Collections.Generic;
+
+namespace AdvChkSys.Interfaces
+{
+ ///
+ /// Interface for managing chunks in memory.
+ /// Provides methods for loading, unloading, and accessing chunks.
+ ///
+ public interface IChunkManager
+ {
+ ///
+ /// Returns true if the chunk at (x, y) is loaded.
+ ///
+ bool IsChunkLoaded(int x, int y);
+
+ ///
+ /// Gets the chunk at (x, y) if loaded, or null if not.
+ ///
+ IChunk? GetChunk(int x, int y);
+
+ ///
+ /// Loads or creates a chunk at (x, y) with the given size.
+ /// Returns the loaded or newly created chunk.
+ ///
+ IChunk LoadOrCreateChunk(int x, int y, int width, int height);
+
+ ///
+ /// Unloads (removes) the chunk at (x, y) if loaded.
+ ///
+ bool UnloadChunk(int x, int y);
+
+ ///
+ /// Enumerates all loaded chunks.
+ ///
+ IEnumerable GetAllChunks();
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs b/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs
new file mode 100644
index 0000000..4d44ef1
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs
@@ -0,0 +1,696 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AdvChkSys.Interfaces;
+using AdvChkSys.Threading;
+
+namespace AdvChkSys.Loading
+{
+ ///
+ /// Manages prioritized loading of chunks.
+ ///
+ public class ChunkLoadingPriority
+ {
+ ///
+ /// Priority levels for chunk loading.
+ ///
+ public enum Priority
+ {
+ ///
+ /// Immediate priority - process as soon as possible
+ ///
+ Immediate,
+
+ ///
+ /// High priority - process after immediate requests
+ ///
+ High,
+
+ ///
+ /// Normal priority - standard processing order
+ ///
+ Normal,
+
+ ///
+ /// Low priority - process after normal requests
+ ///
+ Low,
+
+ ///
+ /// Background priority - process when system is idle
+ ///
+ Background
+ }
+
+ ///
+ /// Represents a chunk loading request with priority.
+ ///
+ public class ChunkLoadRequest
+ {
+ ///
+ /// The X coordinate of the chunk.
+ ///
+ public int X { get; }
+
+ ///
+ /// The Y coordinate of the chunk.
+ ///
+ public int Y { get; }
+
+ ///
+ /// The Z coordinate of the chunk (optional, for 3D chunks).
+ ///
+ public int Z { get; }
+
+ ///
+ /// The priority of this loading request.
+ ///
+ public Priority LoadPriority { get; }
+
+ ///
+ /// The width of the chunk.
+ ///
+ public int Width { get; }
+
+ ///
+ /// The height of the chunk.
+ ///
+ public int Height { get; }
+
+ ///
+ /// The depth of the chunk (for 3D chunks).
+ ///
+ public int Depth { get; }
+
+ ///
+ /// Timestamp when the request was created.
+ ///
+ public DateTime Timestamp { get; }
+
+ ///
+ /// Unique identifier for this request.
+ ///
+ public Guid RequestId { get; }
+
+ ///
+ /// Creates a new 2D chunk loading request.
+ ///
+ public ChunkLoadRequest(int x, int y, int width, int height, Priority priority)
+ {
+ X = x;
+ Y = y;
+ Z = 0;
+ Width = width;
+ Height = height;
+ Depth = 1;
+ LoadPriority = priority;
+ Timestamp = DateTime.UtcNow;
+ RequestId = Guid.NewGuid();
+ }
+
+ ///
+ /// Creates a new 3D chunk loading request.
+ ///
+ public ChunkLoadRequest(int x, int y, int z, int width, int height, int depth, Priority priority)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ Width = width;
+ Height = height;
+ Depth = depth;
+ LoadPriority = priority;
+ Timestamp = DateTime.UtcNow;
+ RequestId = Guid.NewGuid();
+ }
+ }
+
+ // Priority queues for each priority level
+ private readonly Dictionary> _requestQueues = new();
+
+ // Lookup for fast cancellation and status checks
+ private readonly Dictionary _requestLookup = new();
+
+ // Active tasks to prevent duplicate loading
+ private readonly Dictionary<(int X, int Y, int Z), Task> _activeTasks = new();
+
+ // Synchronization
+ private readonly SemaphoreSlim _queueSemaphore = new(1, 1);
+ private readonly SemaphoreSlim _processSemaphore;
+ private readonly CancellationTokenSource _cancellationSource = new();
+
+ // Processing state
+ private bool _isProcessing;
+ private Task? _processingTask;
+
+ // Configuration
+ private readonly int _maxConcurrentLoads;
+ private readonly TimeSpan _requestTimeout;
+
+ ///
+ /// Event raised when a chunk load request is completed.
+ ///
+ public event EventHandler? RequestCompleted;
+
+ ///
+ /// Event raised when a chunk load request fails.
+ ///
+ public event EventHandler<(ChunkLoadRequest Request, Exception Exception)>? RequestFailed;
+
+ ///
+ /// Initializes a new instance of the ChunkLoadingPriority class.
+ ///
+ /// Maximum number of chunks to load concurrently
+ /// Timeout in seconds for chunk load requests
+ public ChunkLoadingPriority(int maxConcurrentLoads = 4, int requestTimeoutSeconds = 30)
+ {
+ _maxConcurrentLoads = maxConcurrentLoads;
+ _requestTimeout = TimeSpan.FromSeconds(requestTimeoutSeconds);
+ _processSemaphore = new SemaphoreSlim(maxConcurrentLoads, maxConcurrentLoads);
+
+ // Initialize queues for each priority level
+ foreach (Priority priority in Enum.GetValues(typeof(Priority)))
+ {
+ _requestQueues[priority] = new Queue();
+ }
+ }
+
+ ///
+ /// Enqueues a chunk load request with the specified priority.
+ ///
+ /// The request object that can be used to track or cancel the request
+ public ChunkLoadRequest EnqueueRequest(int x, int y, int width, int height, Priority priority = Priority.Normal)
+ {
+ var request = new ChunkLoadRequest(x, y, width, height, priority);
+ EnqueueRequest(request);
+ return request;
+ }
+
+ ///
+ /// Enqueues a 3D chunk load request with the specified priority.
+ ///
+ /// The request object that can be used to track or cancel the request
+ public ChunkLoadRequest EnqueueRequest(int x, int y, int z, int width, int height, int depth, Priority priority = Priority.Normal)
+ {
+ var request = new ChunkLoadRequest(x, y, z, width, height, depth, priority);
+ EnqueueRequest(request);
+ return request;
+ }
+
+ ///
+ /// Enqueues a pre-created chunk load request.
+ ///
+ public void EnqueueRequest(ChunkLoadRequest request)
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ // Check if we already have a request for this chunk
+ var key = (request.X, request.Y, request.Z);
+ if (_activeTasks.ContainsKey(key))
+ {
+ return; // Already loading this chunk
+ }
+
+ // Add to appropriate queue
+ _requestQueues[request.LoadPriority].Enqueue(request);
+ _requestLookup[request.RequestId] = request;
+
+ // Start processing if not already running
+ EnsureProcessingStarted();
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+
+ // For immediate priority, wait for processing to start
+ if (request.LoadPriority == Priority.Immediate)
+ {
+ TriggerImmediateProcessing();
+ }
+ }
+
+ ///
+ /// Cancels a pending chunk load request.
+ ///
+ /// True if the request was found and canceled, false otherwise
+ public bool CancelRequest(Guid requestId)
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ if (_requestLookup.TryGetValue(requestId, out var request))
+ {
+ _requestLookup.Remove(requestId);
+ // Note: We don't remove from the queue as that would be inefficient
+ // Instead, we'll skip it when we dequeue
+ return true;
+ }
+ return false;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Cancels all pending chunk load requests.
+ ///
+ public void CancelAllRequests()
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ foreach (var queue in _requestQueues.Values)
+ {
+ queue.Clear();
+ }
+ _requestLookup.Clear();
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Starts the processing of chunk load requests if not already running.
+ ///
+ private void EnsureProcessingStarted()
+ {
+ if (_isProcessing)
+ return;
+
+ _isProcessing = true;
+ _processingTask = Task.Run(ProcessRequestsAsync);
+ }
+
+ ///
+ /// Triggers immediate processing of high-priority requests.
+ ///
+ private void TriggerImmediateProcessing()
+ {
+ // This is a hint to the processor to check for immediate requests now
+ // We don't need to do anything special as the processor checks immediate first
+ }
+
+ ///
+ /// Main processing loop for chunk load requests.
+ ///
+ private async Task ProcessRequestsAsync()
+ {
+ while (!_cancellationSource.IsCancellationRequested)
+ {
+ ChunkLoadRequest? request = null;
+
+ // Get the next request from the highest priority queue
+ await _queueSemaphore.WaitAsync();
+ try
+ {
+ request = DequeueNextRequest();
+
+ if (request == null)
+ {
+ // No requests to process, pause briefly
+ _isProcessing = false;
+ _queueSemaphore.Release();
+ await Task.Delay(50);
+ continue;
+ }
+
+ // Mark this chunk as being processed
+ var key = (request.X, request.Y, request.Z);
+ if (_activeTasks.ContainsKey(key))
+ {
+ // Already processing this chunk, skip
+ continue;
+ }
+
+ // Create a task for this request but don't start it yet
+ var loadTask = ProcessRequestAsync(request);
+ _activeTasks[key] = loadTask;
+ }
+ finally
+ {
+ if (_isProcessing)
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ // Wait for a processing slot
+ await _processSemaphore.WaitAsync();
+
+ // Start the task and continue without waiting
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await ProcessRequestAsync(request!);
+ }
+ finally
+ {
+ _processSemaphore.Release();
+
+ // Remove from active tasks
+ await _queueSemaphore.WaitAsync();
+ try
+ {
+ _activeTasks.Remove((request!.X, request.Y, request.Z));
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+ });
+ }
+ }
+
+ ///
+ /// Dequeues the next request from the highest priority queue.
+ ///
+ private ChunkLoadRequest? DequeueNextRequest()
+ {
+ // Check each priority level in order
+ foreach (Priority priority in Enum.GetValues(typeof(Priority)))
+ {
+ var queue = _requestQueues[priority];
+
+ while (queue.Count > 0)
+ {
+ var request = queue.Dequeue();
+
+ // Skip if the request has been canceled
+ if (!_requestLookup.ContainsKey(request.RequestId))
+ continue;
+
+ // Skip if the request has timed out
+ if (DateTime.UtcNow - request.Timestamp > _requestTimeout)
+ {
+ _requestLookup.Remove(request.RequestId);
+ continue;
+ }
+
+ // Valid request found
+ return request;
+ }
+ }
+
+ return null; // No valid requests found
+ }
+
+ ///
+ /// Processes a single chunk load request.
+ ///
+ private async Task ProcessRequestAsync(ChunkLoadRequest request)
+ {
+ try
+ {
+ // This would be integrated with your chunk manager
+ // For now, we'll just simulate the loading with a delay
+
+ // Simulate different loading times based on priority
+ int delayMs = request.LoadPriority switch
+ {
+ Priority.Immediate => 50,
+ Priority.High => 100,
+ Priority.Normal => 200,
+ Priority.Low => 500,
+ Priority.Background => 1000,
+ _ => 200
+ };
+
+ await Task.Delay(delayMs);
+
+ // Notify completion
+ RequestCompleted?.Invoke(this, request);
+ }
+ catch (Exception ex)
+ {
+ // Notify failure
+ RequestFailed?.Invoke(this, (request, ex));
+ }
+ finally
+ {
+ // Remove from lookup
+ await _queueSemaphore.WaitAsync();
+ try
+ {
+ _requestLookup.Remove(request.RequestId);
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+ }
+
+ ///
+ /// Gets the number of pending requests for each priority level.
+ ///
+ public Dictionary GetQueueSizes()
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ var result = new Dictionary();
+ foreach (var kvp in _requestQueues)
+ {
+ result[kvp.Key] = kvp.Value.Count;
+ }
+ return result;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Gets the total number of pending requests.
+ ///
+ public int GetTotalQueueSize()
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ int total = 0;
+ foreach (var queue in _requestQueues.Values)
+ {
+ total += queue.Count;
+ }
+ return total;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Stops processing and releases resources.
+ ///
+ public async Task ShutdownAsync()
+ {
+ _cancellationSource.Cancel();
+
+ if (_processingTask != null)
+ {
+ try
+ {
+ await _processingTask;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected
+ }
+ catch (Exception)
+ {
+ // Ignore other exceptions during shutdown
+ }
+ }
+
+ _queueSemaphore.Dispose();
+ _processSemaphore.Dispose();
+ _cancellationSource.Dispose();
+ }
+
+ ///
+ /// Creates a ChunkLoadingPriority instance with default settings.
+ ///
+ public static ChunkLoadingPriority CreateDefault()
+ {
+ return new ChunkLoadingPriority();
+ }
+
+ ///
+ /// Creates a ChunkLoadingPriority instance optimized for high throughput.
+ ///
+ public static ChunkLoadingPriority CreateHighThroughput()
+ {
+ return new ChunkLoadingPriority(
+ maxConcurrentLoads: Environment.ProcessorCount * 2,
+ requestTimeoutSeconds: 60);
+ }
+
+ ///
+ /// Creates a ChunkLoadingPriority instance optimized for low latency.
+ ///
+ public static ChunkLoadingPriority CreateLowLatency()
+ {
+ return new ChunkLoadingPriority(
+ maxConcurrentLoads: Math.Max(2, Environment.ProcessorCount / 2),
+ requestTimeoutSeconds: 15);
+ }
+
+ ///
+ /// Integrates with a chunk manager to process load requests.
+ ///
+ /// The type of data in the chunks
+ /// The chunk manager to use for loading chunks
+ public void IntegrateWith(IChunkManager chunkManager)
+ {
+ // Replace the ProcessRequestAsync method with one that uses the chunk manager
+ // This is a simplified example - in a real implementation, you'd need to handle
+ // both 2D and 3D chunk managers and properly cast the interface
+
+ RequestCompleted += (sender, request) =>
+ {
+ // The chunk has been loaded, you might want to do something with it
+ var chunk = chunkManager.GetChunk(request.X, request.Y);
+ // Additional processing if needed
+ };
+ }
+
+ ///
+ /// Gets the estimated time until a request with the given priority would be processed.
+ ///
+ /// Estimated wait time in milliseconds, or -1 if cannot be determined
+ public int GetEstimatedWaitTime(Priority priority)
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ int totalHigherPriorityRequests = 0;
+
+ // Count requests with higher or equal priority
+ foreach (Priority p in Enum.GetValues(typeof(Priority)))
+ {
+ if (p <= priority)
+ {
+ totalHigherPriorityRequests += _requestQueues[p].Count;
+ }
+ }
+
+ // If no requests or no active tasks, return 0
+ if (totalHigherPriorityRequests == 0 || _activeTasks.Count == 0)
+ {
+ return 0;
+ }
+
+ // Estimate based on current processing rate
+ // This is a very simple estimate and could be improved with actual metrics
+ int averageProcessingTimeMs = priority switch
+ {
+ Priority.Immediate => 50,
+ Priority.High => 100,
+ Priority.Normal => 200,
+ Priority.Low => 500,
+ Priority.Background => 1000,
+ _ => 200
+ };
+
+ // Calculate how many batches of concurrent requests we'll need
+ int batches = (int)Math.Ceiling(totalHigherPriorityRequests / (double)_maxConcurrentLoads);
+
+ return batches * averageProcessingTimeMs;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Adjusts the priority of an existing request.
+ ///
+ /// True if the request was found and its priority adjusted, false otherwise
+ public bool AdjustPriority(Guid requestId, Priority newPriority)
+ {
+ // Note: This is not an efficient operation as we don't directly modify the queue
+ // Instead, we mark the request for priority change when it's processed
+
+ _queueSemaphore.Wait();
+ try
+ {
+ if (_requestLookup.TryGetValue(requestId, out var request))
+ {
+ // If the request is of lower priority than requested, we'll re-queue it
+ if (request.LoadPriority > newPriority)
+ {
+ // Remove from lookup (it will be skipped when dequeued)
+ _requestLookup.Remove(requestId);
+
+ // Create a new request with the same parameters but higher priority
+ var newRequest = request.Depth > 1
+ ? new ChunkLoadRequest(request.X, request.Y, request.Z, request.Width, request.Height, request.Depth, newPriority)
+ : new ChunkLoadRequest(request.X, request.Y, request.Width, request.Height, newPriority);
+
+ // Add the new request
+ _requestQueues[newPriority].Enqueue(newRequest);
+ _requestLookup[newRequest.RequestId] = newRequest;
+
+ // If upgrading to immediate, trigger processing
+ if (newPriority == Priority.Immediate)
+ {
+ TriggerImmediateProcessing();
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Gets statistics about the current state of the chunk loading system.
+ ///
+ public Dictionary GetStatistics()
+ {
+ _queueSemaphore.Wait();
+ try
+ {
+ var stats = new Dictionary
+ {
+ ["TotalPendingRequests"] = _requestLookup.Count,
+ ["ActiveTasks"] = _activeTasks.Count,
+ ["IsProcessing"] = _isProcessing,
+ ["MaxConcurrentLoads"] = _maxConcurrentLoads
+ };
+
+ // Add queue sizes for each priority
+ foreach (Priority priority in Enum.GetValues(typeof(Priority)))
+ {
+ stats[$"Queue_{priority}"] = _requestQueues[priority].Count;
+ }
+
+ return stats;
+ }
+ finally
+ {
+ _queueSemaphore.Release();
+ }
+ }
+ }
+}
diff --git a/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs b/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs
new file mode 100644
index 0000000..df1aa92
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs
@@ -0,0 +1,313 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AdvChkSys.Chunk;
+using AdvChkSys.Events;
+using AdvChkSys.Interfaces;
+using AdvChkSys.Resources;
+using AdvChkSys.Constraints;
+using AdvChkSys.Util;
+using System;
+
+namespace AdvChkSys.Manager
+{
+ ///
+ /// Manages 2D chunks in memory. Handles loading, unloading, and access.
+ ///
+ public class ChunkManager2D : IChunkManager
+ {
+ private readonly LRUCache<(int, int), Chunk2D> _chunks;
+ private readonly WorldConstraints? _constraints;
+ private readonly int _capacity;
+ private readonly Dictionary<(int, int), Task>> _loadingChunks = new();
+ private readonly object _lock = new();
+
+ ///
+ /// Delegate for custom air check logic
+ ///
+ private Func? _airCheckDelegate;
+
+ ///
+ /// World generator for determining empty regions
+ ///
+ private IWorldGenerator? _worldGenerator;
+
+ ///
+ /// Data provider for determining empty regions
+ ///
+ private IDataProvider? _dataProvider;
+
+ ///
+ /// Height map for determining sky chunks
+ ///
+ private IHeightMap? _heightMap;
+
+ ///
+ /// Initializes a new instance of the 2D chunk manager.
+ ///
+ /// Optional world constraints
+ /// Maximum number of chunks to keep in memory (0 for auto-calculation)
+ /// Default chunk width in cells
+ /// Default chunk height in cells
+ public ChunkManager2D(WorldConstraints? constraints = null, int capacity = 0, int chunkWidth = 32, int chunkHeight = 32)
+ {
+ _constraints = constraints;
+ if (capacity <= 0)
+ {
+ // Use dynamic calculation
+ int bytesPerChunk = chunkWidth * chunkHeight * System.Runtime.InteropServices.Marshal.SizeOf();
+ ulong availableBytes = MemoryHelper.GetAvailableMemoryBytes();
+ _capacity = (int)(availableBytes * 0.8 / (ulong)bytesPerChunk);
+ }
+ else
+ {
+ _capacity = capacity;
+ }
+ _chunks = new LRUCache<(int, int), Chunk2D>(_capacity);
+ }
+
+ ///
+ /// Returns true if the chunk at (x, y) is loaded.
+ ///
+ public bool IsChunkLoaded(int x, int y) => _chunks.TryGet((x, y), out _);
+
+ ///
+ /// Gets the chunk at (x, y) if loaded, or null if not.
+ ///
+ public Chunk2D? GetChunk(int x, int y)
+ {
+ _chunks.TryGet((x, y), out var chunk);
+ return chunk;
+ }
+
+ ///
+ /// Loads or creates a chunk at (x, y) with the given size.
+ /// Returns the loaded or newly created chunk.
+ ///
+ public Chunk2D LoadOrCreateChunk(int x, int y, int width, int height)
+ {
+ if (_constraints != null)
+ {
+ if (!_constraints.IsWithinBounds(x, y))
+ throw new InvalidOperationException("Chunk coordinates out of bounds.");
+ if (!_constraints.IsWithinChunkLimit(_chunks.Count + 1))
+ throw new InvalidOperationException("Chunk limit exceeded.");
+ }
+
+ if (!_chunks.TryGet((x, y), out var chunk))
+ {
+ // If this region is all air, use the singleton
+ if (ShouldBeAllAir(x, y, width, height))
+ chunk = Chunk2D.AllAir(x, y, width, height);
+ else
+ chunk = new Chunk2D(x, y, width, height);
+
+ _chunks.Add((x, y), chunk, OnChunkEvicted);
+ ChunkResourceManager.AllocateChunk(chunk);
+ ChunkEvents.OnChunkLoaded(chunk);
+ }
+ return chunk;
+ }
+
+ private void OnChunkEvicted((int, int) key, Chunk2D chunk)
+ {
+ if (chunk == null) return;
+
+ var arr = chunk.GetDataArray();
+ if (!chunk.IsAllAir && arr != null)
+ {
+ Chunk2D.ReturnArray(arr);
+ }
+ ChunkResourceManager.ReleaseChunk(chunk);
+ ChunkEvents.OnChunkUnloaded(chunk);
+ }
+
+ ///
+ /// Determines if a region should be represented as an all-air chunk.
+ ///
+ private bool ShouldBeAllAir(int x, int y, int width, int height)
+ {
+ // Check if we have a custom air check delegate
+ if (_airCheckDelegate != null)
+ return _airCheckDelegate(x, y, width, height);
+
+ // Check if we have a world generator
+ if (_worldGenerator != null)
+ return _worldGenerator.IsRegionEmpty(x, y, width, height);
+
+ // Check if we have a data provider
+ if (_dataProvider != null)
+ return _dataProvider.IsEmptyRegion(x, y, width, height);
+
+ // Check height-based air (for sky chunks)
+ if (_heightMap != null)
+ return y > _heightMap.GetMaxHeight(x, x + width);
+
+ // Default to non-air to ensure data integrity
+ return false;
+ }
+
+ ///
+ /// Loads or creates a chunk at (x, y) with the given size asynchronously.
+ /// Returns the loaded or newly created chunk.
+ ///
+ public async Task> LoadOrCreateChunkAsync(int x, int y, int width, int height)
+ {
+ var key = (x, y);
+ Task>? task = null;
+
+ // First check if we already have a task
+ lock (_lock)
+ {
+ if (_loadingChunks.TryGetValue(key, out task))
+ {
+ // We found an existing task, no need to create a new one
+ }
+ }
+
+ // If we found a task, await it outside the lock
+ if (task != null)
+ {
+ return await task;
+ }
+
+ // Otherwise, create a new task outside the lock
+ task = Task.Run(() => LoadOrCreateChunk(x, y, width, height));
+
+ // Register the task
+ lock (_lock)
+ {
+ // Check again in case another thread created the task while we were creating ours
+ if (_loadingChunks.TryGetValue(key, out var existingTask))
+ {
+ // Use the existing task instead
+ task = existingTask;
+ }
+ else
+ {
+ // Register our task
+ _loadingChunks[key] = task;
+ }
+ }
+
+ try
+ {
+ return await task;
+ }
+ finally
+ {
+ lock (_lock)
+ {
+ _loadingChunks.Remove(key);
+ }
+ }
+ }
+
+ ///
+ /// Unloads (removes) the chunk at (x, y) if loaded.
+ ///
+ public bool UnloadChunk(int x, int y)
+ {
+ if (_chunks.Remove((x, y), out var chunk))
+ {
+ if (chunk != null) // null check
+ {
+ var arr = chunk.GetDataArray();
+ if (!chunk.IsAllAir && arr != null)
+ {
+ Chunk2D.ReturnArray(arr);
+ }
+ ChunkResourceManager.ReleaseChunk(chunk);
+ ChunkEvents.OnChunkUnloaded(chunk);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Unloads (removes) the chunk at (x, y) if loaded asynchronously.
+ ///
+ public async Task UnloadChunkAsync(int x, int y)
+ {
+ return await Task.Run(() => UnloadChunk(x, y));
+ }
+
+ ///
+ /// Enumerates all loaded chunks.
+ ///
+ public IEnumerable> GetAllChunks() => _chunks.Values;
+
+ // IChunkManager interface compatibility
+ bool IChunkManager.IsChunkLoaded(int x, int y) => IsChunkLoaded(x, y);
+ IChunk? IChunkManager.GetChunk(int x, int y) => GetChunk(x, y);
+ IChunk IChunkManager.LoadOrCreateChunk(int x, int y, int width, int height) => LoadOrCreateChunk(x, y, width, height);
+ bool IChunkManager.UnloadChunk(int x, int y) => UnloadChunk(x, y);
+ IEnumerable IChunkManager.GetAllChunks() => _chunks.Values;
+
+ ///
+ /// Sets a custom delegate for determining if a region should be all air.
+ ///
+ public void SetAirCheckDelegate(Func airCheckDelegate)
+ {
+ _airCheckDelegate = airCheckDelegate;
+ }
+
+ ///
+ /// Sets a world generator for determining empty regions.
+ ///
+ public void SetWorldGenerator(IWorldGenerator worldGenerator)
+ {
+ _worldGenerator = worldGenerator;
+ }
+
+ ///
+ /// Sets a data provider for determining empty regions.
+ ///
+ public void SetDataProvider(IDataProvider dataProvider)
+ {
+ _dataProvider = dataProvider;
+ }
+
+ ///
+ /// Sets a height map for determining sky chunks.
+ ///
+ public void SetHeightMap(IHeightMap heightMap)
+ {
+ _heightMap = heightMap;
+ }
+ }
+
+ ///
+ /// Interface for world generators that can determine if regions are empty.
+ ///
+ public interface IWorldGenerator
+ {
+ ///
+ /// Determines if a region is empty (all air).
+ ///
+ bool IsRegionEmpty(int x, int y, int width, int height);
+ }
+
+ ///
+ /// Interface for data providers that can determine if regions are empty.
+ ///
+ public interface IDataProvider
+ {
+ ///
+ /// Determines if a region is empty (all air).
+ ///
+ bool IsEmptyRegion(int x, int y, int width, int height);
+ }
+
+ ///
+ /// Interface for height maps that can determine the maximum height at a position.
+ ///
+ public interface IHeightMap
+ {
+ ///
+ /// Gets the maximum height at a position range.
+ ///
+ int GetMaxHeight(int startX, int endX);
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs b/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs
new file mode 100644
index 0000000..23013c0
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs
@@ -0,0 +1,244 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Threading.Tasks;
+using System;
+using AdvChkSys.Chunk;
+using AdvChkSys.Constraints;
+using AdvChkSys.Events;
+using AdvChkSys.Interfaces;
+using AdvChkSys.Resources;
+using AdvChkSys.Util;
+
+namespace AdvChkSys.Manager
+{
+ ///
+ /// Manages 3D chunks in memory. Handles loading, unloading, and access.
+ ///
+ public class ChunkManager3D : IChunkManager
+ {
+ private readonly LRUCache<(int, int, int), Chunk3D> _chunks;
+ private readonly WorldConstraints? _constraints;
+ private readonly int _capacity;
+
+ ///
+ /// Delegate for custom air check logic
+ ///
+ private Func? _airCheckDelegate;
+
+ ///
+ /// World generator for determining empty regions
+ ///
+ private IWorldGenerator3D? _worldGenerator;
+
+ ///
+ /// Data provider for determining empty regions
+ ///
+ private IDataProvider3D? _dataProvider;
+
+ ///
+ /// Height map for determining sky chunks
+ ///
+ private IHeightMap3D? _heightMap;
+
+ ///
+ /// Initializes a new instance of the 3D chunk manager.
+ ///
+ /// Optional world constraints
+ /// Maximum number of chunks to keep in memory
+ public ChunkManager3D(WorldConstraints? constraints = null, int capacity = 4096)
+ {
+ _constraints = constraints;
+ _capacity = capacity;
+ _chunks = new LRUCache<(int, int, int), Chunk3D>(_capacity);
+ }
+
+ ///
+ /// Returns true if the chunk at (x, y, z) is loaded.
+ ///
+ public bool IsChunkLoaded(int x, int y, int z) => _chunks.TryGet((x, y, z), out _);
+
+ ///
+ /// Gets the chunk at (x, y, z) if loaded, or null if not.
+ ///
+ public Chunk3D? GetChunk(int x, int y, int z)
+ {
+ _chunks.TryGet((x, y, z), out var chunk);
+ return chunk;
+ }
+
+ ///
+ /// Determines if a region should be represented as an all-air chunk.
+ ///
+ private bool ShouldBeAllAir(int x, int y, int z, int width, int height, int depth)
+ {
+ // Check if we have a custom air check delegate
+ if (_airCheckDelegate != null)
+ return _airCheckDelegate(x, y, z, width, height, depth);
+
+ // Check if we have a world generator
+ if (_worldGenerator != null)
+ return _worldGenerator.IsRegionEmpty(x, y, z, width, height, depth);
+
+ // Check if we have a data provider
+ if (_dataProvider != null)
+ return _dataProvider.IsEmptyRegion(x, y, z, width, height, depth);
+
+ // Check height-based air (for sky chunks)
+ if (_heightMap != null)
+ return y > _heightMap.GetMaxHeight(x, z, x + width, z + depth);
+
+ // Default to non-air to ensure data integrity
+ return false;
+ }
+
+ ///
+ /// Loads or creates a chunk at (x, y, z) with the given size.
+ /// Returns the loaded or newly created chunk.
+ ///
+ public Chunk3D LoadOrCreateChunk(int x, int y, int z, int width, int height, int depth)
+ {
+ if (_constraints != null)
+ {
+ if (!_constraints.IsWithinBounds(x, y))
+ throw new InvalidOperationException("Chunk coordinates out of bounds.");
+ if (!_constraints.IsWithinChunkLimit(_chunks.Count + 1))
+ throw new InvalidOperationException("Chunk limit exceeded.");
+ }
+
+ if (!_chunks.TryGet((x, y, z), out var chunk))
+ {
+ // If this region is all air, use the singleton
+ if (ShouldBeAllAir(x, y, z, width, height, depth))
+ chunk = Chunk3D.AllAir(x, y, z, width, height, depth);
+ else
+ chunk = new Chunk3D(x, y, z, width, height, depth);
+
+ _chunks.Add((x, y, z), chunk, OnChunkEvicted);
+ ChunkResourceManager.AllocateChunk(chunk);
+ ChunkEvents.OnChunkLoaded(chunk);
+ }
+ return chunk;
+ }
+
+ // Note: This method may cause trimming warnings when publishing as a trimmed app
+ private void OnChunkEvicted((int, int, int) key, Chunk3D chunk)
+ {
+ if (chunk == null) return;
+
+ // Instead of using reflection, use the GetDataArray method if available
+ T[,,]? arr = chunk.GetDataArray();
+
+ // If GetDataArray returned null, fall back to reflection
+ if (arr == null)
+ {
+ var arrField = chunk.GetType().GetField("_data", BindingFlags.NonPublic | BindingFlags.Instance);
+ if (arrField != null)
+ {
+ arr = arrField.GetValue(chunk) as T[,,];
+ }
+ }
+
+ if (!chunk.IsAllAir && arr != null)
+ {
+ Chunk3D.ReturnArray(arr);
+ }
+
+ ChunkResourceManager.ReleaseChunk(chunk);
+ ChunkEvents.OnChunkUnloaded(chunk);
+ }
+
+ ///
+ /// Unloads (removes) the chunk at (x, y, z) if loaded.
+ ///
+ public bool UnloadChunk(int x, int y, int z)
+ {
+ if (_chunks.Remove((x, y, z), out var chunk) && chunk != null) // null check
+ {
+ ChunkResourceManager.ReleaseChunk(chunk);
+ ChunkEvents.OnChunkUnloaded(chunk);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Enumerates all loaded chunks.
+ ///
+ public IEnumerable> GetAllChunks() => _chunks.Values;
+
+ // IChunkManager interface compatibility (for 2D interface)
+ bool IChunkManager.IsChunkLoaded(int x, int y) => _chunks.TryGet((x, y, 0), out _);
+ IChunk? IChunkManager.GetChunk(int x, int y) => _chunks.TryGet((x, y, 0), out var chunk) ? chunk : null;
+ IChunk IChunkManager.LoadOrCreateChunk(int x, int y, int width, int height) =>
+ LoadOrCreateChunk(x, y, 0, width, height, 1);
+ bool IChunkManager.UnloadChunk(int x, int y) => UnloadChunk(x, y, 0);
+ IEnumerable IChunkManager.GetAllChunks() => _chunks.Values;
+
+ ///
+ /// Sets a custom delegate for determining if a region should be all air.
+ ///
+ public void SetAirCheckDelegate(Func airCheckDelegate)
+ {
+ _airCheckDelegate = airCheckDelegate;
+ }
+
+ ///
+ /// Sets a world generator for determining empty regions.
+ ///
+ public void SetWorldGenerator(IWorldGenerator3D worldGenerator)
+ {
+ _worldGenerator = worldGenerator;
+ }
+
+ ///
+ /// Sets a data provider for determining empty regions.
+ ///
+ public void SetDataProvider(IDataProvider3D dataProvider)
+ {
+ _dataProvider = dataProvider;
+ }
+
+ ///
+ /// Sets a height map for determining sky chunks.
+ ///
+ public void SetHeightMap(IHeightMap3D heightMap)
+ {
+ _heightMap = heightMap;
+ }
+ }
+
+ ///
+ /// Interface for 3D world generators that can determine if regions are empty.
+ ///
+ public interface IWorldGenerator3D
+ {
+ ///
+ /// Determines if a region is empty (all air).
+ ///
+ bool IsRegionEmpty(int x, int y, int z, int width, int height, int depth);
+ }
+
+ ///
+ /// Interface for 3D data providers that can determine if regions are empty.
+ ///
+ public interface IDataProvider3D
+ {
+ ///
+ /// Determines if a region is empty (all air).
+ ///
+ bool IsEmptyRegion(int x, int y, int z, int width, int height, int depth);
+ }
+
+ ///
+ /// Interface for 3D height maps that can determine the maximum height at a position.
+ ///
+ public interface IHeightMap3D
+ {
+ ///
+ /// Gets the maximum height at a position range.
+ ///
+ int GetMaxHeight(int startX, int startZ, int endX, int endZ);
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs b/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs
new file mode 100644
index 0000000..227483d
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs
@@ -0,0 +1,75 @@
+#nullable enable
+using AdvChkSys.Interfaces;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+namespace AdvChkSys.Resources
+{
+ ///
+ /// Manages allocation and release of chunk resources in memory.
+ /// Can be extended to track resource usage, pooling, or implement custom memory strategies.
+ ///
+ public static class ChunkResourceManager
+ {
+ // Example: Track allocated chunks (for diagnostics, pooling, or resource limits)
+ private static readonly ConcurrentDictionary _allocatedChunks = new();
+ private static readonly object _lock = new();
+ private static readonly HashSet _activeChunks = new();
+
+ ///
+ /// Called when a chunk is allocated/loaded into memory.
+ /// Tracks the chunk and can be extended for pooling or resource limits.
+ ///
+ public static void AllocateChunk(IChunk chunk)
+ {
+ lock (_lock)
+ {
+ _activeChunks.Add(chunk);
+ _allocatedChunks[chunk] = DateTime.UtcNow;
+ }
+ }
+
+ ///
+ /// Called when a chunk is released/unloaded from memory.
+ /// Removes the chunk from tracking and can be extended for pooling or cleanup.
+ ///
+ public static void ReleaseChunk(IChunk chunk)
+ {
+ lock (_lock)
+ {
+ _activeChunks.Remove(chunk);
+ _allocatedChunks.TryRemove(chunk, out _);
+ }
+ }
+
+ ///
+ /// Gets the current number of allocated chunks.
+ ///
+ public static int AllocatedChunkCount => _allocatedChunks.Count;
+
+ ///
+ /// Clears all tracked chunks (for diagnostics or shutdown).
+ ///
+ public static void Clear()
+ {
+ _allocatedChunks.Clear();
+ lock (_lock)
+ {
+ _activeChunks.Clear();
+ }
+ }
+
+ ///
+ /// Gets the current count of active chunks being tracked by the resource manager.
+ ///
+ /// The number of active chunks
+ public static int GetActiveChunkCount()
+ {
+ lock (_lock)
+ {
+ return _activeChunks.Count;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs b/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs
new file mode 100644
index 0000000..d615a1c
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AdvChkSys.Chunk;
+
+namespace AdvChkSys.Serialization
+{
+ ///
+ /// Provides serialization and deserialization for chunk instances.
+ /// Supports Chunk2D T and Chunk3D T with primitive types (e.g., byte, int, float).
+ /// .
+ public static class ChunkSerializer
+ {
+ // -------- 2D --------
+
+ ///
+ /// Serializes a 2D chunk to a byte array.
+ ///
+ /// The type of data stored in the chunk
+ /// The chunk to serialize
+ /// Serialized byte array
+ public static byte[] Serialize2D(Chunk2D chunk) where T : struct
+ {
+ using var ms = new MemoryStream();
+ using var writer = new BinaryWriter(ms);
+
+ writer.Write(chunk.X);
+ writer.Write(chunk.Y);
+ writer.Write(chunk.Width);
+ writer.Write(chunk.Height);
+
+ writer.Write(chunk.Metadata.Count);
+ foreach (var kvp in chunk.Metadata)
+ {
+ writer.Write(kvp.Key);
+ writer.Write(kvp.Value?.ToString() ?? "");
+ }
+
+ for (int x = 0; x < chunk.Width; x++)
+ for (int y = 0; y < chunk.Height; y++)
+ WritePrimitive(writer, chunk[x, y]);
+
+ writer.Flush();
+ return ms.ToArray();
+ }
+
+ ///
+ /// Deserializes a byte array into a 2D chunk.
+ ///
+ /// The type of data stored in the chunk
+ /// The serialized chunk data
+ /// Deserialized chunk
+ public static Chunk2D Deserialize2D(byte[] data) where T : struct
+ {
+ using var ms = new MemoryStream(data);
+ using var reader = new BinaryReader(ms);
+
+ int x = reader.ReadInt32();
+ int y = reader.ReadInt32();
+ int width = reader.ReadInt32();
+ int height = reader.ReadInt32();
+
+ var chunk = new Chunk2D(x, y, width, height);
+
+ int metaCount = reader.ReadInt32();
+ for (int i = 0; i < metaCount; i++)
+ {
+ string key = reader.ReadString();
+ string value = reader.ReadString();
+ chunk.Metadata[key] = value;
+ }
+
+ for (int ix = 0; ix < width; ix++)
+ for (int iy = 0; iy < height; iy++)
+ chunk[ix, iy] = ReadPrimitive(reader);
+
+ return chunk;
+ }
+
+ // -------- 3D --------
+
+ ///
+ /// Serializes a 3D chunk to a byte array.
+ ///
+ /// The type of data stored in the chunk
+ /// The chunk to serialize
+ /// Serialized byte array
+ public static byte[] Serialize3D(Chunk3D chunk) where T : struct
+ {
+ using var ms = new MemoryStream();
+ using var writer = new BinaryWriter(ms);
+
+ writer.Write(chunk.X);
+ writer.Write(chunk.Y);
+ writer.Write(chunk.Z);
+ writer.Write(chunk.Width);
+ writer.Write(chunk.Height);
+ writer.Write(chunk.Depth);
+
+ writer.Write(chunk.Metadata.Count);
+ foreach (var kvp in chunk.Metadata)
+ {
+ writer.Write(kvp.Key);
+ writer.Write(kvp.Value?.ToString() ?? "");
+ }
+
+ for (int x = 0; x < chunk.Width; x++)
+ for (int y = 0; y < chunk.Height; y++)
+ for (int z = 0; z < chunk.Depth; z++)
+ WritePrimitive(writer, chunk[x, y, z]);
+
+ writer.Flush();
+ return ms.ToArray();
+ }
+
+ ///
+ /// Deserializes a byte array into a 3D chunk.
+ ///
+ /// The type of data stored in the chunk
+ /// The serialized chunk data
+ /// Deserialized chunk
+ public static Chunk3D Deserialize3D(byte[] data) where T : struct
+ {
+ using var ms = new MemoryStream(data);
+ using var reader = new BinaryReader(ms);
+
+ int x = reader.ReadInt32();
+ int y = reader.ReadInt32();
+ int z = reader.ReadInt32();
+ int width = reader.ReadInt32();
+ int height = reader.ReadInt32();
+ int depth = reader.ReadInt32();
+
+ var chunk = new Chunk3D(x, y, z, width, height, depth);
+
+ int metaCount = reader.ReadInt32();
+ for (int i = 0; i < metaCount; i++)
+ {
+ string key = reader.ReadString();
+ string value = reader.ReadString();
+ chunk.Metadata[key] = value;
+ }
+
+ for (int ix = 0; ix < width; ix++)
+ for (int iy = 0; iy < height; iy++)
+ for (int iz = 0; iz < depth; iz++)
+ chunk[ix, iy, iz] = ReadPrimitive(reader);
+
+ return chunk;
+ }
+
+ // -------- Helpers --------
+
+ private static void WritePrimitive(BinaryWriter writer, T value) where T : struct
+ {
+ if (typeof(T) == typeof(byte))
+ writer.Write((byte)(object)value);
+ else if (typeof(T) == typeof(int))
+ writer.Write((int)(object)value);
+ else if (typeof(T) == typeof(float))
+ writer.Write((float)(object)value);
+ else if (typeof(T) == typeof(double))
+ writer.Write((double)(object)value);
+ else if (typeof(T) == typeof(short))
+ writer.Write((short)(object)value);
+ else if (typeof(T) == typeof(long))
+ writer.Write((long)(object)value);
+ else if (typeof(T) == typeof(bool))
+ writer.Write((bool)(object)value);
+ else
+ throw new NotSupportedException($"Type {typeof(T)} is not supported for serialization.");
+ }
+
+ private static T ReadPrimitive(BinaryReader reader) where T : struct
+ {
+ if (typeof(T) == typeof(byte))
+ return (T)(object)reader.ReadByte();
+ else if (typeof(T) == typeof(int))
+ return (T)(object)reader.ReadInt32();
+ else if (typeof(T) == typeof(float))
+ return (T)(object)reader.ReadSingle();
+ else if (typeof(T) == typeof(double))
+ return (T)(object)reader.ReadDouble();
+ else if (typeof(T) == typeof(short))
+ return (T)(object)reader.ReadInt16();
+ else if (typeof(T) == typeof(long))
+ return (T)(object)reader.ReadInt64();
+ else if (typeof(T) == typeof(bool))
+ return (T)(object)reader.ReadBoolean();
+ else
+ throw new NotSupportedException($"Type {typeof(T)} is not supported for deserialization.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs b/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs
new file mode 100644
index 0000000..c37ec4e
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs
@@ -0,0 +1,40 @@
+#nullable enable
+using AdvChkSys.Chunk;
+
+namespace AdvChkSys.Spatial
+{
+ ///
+ /// Provides extension methods for chunks to work with spatial indexing.
+ ///
+ public static class ChunkExtensions
+ {
+ ///
+ /// Converts a Chunk3D to an IChunk3D.
+ ///
+ public static IChunk3D AsIChunk3D(this Chunk3D chunk)
+ {
+ return new Chunk3DAdapter(chunk);
+ }
+
+ ///
+ /// Adapter to make Chunk3D implement IChunk3D.
+ ///
+ private class Chunk3DAdapter : IChunk3D
+ {
+ private readonly Chunk3D _chunk;
+
+ public Chunk3DAdapter(Chunk3D chunk)
+ {
+ _chunk = chunk;
+ }
+
+ public int X => _chunk.X;
+ public int Y => _chunk.Y;
+ public int Z => _chunk.Z;
+ public int Width => _chunk.Width;
+ public int Height => _chunk.Height;
+ public int Depth => _chunk.Depth;
+ public System.Collections.Generic.Dictionary Metadata => _chunk.Metadata;
+ }
+ }
+}
\ No newline at end of file
diff --git a/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs b/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs
new file mode 100644
index 0000000..53c02f5
--- /dev/null
+++ b/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs
@@ -0,0 +1,1225 @@
+#nullable enable
+using System;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Linq;
+using AdvChkSys.Interfaces;
+using AdvChkSys.Threading;
+
+
+namespace AdvChkSys.Spatial
+{
+ ///
+ /// Provides spatial indexing for efficient region and distance queries.
+ ///
+ public class SpatialChunkIndex where T : IChunk
+ {
+ // Grid-based spatial index for fast lookups
+ private readonly Dictionary<(int, int), HashSet> _grid = new();
+
+ // All chunks in the index for iteration
+ private readonly HashSet _allChunks = new();
+
+ // Lock for thread safety
+ private readonly object _lock = new();
+
+ ///
+ /// Adds a chunk to the spatial index.
+ ///
+ /// The chunk to add
+ public void AddChunk(T chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ // Add to all chunks set
+ _allChunks.Add(chunk);
+
+ // Add to grid cells
+ var key = (chunk.X, chunk.Y);
+ if (!_grid.TryGetValue(key, out var chunks))
+ {
+ chunks = new HashSet();
+ _grid[key] = chunks;
+ }
+ chunks.Add(chunk);
+ }
+ }
+
+ ///
+ /// Removes a chunk from the spatial index.
+ ///
+ /// The chunk to remove
+ /// True if the chunk was found and removed, false otherwise
+ public bool RemoveChunk(T chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ // Remove from all chunks set
+ bool removed = _allChunks.Remove(chunk);
+
+ // Remove from grid cells
+ var key = (chunk.X, chunk.Y);
+ if (_grid.TryGetValue(key, out var chunks))
+ {
+ chunks.Remove(chunk);
+ if (chunks.Count == 0)
+ {
+ _grid.Remove(key);
+ }
+ }
+
+ return removed;
+ }
+ }
+
+ ///
+ /// Finds all chunks within a rectangular region.
+ ///
+ /// Minimum X coordinate
+ /// Minimum Y coordinate
+ /// Maximum X coordinate
+ /// Maximum Y coordinate
+ /// All chunks that intersect with the region
+ public IEnumerable FindChunksInRegion(int minX, int minY, int maxX, int maxY)
+ {
+ lock (_lock)
+ {
+ var result = new HashSet();
+
+ // Check each grid cell that overlaps with the region
+ for (int x = minX; x <= maxX; x++)
+ {
+ for (int y = minY; y <= maxY; y++)
+ {
+ var key = (x, y);
+ if (_grid.TryGetValue(key, out var chunks))
+ {
+ foreach (var chunk in chunks)
+ {
+ result.Add(chunk);
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Finds all chunks within a specified distance from a point.
+ ///
+ /// Center X coordinate
+ /// Center Y coordinate
+ /// Radius to search within
+ /// All chunks that are within the radius of the point
+ public IEnumerable FindChunksInRadius(int centerX, int centerY, float radius)
+ {
+ lock (_lock)
+ {
+ var result = new HashSet();
+ float radiusSquared = radius * radius;
+
+ // Calculate the bounding box of the circle
+ int minX = (int)(centerX - radius);
+ int minY = (int)(centerY - radius);
+ int maxX = (int)(centerX + radius);
+ int maxY = (int)(centerY + radius);
+
+ // Check each grid cell that might overlap with the circle
+ for (int x = minX; x <= maxX; x++)
+ {
+ for (int y = minY; y <= maxY; y++)
+ {
+ var key = (x, y);
+ if (_grid.TryGetValue(key, out var chunks))
+ {
+ foreach (var chunk in chunks)
+ {
+ // Calculate distance from chunk center to query center
+ float dx = chunk.X - centerX;
+ float dy = chunk.Y - centerY;
+ float distanceSquared = dx * dx + dy * dy;
+
+ // Check if the chunk is within the radius
+ if (distanceSquared <= radiusSquared)
+ {
+ result.Add(chunk);
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Finds the nearest chunk to a point.
+ ///
+ /// X coordinate
+ /// Y coordinate
+ /// The nearest chunk, or null if no chunks are in the index
+ public T? FindNearestChunk(int x, int y)
+ {
+ lock (_lock)
+ {
+ if (_allChunks.Count == 0)
+ return default;
+
+ T? nearest = default;
+ float nearestDistanceSquared = float.MaxValue;
+
+ foreach (var chunk in _allChunks)
+ {
+ float dx = chunk.X - x;
+ float dy = chunk.Y - y;
+ float distanceSquared = dx * dx + dy * dy;
+
+ if (distanceSquared < nearestDistanceSquared)
+ {
+ nearestDistanceSquared = distanceSquared;
+ nearest = chunk;
+ }
+ }
+
+ return nearest;
+ }
+ }
+
+ ///
+ /// Finds all chunks that contain a point.
+ ///
+ /// X coordinate
+ /// Y coordinate
+ /// All chunks that contain the point
+ public IEnumerable FindChunksContainingPoint(int x, int y)
+ {
+ lock (_lock)
+ {
+ var result = new HashSet();
+
+ // Get the grid cell for this point
+ var key = (x, y);
+ if (_grid.TryGetValue(key, out var chunks))
+ {
+ foreach (var chunk in chunks)
+ {
+ // Check if the point is within the chunk bounds
+ if (x >= chunk.X && x < chunk.X + chunk.Width &&
+ y >= chunk.Y && y < chunk.Y + chunk.Height)
+ {
+ result.Add(chunk);
+ }
+ }
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Gets all chunks in the index.
+ ///
+ public IEnumerable GetAllChunks()
+ {
+ lock (_lock)
+ {
+ return _allChunks.ToList();
+ }
+ }
+
+ ///
+ /// Clears all chunks from the index.
+ ///
+ public void Clear()
+ {
+ lock (_lock)
+ {
+ _allChunks.Clear();
+ _grid.Clear();
+ }
+ }
+
+ ///
+ /// Gets the number of chunks in the index.
+ ///
+ public int Count
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _allChunks.Count;
+ }
+ }
+ }
+
+ ///
+ /// Checks if a chunk is in the index.
+ ///
+ /// The chunk to check
+ /// True if the chunk is in the index, false otherwise
+ public bool Contains(T chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ return _allChunks.Contains(chunk);
+ }
+ }
+
+ ///
+ /// Updates the position of a chunk in the spatial index.
+ ///
+ /// The chunk to update
+ /// The old X coordinate
+ /// The old Y coordinate
+ /// True if the chunk was found and updated, false otherwise
+ public bool UpdateChunkPosition(T chunk, int oldX, int oldY)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ // Remove from old grid cell
+ var oldKey = (oldX, oldY);
+ bool removed = false;
+
+ if (_grid.TryGetValue(oldKey, out var chunks))
+ {
+ removed = chunks.Remove(chunk);
+ if (chunks.Count == 0)
+ {
+ _grid.Remove(oldKey);
+ }
+ }
+
+ if (!removed)
+ return false;
+
+ // Add to new grid cell
+ var newKey = (chunk.X, chunk.Y);
+ if (!_grid.TryGetValue(newKey, out var newChunks))
+ {
+ newChunks = new HashSet();
+ _grid[newKey] = newChunks;
+ }
+ newChunks.Add(chunk);
+
+ return true;
+ }
+ }
+
+ ///
+ /// Performs a spatial query using a custom filter.
+ ///
+ /// The filter function to apply to each chunk
+ /// All chunks that pass the filter
+ public IEnumerable Query(Func filter)
+ {
+ if (filter == null)
+ throw new ArgumentNullException(nameof(filter));
+
+ lock (_lock)
+ {
+ return _allChunks.Where(filter).ToList();
+ }
+ }
+
+ ///
+ /// Finds all chunks that intersect with a given chunk.
+ ///
+ /// The chunk to check for intersections
+ /// All chunks that intersect with the given chunk
+ public IEnumerable FindIntersectingChunks(IChunk chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ return FindChunksInRegion(
+ chunk.X,
+ chunk.Y,
+ chunk.X + chunk.Width - 1,
+ chunk.Y + chunk.Height - 1);
+ }
+
+ ///
+ /// Finds all chunks that are neighbors of a given chunk.
+ ///
+ /// The chunk to find neighbors for
+ /// Whether to include diagonal neighbors
+ /// All neighboring chunks
+ public IEnumerable FindNeighbors(IChunk chunk, bool includeDiagonals = false)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ var result = new HashSet();
+
+ // Check the four adjacent cells
+ CheckNeighbor(chunk.X - 1, chunk.Y, result);
+ CheckNeighbor(chunk.X + 1, chunk.Y, result);
+ CheckNeighbor(chunk.X, chunk.Y - 1, result);
+ CheckNeighbor(chunk.X, chunk.Y + 1, result);
+
+ // Check diagonal cells if requested
+ if (includeDiagonals)
+ {
+ CheckNeighbor(chunk.X - 1, chunk.Y - 1, result);
+ CheckNeighbor(chunk.X + 1, chunk.Y - 1, result);
+ CheckNeighbor(chunk.X - 1, chunk.Y + 1, result);
+ CheckNeighbor(chunk.X + 1, chunk.Y + 1, result);
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Helper method to check for chunks at a specific grid cell.
+ ///
+ private void CheckNeighbor(int x, int y, HashSet result)
+ {
+ var key = (x, y);
+ if (_grid.TryGetValue(key, out var chunks))
+ {
+ foreach (var chunk in chunks)
+ {
+ result.Add(chunk);
+ }
+ }
+ }
+
+ ///
+ /// Performs a spatial operation on chunks in parallel.
+ ///
+ /// The operation to perform on each chunk
+ /// Optional filter to apply before processing
+ /// Maximum degree of parallelism (null for default)
+ /// A task that completes when all operations are done
+ public Task ProcessChunksParallelAsync(
+ Action operation,
+ Func? filter = null,
+ int? maxDegreeOfParallelism = null)
+ {
+ if (operation == null)
+ throw new ArgumentNullException(nameof(operation));
+
+ IEnumerable chunksToProcess;
+
+ lock (_lock)
+ {
+ chunksToProcess = filter != null
+ ? _allChunks.Where(filter).ToList()
+ : _allChunks.ToList();
+ }
+
+ return ChunkTaskScheduler.RunBatchParallelAsync(
+ chunksToProcess.Select(chunk => new Action(() => operation(chunk))).ToArray(),
+ maxDegreeOfParallelism);
+ }
+
+ ///
+ /// Finds all chunks in a region and performs an operation on them in parallel.
+ ///
+ /// Minimum X coordinate
+ /// Minimum Y coordinate
+ /// Maximum X coordinate
+ /// Maximum Y coordinate
+ /// The operation to perform on each chunk
+ /// Maximum degree of parallelism (null for default)
+ /// A task that completes when all operations are done
+ public Task ProcessRegionParallelAsync(
+ int minX, int minY, int maxX, int maxY,
+ Action operation,
+ int? maxDegreeOfParallelism = null)
+ {
+ if (operation == null)
+ throw new ArgumentNullException(nameof(operation));
+
+ var chunksInRegion = FindChunksInRegion(minX, minY, maxX, maxY).ToList();
+
+ return ChunkTaskScheduler.RunBatchParallelAsync(
+ chunksInRegion.Select(chunk => new Action(() => operation(chunk))).ToArray(),
+ maxDegreeOfParallelism);
+ }
+
+ ///
+ /// Creates a quadtree-based spatial index for more efficient queries.
+ ///
+ /// Minimum X coordinate of the world
+ /// Minimum Y coordinate of the world
+ /// Maximum X coordinate of the world
+ /// Maximum Y coordinate of the world
+ /// A new quadtree-based spatial index
+ public QuadtreeSpatialIndex CreateQuadtreeIndex(int minX, int minY, int maxX, int maxY)
+ {
+ var quadtree = new QuadtreeSpatialIndex(minX, minY, maxX, maxY);
+
+ lock (_lock)
+ {
+ foreach (var chunk in _allChunks)
+ {
+ quadtree.AddChunk(chunk);
+ }
+ }
+
+ return quadtree;
+ }
+ }
+
+ ///
+ /// Provides a quadtree-based spatial index for more efficient region queries.
+ ///
+ public class QuadtreeSpatialIndex where T : IChunk
+ {
+ private readonly QuadtreeNode _root;
+ private readonly HashSet _allChunks = new();
+ private readonly object _lock = new();
+
+ ///
+ /// Initializes a new instance of the QuadtreeSpatialIndex class.
+ ///
+ /// Minimum X coordinate of the world
+ /// Minimum Y coordinate of the world
+ /// Maximum X coordinate of the world
+ /// Maximum Y coordinate of the world
+ public QuadtreeSpatialIndex(int minX, int minY, int maxX, int maxY)
+ {
+ _root = new QuadtreeNode(minX, minY, maxX, maxY);
+ }
+
+ ///
+ /// Adds a chunk to the quadtree.
+ ///
+ /// The chunk to add
+ public void AddChunk(T chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ _allChunks.Add(chunk);
+ _root.Insert(chunk);
+ }
+ }
+
+ ///
+ /// Removes a chunk from the quadtree.
+ ///
+ /// The chunk to remove
+ /// True if the chunk was found and removed, false otherwise
+ public bool RemoveChunk(T chunk)
+ {
+ if (chunk == null)
+ throw new ArgumentNullException(nameof(chunk));
+
+ lock (_lock)
+ {
+ bool removed = _allChunks.Remove(chunk);
+ if (removed)
+ {
+ _root.Remove(chunk);
+ }
+ return removed;
+ }
+ }
+
+ ///
+ /// Finds all chunks within a rectangular region.
+ ///
+ /// Minimum X coordinate
+ /// Minimum Y coordinate
+ /// Maximum X coordinate
+ /// Maximum Y coordinate
+ /// All chunks that intersect with the region
+ public IEnumerable FindChunksInRegion(int minX, int minY, int maxX, int maxY)
+ {
+ lock (_lock)
+ {
+ var result = new HashSet();
+ _root.Query(minX, minY, maxX, maxY, result);
+ return result;
+ }
+ }
+
+ ///
+ /// Gets all chunks in the quadtree.
+ ///
+ public IEnumerable GetAllChunks()
+ {
+ lock (_lock)
+ {
+ return _allChunks.ToList();
+ }
+ }
+
+ ///
+ /// Clears all chunks from the quadtree.
+ ///
+ public void Clear()
+ {
+ lock (_lock)
+ {
+ _allChunks.Clear();
+ _root.Clear();
+ }
+ }
+
+ ///
+ /// Gets the number of chunks in the quadtree.
+ ///
+ public int Count
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _allChunks.Count;
+ }
+ }
+ }
+
+ ///
+ /// Represents a node in the quadtree.
+ ///
+ private class QuadtreeNode
+ {
+ // Boundary of this node
+ private readonly int _minX, _minY, _maxX, _maxY;
+
+ // Children nodes (NW, NE, SW, SE)
+ private QuadtreeNode? _northWest;
+ private QuadtreeNode? _northEast;
+ private QuadtreeNode? _southWest;
+ private QuadtreeNode? _southEast;
+
+ // Chunks in this node
+ private readonly HashSet _chunks = new();
+
+ // Maximum number of chunks before splitting
+ private const int MAX_CHUNKS = 8;
+
+ ///
+ /// Initializes a new instance of the QuadtreeNode class.
+ ///
+ public QuadtreeNode(int minX, int minY, int maxX, int maxY)
+ {
+ _minX = minX;
+ _minY = minY;
+ _maxX = maxX;
+ _maxY = maxY;
+ }
+
+ ///
+ /// Inserts a chunk into the quadtree.
+ ///
+ public void Insert(T chunk)
+ {
+ // Check if the chunk is within this node's boundary
+ if (!Intersects(chunk))
+ return;
+
+ // If we haven't split yet and have room, add the chunk to this node
+ if (_northWest == null && _chunks.Count < MAX_CHUNKS)
+ {
+ _chunks.Add(chunk);
+ return;
+ }
+
+ // If we haven't split yet but need to, split the node
+ if (_northWest == null)
+ {
+ Split();
+ }
+
+ // Try to insert the chunk into the children
+ _northWest?.Insert(chunk);
+ _northEast?.Insert(chunk);
+ _southWest?.Insert(chunk);
+ _southEast?.Insert(chunk);
+ }
+
+ ///
+ /// Removes a chunk from the quadtree.
+ ///
+ public bool Remove(T chunk)
+ {
+ // Check if the chunk is within this node's boundary
+ if (!Intersects(chunk))
+ return false;
+
+ // Try to remove from this node
+ bool removed = _chunks.Remove(chunk);
+
+ // If we have children, try to remove from them too
+ if (_northWest != null)
+ {
+ removed |= _northWest.Remove(chunk);
+ removed |= _northEast!.Remove(chunk);
+ removed |= _southWest!.Remove(chunk);
+ removed |= _southEast!.Remove(chunk);
+ }
+
+ return removed;
+ }
+
+ ///
+ /// Queries the quadtree for chunks in a region.
+ ///
+ public void Query(int minX, int minY, int maxX, int maxY, HashSet result)
+ {
+ // Check if the query region intersects this node
+ if (maxX < _minX || minX > _maxX || maxY < _minY || minY > _maxY)
+ return;
+
+ // Add chunks from this node that intersect the query region
+ foreach (var chunk in _chunks)
+ {
+ if (chunk.X <= maxX && chunk.X + chunk.Width >= minX &&
+ chunk.Y <= maxY && chunk.Y + chunk.Height >= minY)
+ {
+ result.Add(chunk);
+ }
+ }
+
+ // If we have children, query them too
+ if (_northWest != null)
+ {
+ _northWest.Query(minX, minY, maxX, maxY, result);
+ _northEast!.Query(minX, minY, maxX, maxY, result);
+ _southWest!.Query(minX, minY, maxX, maxY, result);
+ _southEast!.Query(minX, minY, maxX, maxY, result);
+ }
+ }
+
+ ///
+ /// Splits this node into four children.
+ ///
+ private void Split()
+ {
+ int midX = (_minX + _maxX) / 2;
+ int midY = (_minY + _maxY) / 2;
+
+ // Don't split if the node is too small
+ if (midX == _minX || midY == _minY)
+ return;
+
+ _northWest = new QuadtreeNode(_minX, _minY, midX, midY);
+ _northEast = new QuadtreeNode(midX, _minY, _maxX, midY);
+ _southWest = new QuadtreeNode(_minX, midY, midX, _maxY);
+ _southEast = new QuadtreeNode(midX, midY, _maxX, _maxY);
+
+ // Redistribute chunks to children
+ var chunksToRedistribute = new List(_chunks);
+ _chunks.Clear();
+
+ foreach (var chunk in chunksToRedistribute)
+ {
+ Insert(chunk);
+ }
+ }
+
+ ///
+ /// Checks if a chunk intersects with this node's boundary.
+ ///
+ private bool Intersects(T chunk)
+ {
+ return chunk.X <= _maxX &&
+ chunk.X + chunk.Width >= _minX &&
+ chunk.Y <= _maxY &&
+ chunk.Y + chunk.Height >= _minY;
+ }
+
+ ///
+ /// Clears all chunks from this node and its children.
+ ///
+ public void Clear()
+ {
+ _chunks.Clear();
+
+ if (_northWest != null)
+ {
+ _northWest.Clear();
+ _northEast!.Clear();
+ _southWest!.Clear();
+ _southEast!.Clear();
+
+ _northWest = null;
+ _northEast = null;
+ _southWest = null;
+ _southEast = null;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Provides a spatial index for 3D chunks.
+ ///
+ public class SpatialChunkIndex3D where T : IChunk3D
+ {
+ // Grid-based spatial index for fast lookups
+ private readonly Dictionary<(int, int, int), HashSet> _grid = new();
+
+ // All chunks in the index for iteration
+ private readonly HashSet