diff --git a/.gitignore b/.gitignore
index 6f1430b..a4b762a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ obj/
*.user
.vs/
publish/
+.vscode/launch.json
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..dec90ef
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,17 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/MapExplorer.App/MapExplorer.App.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
diff --git a/src/MapExplorer.App/Converters/BlockColorConverter.cs b/src/MapExplorer.App/Converters/BlockColorConverter.cs
new file mode 100644
index 0000000..aee4fde
--- /dev/null
+++ b/src/MapExplorer.App/Converters/BlockColorConverter.cs
@@ -0,0 +1,25 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using RenderColors = MapExplorer.Rendering.Colors;
+
+namespace MapExplorer.App.Converters;
+
+/// Converts a block name (e.g. "minecraft:dirt") to its curated swatch color — used by the
+/// edit-mode material palette/search list/recents, which bind directly to plain block-name strings
+/// rather than a wrapping view-model per entry (unlike OreOption, this list is filtered/reordered
+/// live by search text and MRU order, so a lightweight converter is simpler than keeping a parallel
+/// ObservableCollection of wrapper objects in sync).
+public sealed class BlockColorConverter : IValueConverter
+{
+ public static readonly BlockColorConverter Instance = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var rgb = RenderColors.GetBlockColor(value as string ?? "minecraft:air");
+ return new SolidColorBrush(Color.FromRgb(rgb.R, rgb.G, rgb.B));
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/src/MapExplorer.App/Converters/SavingLabelConverter.cs b/src/MapExplorer.App/Converters/SavingLabelConverter.cs
new file mode 100644
index 0000000..0bdab1a
--- /dev/null
+++ b/src/MapExplorer.App/Converters/SavingLabelConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace MapExplorer.App.Converters;
+
+/// Save button label: "Saving…" while EditViewModel.SaveCommand is running, "Save" otherwise.
+public sealed class SavingLabelConverter : IValueConverter
+{
+ public static readonly SavingLabelConverter Instance = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? "Saving…" : "Save";
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/src/MapExplorer.App/MainWindow.axaml b/src/MapExplorer.App/MainWindow.axaml
index 6ad0844..8f4e6ce 100644
--- a/src/MapExplorer.App/MainWindow.axaml
+++ b/src/MapExplorer.App/MainWindow.axaml
@@ -40,6 +40,8 @@
IsEnabled="{Binding IsEndAvailable}" ToolTip.Tip="{Binding EndTooltip}" />
+
@@ -47,6 +49,9 @@
+
+
+
diff --git a/src/MapExplorer.App/MainWindow.axaml.cs b/src/MapExplorer.App/MainWindow.axaml.cs
index a1c6f3b..c82b925 100644
--- a/src/MapExplorer.App/MainWindow.axaml.cs
+++ b/src/MapExplorer.App/MainWindow.axaml.cs
@@ -23,6 +23,8 @@ public MainWindow()
MapCanvas.Config = _viewModel.Map.BuildLayerConfig();
_viewModel.Map.RenderConfigChanged += () => MapCanvas.Config = _viewModel.Map.BuildLayerConfig();
+ MapCanvas.EditContext = _viewModel.Edit;
+ _viewModel.Edit.Saved += ResummarizeDirtyChunks;
MapCanvas.HoveredBlockChanged += OnHoveredBlockChanged;
MapCanvas.ZoomChanged += OnZoomChanged;
_viewModel.RegionDirRequested += async regionDir =>
@@ -70,6 +72,8 @@ private void OnDimensionClicked(object? sender, Avalonia.Interactivity.RoutedEve
private void OnPlayersClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _viewModel.TogglePlayersPanel();
+ private void OnEditClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _viewModel.ToggleEditMode();
+
private void OnHoveredBlockChanged(HoveredBlock? block)
{
Dispatcher.UIThread.Post(() =>
@@ -106,6 +110,7 @@ private async Task LoadWorld(string regionDir, CancellationToken cancellationTok
var blockNames = new NamePalette();
var biomeNames = new NamePalette();
var chunkStore = new WorldChunkStore(regionDir);
+ _viewModel.Edit.SetRegionDir(regionDir, chunkStore);
// WorldLoader.Load only hands back one summary per chunk (the return value below), so the
// ore summary is captured as a side effect of the same summarize callback instead of a
@@ -197,4 +202,26 @@ private async Task LoadWorld(string regionDir, CancellationToken cancellationTok
map.StatusText = $"Loaded {result.RegionCount} regions, {result.Chunks.Count} chunks in {sw.ElapsedMilliseconds}ms " +
$"(threads: {Environment.ProcessorCount})";
}
+
+ /// After a successful edit-mode Save, refreshes the always-resident summary/ore data for
+ /// just the chunks that changed, so Surface/Heightmap/ore-overlay views reflect the edit without
+ /// requiring a full world reload. Slice-mode rendering doesn't need this at all — it already
+ /// renders straight from the edit overlay regardless of save state (see MapCanvasControl).
+ private void ResummarizeDirtyChunks()
+ {
+ var world = MapCanvas.World;
+ if (world is null) return;
+
+ foreach (var (cx, cz) in _viewModel.Edit.Overlay.DirtyChunks)
+ {
+ world.ChunkStore.InvalidateChunk(cx, cz);
+ var chunkData = world.ChunkStore.GetOrDecode(cx, cz);
+ if (chunkData is null) continue;
+
+ world.Summaries[(cx, cz)] = ChunkSummaryBuilder.Build(chunkData, world.BlockNames, world.BiomeNames);
+ world.OreSummaries[(cx, cz)] = OreSummaryBuilder.Build(chunkData);
+ }
+
+ MapCanvas.NotifyChunksGrew();
+ }
}
diff --git a/src/MapExplorer.App/ViewModels/EditViewModel.cs b/src/MapExplorer.App/ViewModels/EditViewModel.cs
new file mode 100644
index 0000000..e0b03fc
--- /dev/null
+++ b/src/MapExplorer.App/ViewModels/EditViewModel.cs
@@ -0,0 +1,277 @@
+using System.Collections.ObjectModel;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MapExplorer.Core.World;
+using RenderColors = MapExplorer.Rendering.Colors;
+
+namespace MapExplorer.App.ViewModels;
+
+public enum EditTool
+{
+ Paint,
+ Fill,
+ Copy,
+ Paste,
+ Picker
+}
+
+public enum MaterialSlot
+{
+ Left,
+ Right
+}
+
+/// A copied rectangle of block names, sized Width (X) x Depth (Z), captured at the layer
+/// that was active at copy time — Paste always writes it into the currently active layer.
+public sealed record ClipboardData(int Width, int Depth, string[,] Blocks);
+
+/// One row in the material palette/search list/recents — mirrors OreOption's shape (a swatch
+/// color alongside the raw data), sharing the single AssignMaterialCommand instance rather than each
+/// row carrying its own, since selecting any row does exactly the same thing.
+public sealed class MaterialOption
+{
+ public required string BlockName { get; init; }
+ public required IBrush SwatchBrush { get; init; }
+ public required IRelayCommand AssignCommand { get; init; }
+}
+
+///
+/// Edit-mode state: which tool is active, the two paintable materials (left/right click), the
+/// searchable material palette, recent materials, and undo/redo/save — wraps a Core EditOverlay the
+/// same way MapViewModel wraps a LayerConfig. Owned by MainViewModel alongside Map/Players.
+///
+public partial class EditViewModel : ObservableObject
+{
+ private const int MaxRecentMaterials = 10;
+
+ public EditOverlay Overlay { get; } = new();
+
+ public ObservableCollection RecentMaterials { get; } = [];
+
+ /// Every block this app can paint — reuses the curated color table (Colors.BlockColors)
+ /// rather than a separate ~1000-entry vanilla block registry, since every paintable material
+ /// needs a swatch color anyway.
+ public IReadOnlyList AllMaterials { get; }
+
+ private readonly Dictionary _materialOptionsByName;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPaintTool))]
+ [NotifyPropertyChangedFor(nameof(IsFillTool))]
+ [NotifyPropertyChangedFor(nameof(IsCopyTool))]
+ [NotifyPropertyChangedFor(nameof(IsPasteTool))]
+ [NotifyPropertyChangedFor(nameof(IsPickerTool))]
+ private EditTool _selectedTool = EditTool.Paint;
+
+ public bool IsPaintTool => SelectedTool == EditTool.Paint;
+ public bool IsFillTool => SelectedTool == EditTool.Fill;
+ public bool IsCopyTool => SelectedTool == EditTool.Copy;
+ public bool IsPasteTool => SelectedTool == EditTool.Paste;
+ public bool IsPickerTool => SelectedTool == EditTool.Picker;
+
+ [ObservableProperty]
+ private bool _isEditModeOn;
+
+ [ObservableProperty]
+ private string _primaryMaterial = "minecraft:dirt";
+
+ [ObservableProperty]
+ private string _secondaryMaterial = "minecraft:air";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsLeftArmed))]
+ [NotifyPropertyChangedFor(nameof(IsRightArmed))]
+ private MaterialSlot _armedSlot = MaterialSlot.Left;
+
+ public bool IsLeftArmed => ArmedSlot == MaterialSlot.Left;
+ public bool IsRightArmed => ArmedSlot == MaterialSlot.Right;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(FilteredMaterials))]
+ private string _materialSearchText = "";
+
+ [ObservableProperty]
+ private string _statusText = "";
+
+ // Set only while SaveCommand is running (see Save()) — separate from StatusText, which holds the
+ // final result message once a save finishes, so the two don't clobber each other mid-save.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SaveProgressText))]
+ private int _saveProgressCurrent;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(SaveProgressText))]
+ private int _saveProgressTotal;
+
+ public string SaveProgressText => SaveProgressTotal > 0 ? $"Saving… {SaveProgressCurrent}/{SaveProgressTotal} chunks" : "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasClipboard))]
+ private ClipboardData? _clipboard;
+
+ public bool HasClipboard => Clipboard is not null;
+
+ public bool HasRecentMaterials => RecentMaterials.Count > 0;
+
+ public string? RegionDir { get; private set; }
+
+ public bool CanUndo => Overlay.CanUndo;
+ public bool CanRedo => Overlay.CanRedo;
+
+ public IEnumerable FilteredMaterials =>
+ string.IsNullOrWhiteSpace(MaterialSearchText)
+ ? AllMaterials
+ : AllMaterials.Where(m => m.BlockName.Contains(MaterialSearchText, StringComparison.OrdinalIgnoreCase));
+
+ /// Raised when IsEditModeOn turns on — MainViewModel forces Y-Slice mode in response,
+ /// since editing always targets a single layer.
+ public event Action? EditModeEnabled;
+
+ /// Raised after a Save that actually wrote something — MainWindow re-summarizes the
+ /// affected chunks so Surface/Heightmap/ore views reflect the edit without a full reload.
+ public event Action? Saved;
+
+ public EditViewModel()
+ {
+ Overlay.ChunksInvalidated += _ => RaiseUndoRedoChanged();
+ Overlay.Reset += RaiseUndoRedoChanged;
+
+ AllMaterials = [.. RenderColors.BlockColors.Keys
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .Select(name => new MaterialOption
+ {
+ BlockName = name,
+ SwatchBrush = SwatchFor(name),
+ AssignCommand = AssignMaterialCommand
+ })];
+ _materialOptionsByName = AllMaterials.ToDictionary(o => o.BlockName);
+ RecentMaterials.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRecentMaterials));
+ }
+
+ private static IBrush SwatchFor(string blockName)
+ {
+ var rgb = RenderColors.GetBlockColor(blockName);
+ return new SolidColorBrush(Color.FromRgb(rgb.R, rgb.G, rgb.B));
+ }
+
+ partial void OnIsEditModeOnChanged(bool value)
+ {
+ if (value) EditModeEnabled?.Invoke();
+ }
+
+ /// Called whenever the active world/dimension changes — rebinds the overlay to the new
+ /// region's chunk store (discarding any unsaved edits from the previous one, which are keyed by
+ /// coordinates that mean something different there) and remembers where Save should write to.
+ public void SetRegionDir(string regionDir, WorldChunkStore chunkStore)
+ {
+ RegionDir = regionDir;
+ Overlay.RebindWorld(chunkStore);
+ Clipboard = null;
+ StatusText = "";
+ }
+
+ private void RaiseUndoRedoChanged()
+ {
+ OnPropertyChanged(nameof(CanUndo));
+ OnPropertyChanged(nameof(CanRedo));
+ UndoCommand.NotifyCanExecuteChanged();
+ RedoCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void SetTool(EditTool tool) => SelectedTool = tool;
+
+ [RelayCommand]
+ private void ArmLeft() => ArmedSlot = MaterialSlot.Left;
+
+ [RelayCommand]
+ private void ArmRight() => ArmedSlot = MaterialSlot.Right;
+
+ [RelayCommand]
+ private void AssignMaterial(string? blockName)
+ {
+ if (string.IsNullOrEmpty(blockName)) return;
+ AssignToSlot(blockName, ArmedSlot);
+ }
+
+ /// Also used directly by the eyedropper tool (MapCanvasControl), which knows which
+ /// button/slot was clicked without going through the "armed slot" UI concept.
+ public void AssignToSlot(string blockName, MaterialSlot slot)
+ {
+ if (slot == MaterialSlot.Left) PrimaryMaterial = blockName;
+ else SecondaryMaterial = blockName;
+ PushRecent(blockName);
+ }
+
+ public void PushRecent(string blockName)
+ {
+ // Materials painted via the eyedropper/AssignMaterial aren't guaranteed to be in the curated
+ // palette (e.g. picking up a block this app doesn't have a color for) — fall back to a
+ // synthesized option rather than dropping it from Recents.
+ if (!_materialOptionsByName.TryGetValue(blockName, out var option))
+ {
+ option = new MaterialOption { BlockName = blockName, SwatchBrush = SwatchFor(blockName), AssignCommand = AssignMaterialCommand };
+ }
+
+ var existing = RecentMaterials.FirstOrDefault(o => o.BlockName == blockName);
+ if (existing is not null) RecentMaterials.Remove(existing);
+ RecentMaterials.Insert(0, option);
+ while (RecentMaterials.Count > MaxRecentMaterials) RecentMaterials.RemoveAt(RecentMaterials.Count - 1);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanUndo))]
+ private void Undo()
+ {
+ if (SaveCommand.IsRunning) return;
+ Overlay.Undo();
+ }
+
+ [RelayCommand(CanExecute = nameof(CanRedo))]
+ private void Redo()
+ {
+ if (SaveCommand.IsRunning) return;
+ Overlay.Redo();
+ }
+
+ // [RelayCommand] on an async method generates an IAsyncRelayCommand whose IsRunning property is
+ // exactly the "save in progress" flag the UI needs (bind Button.Command to SaveCommand and it
+ // auto-disables for the duration too — CanExecute returns false while IsRunning is true by
+ // default) — no separate IsSaving flag needed.
+ [RelayCommand]
+ private async Task Save()
+ {
+ if (RegionDir is null) return;
+
+ var regionDir = RegionDir;
+ SaveProgressCurrent = 0;
+ SaveProgressTotal = Overlay.DirtyChunks.Count;
+ // Progress captures the UI thread's SynchronizationContext at construction, so each
+ // Report() below marshals back automatically — safe to assign these properties directly.
+ var progress = new Progress(p =>
+ {
+ SaveProgressCurrent = p.ChunksProcessed;
+ SaveProgressTotal = p.TotalChunks;
+ });
+
+ try
+ {
+ // WorldEditWriter.Save is synchronous file I/O/compression that can run long for a big
+ // batch of edits — off the UI thread so the window (and the progress bar above) keeps
+ // updating instead of freezing for the duration.
+ var result = await Task.Run(() => WorldEditWriter.Save(regionDir, Overlay, progress));
+ StatusText = result.Error is not null
+ ? $"Save failed: {result.Error}"
+ : result.ChunksWritten == 0
+ ? "Nothing to save"
+ : $"Saved {result.ChunksWritten} chunk(s) across {result.RegionsWritten} region file(s)";
+
+ if (result.Error is null && result.ChunksWritten > 0) Saved?.Invoke();
+ }
+ finally
+ {
+ SaveProgressCurrent = 0;
+ SaveProgressTotal = 0;
+ }
+ }
+}
diff --git a/src/MapExplorer.App/ViewModels/MainViewModel.cs b/src/MapExplorer.App/ViewModels/MainViewModel.cs
index 3cafe58..0273d0a 100644
--- a/src/MapExplorer.App/ViewModels/MainViewModel.cs
+++ b/src/MapExplorer.App/ViewModels/MainViewModel.cs
@@ -15,6 +15,7 @@ public partial class MainViewModel : ObservableObject
public WorldPickerViewModel WorldPicker { get; } = new();
public MapViewModel Map { get; } = new();
public PlayersViewModel Players { get; } = new();
+ public EditViewModel Edit { get; } = new();
private SelectedWorld? _currentWorld;
@@ -69,6 +70,9 @@ public MainViewModel()
Players.LoadWorld(world.WorldPath);
RegionDirRequested?.Invoke(world.OverworldDir);
};
+
+ // Editing only ever targets a single Y layer, so arming it always shows that layer.
+ Edit.EditModeEnabled += () => Map.Mode = LayerMode.Slice;
}
public void SwitchDimension(Dimension dimension)
@@ -109,10 +113,13 @@ public void BackToPicker()
IsNetherAvailable = false;
IsEndAvailable = false;
IsPlayersPanelOpen = false;
+ Edit.IsEditModeOn = false;
Players.Clear();
Phase = AppPhase.Picker;
_ = WorldPicker.ScanAsync();
}
public void TogglePlayersPanel() => IsPlayersPanelOpen = !IsPlayersPanelOpen;
+
+ public void ToggleEditMode() => Edit.IsEditModeOn = !Edit.IsEditModeOn;
}
diff --git a/src/MapExplorer.App/Views/EditPanelView.axaml b/src/MapExplorer.App/Views/EditPanelView.axaml
new file mode 100644
index 0000000..86ec2a8
--- /dev/null
+++ b/src/MapExplorer.App/Views/EditPanelView.axaml
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MapExplorer.App/Views/EditPanelView.axaml.cs b/src/MapExplorer.App/Views/EditPanelView.axaml.cs
new file mode 100644
index 0000000..f51e792
--- /dev/null
+++ b/src/MapExplorer.App/Views/EditPanelView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace MapExplorer.App.Views;
+
+public partial class EditPanelView : UserControl
+{
+ public EditPanelView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/MapExplorer.App/Views/MapCanvasControl.cs b/src/MapExplorer.App/Views/MapCanvasControl.cs
index bc62bb7..82d6d16 100644
--- a/src/MapExplorer.App/Views/MapCanvasControl.cs
+++ b/src/MapExplorer.App/Views/MapCanvasControl.cs
@@ -9,8 +9,11 @@
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
+using MapExplorer.App.ViewModels;
+using MapExplorer.Core.Chunk;
using MapExplorer.Core.World;
using MapExplorer.Rendering;
+using RenderColors = MapExplorer.Rendering.Colors;
namespace MapExplorer.App.Views;
@@ -62,6 +65,7 @@ public sealed class MapCanvasControl : Control
private static readonly Pen GridPen = new(new SolidColorBrush(Color.FromArgb(20, 255, 255, 255)));
private static readonly Pen CrosshairPen = new(new SolidColorBrush(Color.FromArgb(153, 255, 60, 60)));
+ private static readonly Pen SelectionPen = new(new SolidColorBrush(Color.FromArgb(220, 255, 220, 60)), 1.5);
// Viewport lives as plain mutable control-local state, not an observable
// property — it changes on every pan/zoom frame, and routing that through
@@ -81,6 +85,13 @@ public sealed class MapCanvasControl : Control
private readonly HashSet<(int, int)> _pendingChunkTiles = new();
private readonly HashSet<(int, int)> _pendingMacroTiles = new();
+ // Chunk tiles known to be out of date (an edit landed since they were built) but still shown
+ // while a fresh build is in flight — see MarkChunkTileStaleLocked/GetOrRequestTile. Without this,
+ // painting would remove each edited chunk's tile from _tileCache immediately and only put it back
+ // once the (async) rebuild finished, which reads as the whole chunk flickering to the blurry
+ // overview backdrop and back on every single block painted.
+ private readonly HashSet<(int, int)> _staleChunkTiles = new();
+
// Bounds how many tile/macro-tile builds run at once. A first paint of a large viewport
// can discover hundreds or thousands of missing tiles in one Render() pass; queuing all of
// them as unbounded Task.Run work would flood the thread pool and delay unrelated work
@@ -130,6 +141,13 @@ public sealed class MapCanvasControl : Control
// skips building/caching full-detail tiles altogether.
private bool _isLoading;
+ private EditViewModel? _editContext;
+ private bool _toolDragging;
+ private bool _toolIsPrimary;
+ private (int X, int Z)? _lastPaintedBlock;
+ private (int X, int Z)? _selectionStart;
+ private (int X, int Z)? _selectionCurrent;
+
public bool IsLoading
{
get => _isLoading;
@@ -148,6 +166,7 @@ public bool IsLoading
_macroTileCache.Clear();
_pendingChunkTiles.Clear();
_pendingMacroTiles.Clear();
+ _staleChunkTiles.Clear();
}
}
InvalidateVisual();
@@ -171,6 +190,7 @@ public LoadedChunkData? World
_macroTileCache.Clear();
_pendingChunkTiles.Clear();
_pendingMacroTiles.Clear();
+ _staleChunkTiles.Clear();
}
RequestOverviewRebuild();
InvalidateVisual();
@@ -190,6 +210,7 @@ public LayerConfig Config
_macroTileCache.Clear();
_pendingChunkTiles.Clear();
_pendingMacroTiles.Clear();
+ _staleChunkTiles.Clear();
}
SetZoom(_zoom); // re-clamp against the new mode's zoom floor (e.g. entering Slice mode already zoomed out too far)
RequestOverviewRebuild();
@@ -197,6 +218,84 @@ public LayerConfig Config
}
}
+ /// Wires up edit-mode tool state and the overlay whose unsaved edits render on top of
+ /// the normal Slice-mode pixels (set once from MainWindow, alongside Config).
+ public EditViewModel? EditContext
+ {
+ get => _editContext;
+ set
+ {
+ if (ReferenceEquals(_editContext, value)) return;
+ if (_editContext is not null)
+ {
+ _editContext.Overlay.ChunksInvalidated -= OnOverlayChunksInvalidated;
+ _editContext.Overlay.Reset -= OnOverlayReset;
+ _editContext.PropertyChanged -= OnEditContextPropertyChanged;
+ }
+ _editContext = value;
+ if (_editContext is not null)
+ {
+ _editContext.Overlay.ChunksInvalidated += OnOverlayChunksInvalidated;
+ _editContext.Overlay.Reset += OnOverlayReset;
+ _editContext.PropertyChanged += OnEditContextPropertyChanged;
+ }
+ InvalidateVisual();
+ }
+ }
+
+ // Toggling edit mode on/off changes whether the "layer below" preview renders through air (see
+ // BuildChunkTilePixels) — already-cached tiles were built without knowing that, so they need to
+ // be dropped for the new state to actually show up.
+ private void OnEditContextPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(EditViewModel.IsEditModeOn)) return;
+ lock (_cacheLock)
+ {
+ _tileCache.Clear();
+ _macroTileCache.Clear();
+ _pendingChunkTiles.Clear();
+ _pendingMacroTiles.Clear();
+ _staleChunkTiles.Clear();
+ }
+ InvalidateVisual();
+ }
+
+ // Paint invalidates the single chunk it just touched itself (see PaintAt), for immediate
+ // per-block feedback while dragging. This event instead covers the "one operation, redraw once
+ // it's done" tools (Fill, Paste) plus Undo/Redo, none of which need per-block granularity.
+ private void OnOverlayChunksInvalidated(IReadOnlyCollection<(int ChunkX, int ChunkZ)> chunks)
+ {
+ lock (_cacheLock)
+ {
+ foreach (var (cx, cz) in chunks) MarkChunkTileStaleLocked(cx, cz);
+ }
+ InvalidateVisual();
+ }
+
+ private void OnOverlayReset()
+ {
+ lock (_cacheLock)
+ {
+ _tileCache.Clear();
+ _macroTileCache.Clear();
+ _pendingChunkTiles.Clear();
+ _pendingMacroTiles.Clear();
+ _staleChunkTiles.Clear();
+ }
+ InvalidateVisual();
+ }
+
+ /// Flags a chunk's tile for rebuild without dropping the current (about-to-be-stale) one
+ /// out of the cache — GetOrRequestTile keeps returning it until the rebuild lands, so the chunk
+ /// never has a frame with nothing drawn for it. Macro tiles are still dropped outright: they're
+ /// essentially never on screen while editing (SliceModeMinZoom keeps that zoomed in), so the
+ /// same flicker risk doesn't apply and isn't worth the extra bookkeeping.
+ private void MarkChunkTileStaleLocked(int cx, int cz)
+ {
+ _staleChunkTiles.Add((cx, cz));
+ _macroTileCache.Remove((FloorDiv(cx, MacroTileChunks), FloorDiv(cz, MacroTileChunks)));
+ }
+
///
/// Call when the same World.Summaries dictionary just received more entries — e.g. on each
/// progress tick during a world load, so the map progressively paints in instead of
@@ -228,13 +327,19 @@ public MapCanvasControl()
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
- if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ var props = e.GetCurrentPoint(this).Properties;
+ var edit = _editContext;
+
+ if (edit is { IsEditModeOn: true })
{
- _dragging = true;
- _lastPointerPos = e.GetPosition(this);
- _dragStartScreenPos = _lastPointerPos;
- CaptureDragSnapshot();
+ // Left/right are the two materials in edit mode, so panning moves to the middle button.
+ if (props.IsMiddleButtonPressed) StartPan(e.GetPosition(this));
+ else if (props.IsLeftButtonPressed) BeginToolAction(e.GetPosition(this), edit, isPrimary: true);
+ else if (props.IsRightButtonPressed) BeginToolAction(e.GetPosition(this), edit, isPrimary: false);
+ return;
}
+
+ if (props.IsLeftButtonPressed) StartPan(e.GetPosition(this));
}
protected override void OnPointerMoved(PointerEventArgs e)
@@ -253,22 +358,235 @@ protected override void OnPointerMoved(PointerEventArgs e)
return; // hover has no visible effect while dragging — skip the lookup/scan
}
+ if (_toolDragging && _editContext is { } edit)
+ {
+ ContinueToolAction(ScreenToBlock(pos), edit);
+ return;
+ }
+
UpdateHover(pos);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
- EndDrag();
+ if (_dragging) { EndDrag(); return; }
+ FinishToolAction();
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
EndDrag();
+ FinishToolAction();
HoveredBlockChanged?.Invoke(null);
}
+ private void StartPan(Point pos)
+ {
+ _dragging = true;
+ _lastPointerPos = pos;
+ _dragStartScreenPos = pos;
+ CaptureDragSnapshot();
+ }
+
+ private (int X, int Z) ScreenToBlock(Point pos)
+ {
+ double w = Bounds.Width, h = Bounds.Height;
+ int blockX = (int)Math.Floor(_offsetX + (pos.X - w / 2) / _zoom);
+ int blockZ = (int)Math.Floor(_offsetZ + (pos.Y - h / 2) / _zoom);
+ return (blockX, blockZ);
+ }
+
+ private void BeginToolAction(Point pos, EditViewModel edit, bool isPrimary)
+ {
+ // A save reads Overlay on a background thread (see EditViewModel.Save) — starting a new tool
+ // action while that's running would mutate it concurrently and risk a save that reflects a
+ // half-applied edit. Panning (middle-button, handled before this is ever called) and hover
+ // are unaffected — only new paint/fill/copy/paste/pick actions are held off.
+ if (edit.SaveCommand.IsRunning) return;
+
+ var world = _world;
+ if (world is null) return;
+ var (worldX, worldZ) = ScreenToBlock(pos);
+ string material = isPrimary ? edit.PrimaryMaterial : edit.SecondaryMaterial;
+
+ switch (edit.SelectedTool)
+ {
+ case EditTool.Paint:
+ _toolDragging = true;
+ _toolIsPrimary = isPrimary;
+ _lastPaintedBlock = null;
+ edit.Overlay.BeginBatch();
+ PaintAt(worldX, worldZ, material, edit.Overlay);
+ break;
+ case EditTool.Fill:
+ edit.Overlay.BeginBatch();
+ FloodFillAt(worldX, worldZ, material, world, edit.Overlay);
+ edit.Overlay.EndBatch();
+ break;
+ case EditTool.Copy:
+ _toolDragging = true;
+ _selectionStart = (worldX, worldZ);
+ _selectionCurrent = (worldX, worldZ);
+ InvalidateVisual();
+ break;
+ case EditTool.Paste:
+ PasteAt(worldX, worldZ, edit);
+ break;
+ case EditTool.Picker:
+ PickAt(worldX, worldZ, world, edit, isPrimary);
+ break;
+ }
+ }
+
+ private void ContinueToolAction((int X, int Z) block, EditViewModel edit)
+ {
+ switch (edit.SelectedTool)
+ {
+ case EditTool.Paint when _lastPaintedBlock != block:
+ string material = _toolIsPrimary ? edit.PrimaryMaterial : edit.SecondaryMaterial;
+ PaintAt(block.X, block.Z, material, edit.Overlay);
+ break;
+ case EditTool.Copy:
+ _selectionCurrent = block;
+ InvalidateVisual();
+ break;
+ }
+ }
+
+ private void FinishToolAction()
+ {
+ if (!_toolDragging) return;
+ _toolDragging = false;
+ _lastPaintedBlock = null;
+
+ var edit = _editContext;
+ switch (edit?.SelectedTool)
+ {
+ case EditTool.Paint:
+ edit.Overlay.EndBatch();
+ break;
+ case EditTool.Copy:
+ FinishCopy(edit);
+ break;
+ }
+ }
+
+ private void PaintAt(int worldX, int worldZ, string material, EditOverlay overlay)
+ {
+ overlay.Set(worldX, _config.SliceY, worldZ, material);
+ _lastPaintedBlock = (worldX, worldZ);
+ lock (_cacheLock) { MarkChunkTileStaleLocked(FloorDiv(worldX, ChunkSize), FloorDiv(worldZ, ChunkSize)); }
+ InvalidateVisual();
+ }
+
+ // 4-connected, within the current layer only — matches "editing a single layer." Bounded so a
+ // click on a huge contiguous area (e.g. the ocean) can't run away decoding the whole world.
+ private const int FloodFillMaxCells = 200_000;
+
+ private void FloodFillAt(int startX, int startZ, string material, LoadedChunkData world, EditOverlay overlay)
+ {
+ string target = GetEffectiveBlock(startX, startZ, world, overlay);
+ if (target == material) return;
+
+ var visited = new HashSet<(int, int)> { (startX, startZ) };
+ var queue = new Queue<(int, int)>();
+ queue.Enqueue((startX, startZ));
+ var touchedChunks = new HashSet<(int, int)>();
+ int touched = 0;
+
+ while (queue.Count > 0 && touched < FloodFillMaxCells)
+ {
+ var (x, z) = queue.Dequeue();
+ overlay.Set(x, _config.SliceY, z, material);
+ touchedChunks.Add((FloorDiv(x, ChunkSize), FloorDiv(z, ChunkSize)));
+ touched++;
+
+ TryEnqueueNeighbor(x + 1, z, target, world, overlay, visited, queue);
+ TryEnqueueNeighbor(x - 1, z, target, world, overlay, visited, queue);
+ TryEnqueueNeighbor(x, z + 1, target, world, overlay, visited, queue);
+ TryEnqueueNeighbor(x, z - 1, target, world, overlay, visited, queue);
+ }
+
+ lock (_cacheLock) { foreach (var (cx, cz) in touchedChunks) MarkChunkTileStaleLocked(cx, cz); }
+ InvalidateVisual();
+ }
+
+ // Expanded out of FloodFillAt's loop (rather than `foreach (var n in new[] { ... })`) so filling
+ // a large area — up to FloodFillMaxCells — doesn't allocate a fresh 4-element array per visited
+ // cell purely to iterate it once.
+ private void TryEnqueueNeighbor(int x, int z, string target, LoadedChunkData world, EditOverlay overlay,
+ HashSet<(int, int)> visited, Queue<(int, int)> queue)
+ {
+ var pos = (x, z);
+ if (!visited.Add(pos)) return;
+ if (GetEffectiveBlock(x, z, world, overlay) == target) queue.Enqueue(pos);
+ }
+
+ private void FinishCopy(EditViewModel edit)
+ {
+ var start = _selectionStart;
+ var end = _selectionCurrent;
+ _selectionStart = null;
+ _selectionCurrent = null;
+ InvalidateVisual();
+
+ if (start is not { } s || end is not { } en || _world is not { } world) return;
+
+ int minX = Math.Min(s.X, en.X), maxX = Math.Max(s.X, en.X);
+ int minZ = Math.Min(s.Z, en.Z), maxZ = Math.Max(s.Z, en.Z);
+ int width = maxX - minX + 1, depth = maxZ - minZ + 1;
+ if ((long)width * depth > FloodFillMaxCells) return; // selection too large — silently ignored, same cap as flood fill
+
+ var blocks = new string[width, depth];
+ for (int dz = 0; dz < depth; dz++)
+ for (int dx = 0; dx < width; dx++)
+ blocks[dx, dz] = GetEffectiveBlock(minX + dx, minZ + dz, world, edit.Overlay);
+
+ edit.Clipboard = new ClipboardData(width, depth, blocks);
+ }
+
+ private void PasteAt(int worldX, int worldZ, EditViewModel edit)
+ {
+ if (edit.Clipboard is not { } clip || _world is null) return;
+
+ edit.Overlay.BeginBatch();
+ var touchedChunks = new HashSet<(int, int)>();
+ for (int dz = 0; dz < clip.Depth; dz++)
+ for (int dx = 0; dx < clip.Width; dx++)
+ {
+ int x = worldX + dx, z = worldZ + dz;
+ edit.Overlay.Set(x, _config.SliceY, z, clip.Blocks[dx, dz]);
+ touchedChunks.Add((FloorDiv(x, ChunkSize), FloorDiv(z, ChunkSize)));
+ }
+ edit.Overlay.EndBatch();
+
+ lock (_cacheLock) { foreach (var (cx, cz) in touchedChunks) MarkChunkTileStaleLocked(cx, cz); }
+ InvalidateVisual();
+ }
+
+ private void PickAt(int worldX, int worldZ, LoadedChunkData world, EditViewModel edit, bool isPrimary)
+ {
+ string block = GetEffectiveBlock(worldX, worldZ, world, edit.Overlay);
+ edit.AssignToSlot(block, isPrimary ? MaterialSlot.Left : MaterialSlot.Right);
+ }
+
+ /// The block at (worldX, worldZ, current SliceY) as it renders right now — the overlay's
+ /// unsaved edit if there is one, otherwise whatever's decoded from disk. Used by every tool that
+ /// needs to read before writing (Fill's match test, Copy, the eyedropper).
+ private string GetEffectiveBlock(int worldX, int worldZ, LoadedChunkData world, EditOverlay overlay)
+ {
+ string? overridden = overlay.GetOverride(worldX, _config.SliceY, worldZ);
+ if (overridden is not null) return overridden;
+
+ int cx = FloorDiv(worldX, ChunkSize), cz = FloorDiv(worldZ, ChunkSize);
+ int sectionY = FloorDiv(_config.SliceY, 16);
+ var slice = world.ChunkStore.GetOrDecodeSlice(cx, cz, sectionY);
+ if (slice is null) return "minecraft:air";
+ return slice.GetBlock(worldX - cx * ChunkSize, _config.SliceY - sectionY * 16, worldZ - cz * ChunkSize);
+ }
+
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
@@ -327,9 +645,7 @@ private void UpdateHover(Point pos)
var world = _world;
if (world is null) return;
- double w = Bounds.Width, h = Bounds.Height;
- int blockX = (int)Math.Floor(_offsetX + (pos.X - w / 2) / _zoom);
- int blockZ = (int)Math.Floor(_offsetZ + (pos.Y - h / 2) / _zoom);
+ var (blockX, blockZ) = ScreenToBlock(pos);
int chunkX = FloorDiv(blockX, ChunkSize);
int chunkZ = FloorDiv(blockZ, ChunkSize);
int lx = ((blockX % ChunkSize) + ChunkSize) % ChunkSize;
@@ -348,7 +664,8 @@ private void UpdateHover(Point pos)
int sectionY = FloorDiv(_config.SliceY, 16);
var slice = world.ChunkStore.GetOrDecodeSlice(chunkX, chunkZ, sectionY);
if (slice is null) { HoveredBlockChanged?.Invoke(null); return; }
- string name = slice.GetBlock(lx, _config.SliceY - sectionY * 16, lz);
+ string name = _editContext?.Overlay.GetOverride(blockX, _config.SliceY, blockZ)
+ ?? slice.GetBlock(lx, _config.SliceY - sectionY * 16, lz);
HoveredBlockChanged?.Invoke(new HoveredBlock { X = blockX, Y = _config.SliceY, Z = blockZ, Name = name });
break;
}
@@ -443,6 +760,18 @@ public override void Render(DrawingContext context)
double oz = (0 - _offsetZ) * pixelsPerBlock + h / 2;
context.DrawLine(CrosshairPen, new Point(ox - 8, oz), new Point(ox + 8, oz));
context.DrawLine(CrosshairPen, new Point(ox, oz - 8), new Point(ox, oz + 8));
+
+ // Copy-tool marquee, while dragging out a rectangle.
+ if (_selectionStart is { } selStart && _selectionCurrent is { } selEnd)
+ {
+ int selMinX = Math.Min(selStart.X, selEnd.X), selMaxX = Math.Max(selStart.X, selEnd.X) + 1;
+ int selMinZ = Math.Min(selStart.Z, selEnd.Z), selMaxZ = Math.Max(selStart.Z, selEnd.Z) + 1;
+ double sx0 = (selMinX - _offsetX) * pixelsPerBlock + w / 2;
+ double sz0 = (selMinZ - _offsetZ) * pixelsPerBlock + h / 2;
+ double sx1 = (selMaxX - _offsetX) * pixelsPerBlock + w / 2;
+ double sz1 = (selMaxZ - _offsetZ) * pixelsPerBlock + h / 2;
+ context.DrawRectangle(SelectionPen, new Rect(sx0, sz0, sx1 - sx0, sz1 - sz0));
+ }
}
private void DrawOverview(DrawingContext context, double pixelsPerBlock, double w, double h)
@@ -523,14 +852,22 @@ private void RenderMacroTiles(
private WriteableBitmap? GetOrRequestTile(int cx, int cz, LoadedChunkData world)
{
var key = (cx, cz);
+ WriteableBitmap? cached;
lock (_cacheLock)
{
- if (_tileCache.TryGetValue(key, out var cached)) return cached;
- if (!_pendingChunkTiles.Add(key)) return null; // already building
+ _tileCache.TryGetValue(key, out cached);
+ // Not stale: nothing to do, whatever's cached (possibly null, on a first build) is current.
+ if (cached is not null && !_staleChunkTiles.Contains(key)) return cached;
+ // Already rebuilding (from a prior edit/config change) — keep showing the stale tile
+ // meanwhile rather than starting a second concurrent build for the same key.
+ if (!_pendingChunkTiles.Add(key)) return cached;
+ _staleChunkTiles.Remove(key);
}
var config = _config;
var worldRef = world;
+ var overlay = _editContext?.Overlay;
+ var showBelowLayer = _editContext?.IsEditModeOn ?? false;
Task.Run(async () =>
{
await TileBuildConcurrency.WaitAsync().ConfigureAwait(false);
@@ -540,7 +877,7 @@ private void RenderMacroTiles(
// later frame — the alternative (letting the exception propagate) would abandon
// this Task before the Dispatcher.Post below runs, which would never clear the
// pending flag and permanently stick this tile at overview-only resolution.
- byte[]? pixels = BuildChunkTilePixels(cx, cz, worldRef, config);
+ byte[]? pixels = BuildChunkTilePixels(cx, cz, worldRef, config, overlay, showBelowLayer);
Dispatcher.UIThread.Post(() =>
{
@@ -568,7 +905,9 @@ private void RenderMacroTiles(
}
});
- return null;
+ // Whatever was cached before this call (null on a first build, the still-good-enough stale
+ // tile on a rebuild) keeps rendering until the background build above lands.
+ return cached;
}
///
@@ -580,7 +919,7 @@ private void RenderMacroTiles(
/// than requiring a full ChunkData scan. Tile builds stay off the render thread even though these
/// cases are all cheap enough to run inline, to keep this one code path uniform.
///
- private static byte[]? BuildChunkTilePixels(int cx, int cz, LoadedChunkData world, LayerConfig config)
+ private static byte[]? BuildChunkTilePixels(int cx, int cz, LoadedChunkData world, LayerConfig config, EditOverlay? overlay, bool showBelowLayer)
{
try
{
@@ -593,9 +932,41 @@ private void RenderMacroTiles(
int sectionY = FloorDiv(config.SliceY, 16);
var slice = world.ChunkStore.GetOrDecodeSlice(cx, cz, sectionY);
if (slice is null) return null;
+
+ // Edit mode only: where the active layer is air, peek at the layer directly below
+ // (dimmed) instead of leaving it blank, so painting near a drop-off or over water
+ // doesn't happen blind. Only ever needs a second decode when SliceY sits at the
+ // bottom of its section — otherwise "below" is still inside the section already
+ // decoded above.
+ int localY = config.SliceY - sectionY * 16;
+ ChunkSliceData? belowSlice = null;
+ int belowLocalY = 0;
+ if (showBelowLayer)
+ {
+ if (localY > 0) { belowSlice = slice; belowLocalY = localY - 1; }
+ else { belowSlice = world.ChunkStore.GetOrDecodeSlice(cx, cz, sectionY - 1); belowLocalY = 15; }
+ }
+
for (int lz = 0; lz < ChunkSize; lz++)
for (int lx = 0; lx < ChunkSize; lx++)
- WritePixel(pixels, ChunkSize, lx, lz, ChunkRenderer.GetChunkPixelColor(slice, config, lx, lz, oreSummary));
+ {
+ string? ov = overlay?.GetOverride(cx * ChunkSize + lx, config.SliceY, cz * ChunkSize + lz);
+
+ Rgb color;
+ string currentBlock = ov ?? slice.GetBlock(lx, localY, lz);
+ if (belowSlice is not null && RenderColors.AirBlocks.Contains(currentBlock))
+ {
+ string? belowOv = overlay?.GetOverride(cx * ChunkSize + lx, config.SliceY - 1, cz * ChunkSize + lz);
+ string belowBlock = belowOv ?? belowSlice.GetBlock(lx, belowLocalY, lz);
+ color = ChunkRenderer.DimForBelowLayer(RenderColors.GetBlockColor(belowBlock));
+ }
+ else
+ {
+ color = ChunkRenderer.GetChunkPixelColor(slice, config, lx, lz, oreSummary, ov);
+ }
+
+ WritePixel(pixels, ChunkSize, lx, lz, color);
+ }
break;
}
default:
@@ -659,6 +1030,7 @@ private void RenderMacroTiles(
int sizeBlocks = MacroTileChunks * ChunkSize;
var config = _config;
var worldRef = world;
+ var overlay = _editContext?.Overlay;
Task.Run(async () =>
{
await TileBuildConcurrency.WaitAsync().ConfigureAwait(false);
@@ -694,6 +1066,10 @@ private void RenderMacroTiles(
switch (dataNeed)
{
+ // Edit mode's "layer below through air" preview (see BuildChunkTilePixels)
+ // is deliberately not replicated here — SliceModeMinZoom already keeps
+ // editing zoomed in far enough that this macro path essentially never
+ // renders while painting, so the extra per-cell decode isn't worth it.
case ChunkDataNeed.Slice:
{
var slice = worldRef.ChunkStore.GetOrDecodeSlice(cx, cz, sliceSectionY);
@@ -701,7 +1077,8 @@ private void RenderMacroTiles(
for (int lz = 0; lz < ChunkSize; lz++)
for (int lx = 0; lx < ChunkSize; lx++)
{
- var color = ChunkRenderer.GetChunkPixelColor(slice, config, lx, lz);
+ string? ov = overlay?.GetOverride(cx * ChunkSize + lx, config.SliceY, cz * ChunkSize + lz);
+ var color = ChunkRenderer.GetChunkPixelColor(slice, config, lx, lz, blockNameOverride: ov);
if (oreTint is not null) color = ChunkRenderer.BlendOre(color, oreTint.Value);
WritePixel(pixels, sizeBlocks, destBaseX + lx, destBaseZ + lz, color);
}
@@ -863,6 +1240,15 @@ private sealed class LruBitmapCache
public LruBitmapCache(int capacity) => _capacity = capacity;
+ public void Remove((int, int) key)
+ {
+ if (_map.Remove(key, out var node))
+ {
+ _order.Remove(node);
+ node.Value.Bitmap.Dispose();
+ }
+ }
+
public bool TryGetValue((int, int) key, out WriteableBitmap bitmap)
{
if (_map.TryGetValue(key, out var node))
diff --git a/src/MapExplorer.Core/Chunk/ChunkSectionCodec.cs b/src/MapExplorer.Core/Chunk/ChunkSectionCodec.cs
new file mode 100644
index 0000000..c04c8a9
--- /dev/null
+++ b/src/MapExplorer.Core/Chunk/ChunkSectionCodec.cs
@@ -0,0 +1,104 @@
+using MapExplorer.Core.Nbt.RoundTrip;
+
+namespace MapExplorer.Core.Chunk;
+
+///
+/// Full-fidelity block_states encode/decode for a single 16x16x16 section, operating on
+/// MapExplorer.Core.Nbt.RoundTrip's tag model (not the lossy, read-only MapExplorer.Core.Nbt.Nbt used
+/// for rendering) — this is the write-side counterpart of ChunkDecoder.DecodeSectionBlocks, kept as a
+/// separate small implementation rather than unifying the two NBT stacks (they're intentionally split
+/// for read-performance reasons — see Nbt.cs/NbtRoundTrip.cs).
+///
+public static class ChunkSectionCodec
+{
+ private const int BlockCount = 4096; // 16*16*16
+
+ /// Decodes a section's current 4096 block names (index = localY*256 + localZ*16 + localX,
+ /// matching Minecraft's on-disk layout and ChunkSliceData's convention). Returns all-air if the
+ /// section has no block_states/palette at all.
+ public static string[] DecodeBlockNames(NbtCompoundTag section)
+ {
+ var names = new string[BlockCount];
+ Array.Fill(names, "minecraft:air");
+
+ if (section.Get("block_states") is not NbtCompoundTag bs) return names;
+ var rawPalette = bs.Get("palette") is NbtListTag pl ? pl.Items : [];
+ if (rawPalette.Count == 0) return names;
+
+ var paletteNames = new string[rawPalette.Count];
+ for (int i = 0; i < rawPalette.Count; i++)
+ {
+ paletteNames[i] = rawPalette[i] is NbtCompoundTag entry && entry.Get("Name") is NbtStringTag n
+ ? n.Value
+ : "minecraft:air";
+ }
+
+ if (rawPalette.Count == 1)
+ {
+ Array.Fill(names, paletteNames[0]);
+ return names;
+ }
+
+ if (bs.Get("data") is not NbtLongArrayTag dataArr) return names; // malformed: leave as air
+
+ int bitsPerBlock = Math.Max(4, (int)Math.Ceiling(Math.Log2(rawPalette.Count)));
+ int valuesPerLong = 64 / bitsPerBlock;
+ long mask = (1L << bitsPerBlock) - 1;
+ for (int i = 0; i < BlockCount; i++)
+ {
+ int longIdx = i / valuesPerLong;
+ int bitIdx = (i - longIdx * valuesPerLong) * bitsPerBlock;
+ int value = longIdx < dataArr.Value.Length ? (int)((dataArr.Value[longIdx] >> bitIdx) & mask) : 0;
+ names[i] = value < paletteNames.Length ? paletteNames[value] : "minecraft:air";
+ }
+ return names;
+ }
+
+ /// Builds a fresh block_states compound from a 4096-entry block name array, using the
+ /// smallest palette that actually appears (matching vanilla's own encoding — a uniform section
+ /// gets a single-entry palette and no "data" tag at all).
+ public static NbtCompoundTag EncodeBlockStates(string[] names)
+ {
+ if (names.Length != BlockCount)
+ throw new ArgumentException($"Expected {BlockCount} block names, got {names.Length}", nameof(names));
+
+ var palette = new List();
+ var paletteIndex = new Dictionary();
+ var localIndices = new int[BlockCount];
+ for (int i = 0; i < BlockCount; i++)
+ {
+ if (!paletteIndex.TryGetValue(names[i], out int idx))
+ {
+ idx = palette.Count;
+ palette.Add(names[i]);
+ paletteIndex[names[i]] = idx;
+ }
+ localIndices[i] = idx;
+ }
+
+ var paletteList = new NbtListTag(NbtTagType.Compound,
+ palette.Select(name => (NbtTag)new NbtCompoundTag(new Dictionary
+ {
+ ["Name"] = new NbtStringTag(name)
+ })).ToList());
+
+ var fields = new Dictionary { ["palette"] = paletteList };
+
+ if (palette.Count > 1)
+ {
+ int bitsPerBlock = Math.Max(4, (int)Math.Ceiling(Math.Log2(palette.Count)));
+ int valuesPerLong = 64 / bitsPerBlock;
+ int longCount = (int)Math.Ceiling(BlockCount / (double)valuesPerLong);
+ var longs = new long[longCount];
+ for (int i = 0; i < BlockCount; i++)
+ {
+ int longIdx = i / valuesPerLong;
+ int bitIdx = (i - longIdx * valuesPerLong) * bitsPerBlock;
+ longs[longIdx] |= (long)localIndices[i] << bitIdx;
+ }
+ fields["data"] = new NbtLongArrayTag(longs);
+ }
+
+ return new NbtCompoundTag(fields);
+ }
+}
diff --git a/src/MapExplorer.Core/Region/RegionChunkPatcher.cs b/src/MapExplorer.Core/Region/RegionChunkPatcher.cs
new file mode 100644
index 0000000..1ebd401
--- /dev/null
+++ b/src/MapExplorer.Core/Region/RegionChunkPatcher.cs
@@ -0,0 +1,105 @@
+using MapExplorer.Core.Chunk;
+using MapExplorer.Core.Nbt.RoundTrip;
+
+namespace MapExplorer.Core.Region;
+
+///
+/// Applies in-memory block edits to a chunk's full-fidelity NBT and re-serializes it — the write-side
+/// counterpart of ChunkDecoder, used only at Save time (see WorldEditWriter). Uses NbtRoundTrip (not
+/// the lossy MapExplorer.Core.Nbt.Nbt) so every field this app never looks at — entities, heightmaps,
+/// structure references, etc. — round-trips byte-for-byte-equivalent except for what was actually edited.
+///
+public static class RegionChunkPatcher
+{
+ /// Edits keyed by chunk-local position: (localX 0-15, world Y, localZ 0-15) -> new block name.
+ public static byte[] ApplyEdits(byte[] originalRawChunkNbt, int chunkX, int chunkZ,
+ IReadOnlyDictionary<(int LocalX, int Y, int LocalZ), string> edits)
+ {
+ var doc = NbtRoundTrip.Parse(originalRawChunkNbt);
+
+ var chunkData = doc.Root;
+ if (chunkData.Get("sections") is not NbtListTag && chunkData.Get("Level") is NbtCompoundTag level)
+ {
+ chunkData = level;
+ }
+ if (chunkData.Get("sections") is not NbtListTag sections)
+ {
+ throw new InvalidOperationException("Chunk NBT has no sections to edit");
+ }
+
+ foreach (var group in edits.GroupBy(kv => FloorDiv(kv.Key.Y, 16)))
+ {
+ int sectionY = group.Key;
+ var sectionTag = sections.Items.OfType()
+ .FirstOrDefault(s => ReadSectionY(s.Get("Y")) == sectionY);
+
+ if (sectionTag is null)
+ {
+ // Real chunk NBT stores this as TAG_Byte (see ReadSectionY) — match that convention
+ // for a freshly-created section too, rather than introducing a TAG_Int one no other
+ // section in the file would ever have.
+ sectionTag = new NbtCompoundTag(new Dictionary { ["Y"] = new NbtByteTag((sbyte)sectionY) });
+ sections.Items.Add(sectionTag);
+ }
+
+ var names = ChunkSectionCodec.DecodeBlockNames(sectionTag);
+ foreach (var (key, blockName) in group)
+ {
+ int localY = key.Y - sectionY * 16;
+ int idx = localY * 256 + key.LocalZ * 16 + key.LocalX;
+ names[idx] = blockName;
+ }
+ sectionTag.Set("block_states", ChunkSectionCodec.EncodeBlockStates(names));
+ }
+
+ RemoveStaleBlockEntities(chunkData, chunkX, chunkZ, edits.Keys);
+ ResetLighting(chunkData);
+
+ return NbtRoundTrip.Write(new NbtDocument(doc.RootName, doc.Root));
+ }
+
+ // Painting a simple material (the only kind this editor's palette offers) over a position that
+ // used to hold a chest/sign/etc. must not leave that block entity's NBT behind — it would
+ // reference a block that no longer exists there.
+ private static void RemoveStaleBlockEntities(NbtCompoundTag chunkData, int chunkX, int chunkZ,
+ IEnumerable<(int LocalX, int Y, int LocalZ)> editedPositions)
+ {
+ if (chunkData.Get("block_entities") is not NbtListTag beList) return;
+
+ var absolute = editedPositions
+ .Select(p => (X: chunkX * 16 + p.LocalX, Y: p.Y, Z: chunkZ * 16 + p.LocalZ))
+ .ToHashSet();
+
+ beList.Items.RemoveAll(item =>
+ item is NbtCompoundTag be &&
+ be.Get("x") is NbtIntTag x && be.Get("y") is NbtIntTag y && be.Get("z") is NbtIntTag z &&
+ absolute.Contains((x.Value, y.Value, z.Value)));
+ }
+
+ // Forces Minecraft to recompute lighting for this chunk on next load instead of showing stale
+ // light values around the edit.
+ private static void ResetLighting(NbtCompoundTag chunkData)
+ {
+ if (chunkData.Get("isLightOn") is NbtByteTag)
+ {
+ chunkData.Set("isLightOn", new NbtByteTag(0));
+ }
+ }
+
+ // A section's "Y" tag is TAG_Byte in real Minecraft chunk NBT (unlike block_entities' x/y/z,
+ // which are genuinely TAG_Int) — accept Byte/Short/Int alike so this matches whichever one a
+ // given file actually has, the same flexibility MapExplorer.Core.Nbt.Nbt's NbtInt already gives
+ // the read-only decoder. Matching only NbtIntTag here previously meant the lookup below never
+ // found an existing section (real files never have one), so every edit silently created a
+ // duplicate, near-empty section that a later full decode would layer over — and lose — the
+ // original block data for that entire Y range.
+ private static int? ReadSectionY(NbtTag? tag) => tag switch
+ {
+ NbtByteTag b => b.Value,
+ NbtShortTag s => s.Value,
+ NbtIntTag i => i.Value,
+ _ => null
+ };
+
+ private static int FloorDiv(int a, int b) => (int)Math.Floor((double)a / b);
+}
diff --git a/src/MapExplorer.Core/Region/RegionFileWriter.cs b/src/MapExplorer.Core/Region/RegionFileWriter.cs
new file mode 100644
index 0000000..fffcb13
--- /dev/null
+++ b/src/MapExplorer.Core/Region/RegionFileWriter.cs
@@ -0,0 +1,133 @@
+using System.Buffers.Binary;
+using System.IO.Compression;
+
+namespace MapExplorer.Core.Region;
+
+// Rebuilds a region file's bytes given a set of already-patched chunks' raw NBT. Rather than manage
+// an in-place free-list over the existing sector layout (what real Minecraft does), this always
+// rebuilds the sector area from scratch: untouched chunks' compressed bytes are copied verbatim
+// (no recompression), patched chunks are freshly zlib-compressed, and everything is laid out
+// back-to-back starting at sector 2. Simpler than in-place patching and always produces a valid,
+// defragmented file. Pure byte transform — no disk I/O — so it's easy to unit test.
+public static class RegionFileWriter
+{
+ // The sector-table entry format packs sectorCount into a single byte (see ReadRawEntryBytes'
+ // "& 0xFF" and the offset-word layout below) — a chunk needing more than this many 4KiB sectors
+ // (~1 MiB) can't be represented at all. Real Minecraft handles that rare case by spilling the
+ // chunk into a separate "c.x.z.mcc" file; this writer doesn't implement that, so it fails loudly
+ // instead, since silently OR-ing an overflowed sectorCount into the offset word would corrupt
+ // both this entry's byte range and, via bit overlap, its sectorOffset too.
+ private const int MaxSectorsPerChunk = 255;
+
+ public static byte[] Rebuild(byte[] originalRegionBytes,
+ IReadOnlyDictionary<(int LocalX, int LocalZ), byte[]> patchedChunkNbt,
+ long timestampSeconds)
+ {
+ bool hasHeader = originalRegionBytes.Length >= 8192;
+ var newOffsets = new uint[1024];
+ var newTimestamps = new uint[1024];
+ var sectors = new List();
+ int nextSector = 2; // sectors 0-1 are the offset + timestamp tables
+
+ for (int i = 0; i < 1024; i++)
+ {
+ int localX = i % 32;
+ int localZ = i / 32;
+
+ byte[]? entry;
+ uint timestamp;
+
+ if (patchedChunkNbt.TryGetValue((localX, localZ), out var newNbt))
+ {
+ entry = BuildEntry(newNbt);
+ timestamp = (uint)timestampSeconds;
+ }
+ else if (hasHeader)
+ {
+ entry = ReadRawEntryBytes(originalRegionBytes, i);
+ timestamp = BinaryPrimitives.ReadUInt32BigEndian(originalRegionBytes.AsSpan(4096 + i * 4, 4));
+ }
+ else
+ {
+ entry = null;
+ timestamp = 0;
+ }
+
+ if (entry is null) continue; // empty slot — offset/timestamp stay 0
+
+ int sectorCount = (int)Math.Ceiling(entry.Length / 4096.0);
+ if (sectorCount > MaxSectorsPerChunk)
+ {
+ throw new InvalidOperationException(
+ $"Chunk at region slot ({localX}, {localZ}) needs {sectorCount} sectors, " +
+ $"exceeding the {MaxSectorsPerChunk}-sector-per-chunk limit of the region file format " +
+ "(oversized/.mcc chunk storage is not supported).");
+ }
+ var padded = new byte[sectorCount * 4096];
+ entry.CopyTo(padded, 0);
+ sectors.Add(padded);
+
+ newOffsets[i] = ((uint)nextSector << 8) | (uint)sectorCount;
+ newTimestamps[i] = timestamp;
+ nextSector += sectorCount;
+ }
+
+ using var ms = new MemoryStream();
+ var offsetHeader = new byte[4096];
+ for (int i = 0; i < 1024; i++) BinaryPrimitives.WriteUInt32BigEndian(offsetHeader.AsSpan(i * 4, 4), newOffsets[i]);
+ ms.Write(offsetHeader);
+
+ var timestampHeader = new byte[4096];
+ for (int i = 0; i < 1024; i++) BinaryPrimitives.WriteUInt32BigEndian(timestampHeader.AsSpan(i * 4, 4), newTimestamps[i]);
+ ms.Write(timestampHeader);
+
+ foreach (var sector in sectors) ms.Write(sector);
+ return ms.ToArray();
+ }
+
+ /// Builds one sector-table entry's payload (4-byte length + 1-byte compression type +
+ /// zlib-compressed NBT), unpadded.
+ private static byte[] BuildEntry(byte[] rawNbt)
+ {
+ var compressed = ZlibCompress(rawNbt);
+ var entry = new byte[5 + compressed.Length];
+ BinaryPrimitives.WriteUInt32BigEndian(entry, (uint)(compressed.Length + 1));
+ entry[4] = 2; // zlib
+ compressed.CopyTo(entry, 5);
+ return entry;
+ }
+
+ /// Slices an existing entry's raw bytes (length field + compression byte + compressed
+ /// payload) straight out of the original buffer, for verbatim reuse. Mirrors RegionFile's own
+ /// sector-table parsing so both stay consistent about the on-disk layout.
+ private static byte[]? ReadRawEntryBytes(byte[] buffer, int i)
+ {
+ uint offsetEntry = BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(i * 4, 4));
+ uint sectorOffset = (offsetEntry >> 8) & 0xFFFFFF;
+ uint sectorCount = offsetEntry & 0xFF;
+ if (sectorOffset == 0 || sectorCount == 0) return null;
+
+ long byteOffset = sectorOffset * 4096L;
+ if (byteOffset + 5 > buffer.Length) return null;
+
+ uint length = BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan((int)byteOffset, 4));
+ // The length field counts bytes *after* itself (the compression-type byte + the compressed
+ // payload), so the entry's total footprint is 4 (the length field) + length — not 5 + length.
+ // The extra byte of margin previously required here meant an entry whose real bytes ended
+ // exactly at buffer.Length (no trailing sector padding) was wrongly rejected as out of bounds
+ // and silently dropped during rebuild.
+ if (length < 1 || byteOffset + 4 + length > buffer.Length) return null;
+
+ return buffer.AsSpan((int)byteOffset, (int)(4 + length)).ToArray();
+ }
+
+ private static byte[] ZlibCompress(byte[] raw)
+ {
+ using var output = new MemoryStream();
+ using (var zlib = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ zlib.Write(raw);
+ }
+ return output.ToArray();
+ }
+}
diff --git a/src/MapExplorer.Core/World/EditOverlay.cs b/src/MapExplorer.Core/World/EditOverlay.cs
new file mode 100644
index 0000000..94d023f
--- /dev/null
+++ b/src/MapExplorer.Core/World/EditOverlay.cs
@@ -0,0 +1,186 @@
+using System.Collections.Concurrent;
+
+namespace MapExplorer.Core.World;
+
+///
+/// The live, in-memory source of truth for "what does this block look like right now" — independent
+/// of whether it's been saved to disk. Rendering always consults this before falling back to the
+/// decoded on-disk value (see MapCanvasControl), so Save doesn't need to invalidate any read caches:
+/// it only flushes these overrides to disk. Entries are never cleared on Save; they remain correct
+/// (the disk now matches them too) for the rest of the session.
+///
+public sealed class EditOverlay
+{
+ public sealed record Edit(int X, int Y, int Z, string Before, string After);
+
+ private readonly object _lock = new();
+ private readonly ConcurrentDictionary<(int X, int Y, int Z), string> _current = new();
+
+ // Which full-coordinate keys fall in each chunk, maintained incrementally as Set() touches new
+ // positions (Undo/Redo only ever change the *value* at an already-indexed key, never add one, so
+ // this never needs updating there). GetEditsForChunk used to scan the whole of _current per
+ // chunk it was asked about — O(totalEdits) per call, so O(totalEdits * dirtyChunks) across a
+ // whole Save — which got slow for a big flood fill/paste spread across many chunks. Keyed on the
+ // same chunk coordinates as DirtyChunks, which is now just this dictionary's key set.
+ private readonly Dictionary<(int ChunkX, int ChunkZ), HashSet<(int X, int Y, int Z)>> _editsByChunk = [];
+ private readonly List> _undoStack = [];
+ private readonly List> _redoStack = [];
+ private Dictionary<(int, int, int), Edit>? _openBatch;
+
+ private WorldChunkStore? _chunkStore;
+
+ /// Fired after a batch commits, an undo, or a redo — with the set of chunks whose
+ /// rendered pixels may have changed, so the canvas can drop just those tiles from its cache.
+ public event Action>? ChunksInvalidated;
+
+ /// Fired when switching to a different world/dimension — all overlay state is discarded
+ /// since it's keyed by absolute coordinates that mean something different there.
+ public event Action? Reset;
+
+ /// Must be called whenever the active world/dimension changes — supplies the decoder used
+ /// to capture a block's original value the first time it's touched, and clears all overlay state
+ /// (edits, undo/redo history) from the previous world.
+ public void RebindWorld(WorldChunkStore chunkStore)
+ {
+ lock (_lock)
+ {
+ _chunkStore = chunkStore;
+ _current.Clear();
+ _editsByChunk.Clear();
+ _undoStack.Clear();
+ _redoStack.Clear();
+ _openBatch = null;
+ }
+ Reset?.Invoke();
+ }
+
+ public string? GetOverride(int x, int y, int z) => _current.TryGetValue((x, y, z), out var v) ? v : null;
+
+ public bool CanUndo { get { lock (_lock) return _undoStack.Count > 0; } }
+ public bool CanRedo { get { lock (_lock) return _redoStack.Count > 0; } }
+
+ public IReadOnlySet<(int ChunkX, int ChunkZ)> DirtyChunks { get { lock (_lock) return _editsByChunk.Keys.ToHashSet(); } }
+
+ /// All current overlay entries within one chunk, keyed by chunk-local position — used by
+ /// WorldEditWriter to build the patch set for that chunk. O(edits in this chunk), via
+ /// _editsByChunk, rather than a full scan of every overlay entry in the world.
+ public IReadOnlyDictionary<(int LocalX, int Y, int LocalZ), string> GetEditsForChunk(int chunkX, int chunkZ)
+ {
+ lock (_lock)
+ {
+ var result = new Dictionary<(int, int, int), string>();
+ if (_editsByChunk.TryGetValue((chunkX, chunkZ), out var keys))
+ {
+ foreach (var (x, y, z) in keys)
+ {
+ if (_current.TryGetValue((x, y, z), out var name))
+ {
+ result[(x - chunkX * 16, y, z - chunkZ * 16)] = name;
+ }
+ }
+ }
+ return result;
+ }
+ }
+
+ /// Opens one undo step. Every tool action (a whole paint stroke, one fill, one paste) is
+ /// exactly one Begin/EndBatch pair.
+ public void BeginBatch()
+ {
+ lock (_lock) _openBatch = [];
+ }
+
+ public void Set(int x, int y, int z, string blockName)
+ {
+ lock (_lock)
+ {
+ if (_openBatch is null) throw new InvalidOperationException("Set called outside a batch");
+
+ var key = (x, y, z);
+ string before = _current.TryGetValue(key, out var cur) ? cur : LookupOriginal(x, y, z);
+ // A true no-op (this position already looks exactly like `blockName`, whether from an
+ // earlier action or because it's what's already on disk) — skip it entirely rather than
+ // recording an edit that would still mark the chunk dirty and cost Save an unnecessary
+ // read-patch-rewrite of a region file nothing actually changed in.
+ if (before == blockName && !_openBatch.ContainsKey(key)) return;
+
+ if (_openBatch.TryGetValue(key, out var existing))
+ {
+ _openBatch[key] = existing with { After = blockName };
+ }
+ else
+ {
+ _openBatch[key] = new Edit(x, y, z, before, blockName);
+ }
+
+ _current[key] = blockName;
+
+ var chunkKey = (FloorDiv(x, 16), FloorDiv(z, 16));
+ if (!_editsByChunk.TryGetValue(chunkKey, out var keysInChunk))
+ {
+ keysInChunk = [];
+ _editsByChunk[chunkKey] = keysInChunk;
+ }
+ keysInChunk.Add(key);
+ }
+ }
+
+ public void EndBatch()
+ {
+ List<(int, int)> touchedChunks;
+ lock (_lock)
+ {
+ if (_openBatch is null) return;
+ var changed = _openBatch.Values.Where(e => e.Before != e.After).ToList();
+ _openBatch = null;
+ if (changed.Count == 0) return;
+
+ _undoStack.Add(changed);
+ _redoStack.Clear();
+ touchedChunks = changed.Select(e => (FloorDiv(e.X, 16), FloorDiv(e.Z, 16))).Distinct().ToList();
+ }
+ ChunksInvalidated?.Invoke(touchedChunks);
+ }
+
+ public void Undo()
+ {
+ List<(int, int)> touched;
+ lock (_lock)
+ {
+ if (_undoStack.Count == 0) return;
+ var batch = _undoStack[^1];
+ _undoStack.RemoveAt(_undoStack.Count - 1);
+ foreach (var e in batch) _current[(e.X, e.Y, e.Z)] = e.Before;
+ _redoStack.Add(batch);
+ touched = batch.Select(e => (FloorDiv(e.X, 16), FloorDiv(e.Z, 16))).Distinct().ToList();
+ }
+ ChunksInvalidated?.Invoke(touched);
+ }
+
+ public void Redo()
+ {
+ List<(int, int)> touched;
+ lock (_lock)
+ {
+ if (_redoStack.Count == 0) return;
+ var batch = _redoStack[^1];
+ _redoStack.RemoveAt(_redoStack.Count - 1);
+ foreach (var e in batch) _current[(e.X, e.Y, e.Z)] = e.After;
+ _undoStack.Add(batch);
+ touched = batch.Select(e => (FloorDiv(e.X, 16), FloorDiv(e.Z, 16))).Distinct().ToList();
+ }
+ ChunksInvalidated?.Invoke(touched);
+ }
+
+ private string LookupOriginal(int x, int y, int z)
+ {
+ if (_chunkStore is null) return "minecraft:air";
+ int cx = FloorDiv(x, 16), cz = FloorDiv(z, 16), sectionY = FloorDiv(y, 16);
+ var slice = _chunkStore.GetOrDecodeSlice(cx, cz, sectionY);
+ if (slice is null) return "minecraft:air";
+ int lx = x - cx * 16, lz = z - cz * 16, localY = y - sectionY * 16;
+ return slice.GetBlock(lx, localY, lz);
+ }
+
+ private static int FloorDiv(int a, int b) => (int)Math.Floor((double)a / b);
+}
diff --git a/src/MapExplorer.Core/World/WorldChunkStore.cs b/src/MapExplorer.Core/World/WorldChunkStore.cs
index ca35029..017520a 100644
--- a/src/MapExplorer.Core/World/WorldChunkStore.cs
+++ b/src/MapExplorer.Core/World/WorldChunkStore.cs
@@ -94,6 +94,13 @@ public sealed class WorldChunkStore(string regionDir, int capacity = 4096, int s
return RegionFile.ParseChunk(buffer, Path.GetFileName(path), localX, localZ);
}
+ /// Drops any cached full decode of this chunk — called after WorldEditWriter.Save writes
+ /// new bytes for it, so a subsequent GetOrDecode (used to re-summarize the edited chunk) can't
+ /// return a decode from before the edit. GetOrDecodeSlice's cache is deliberately left alone: the
+ /// edit-mode overlay is always consulted before any cached slice pixel is used (see
+ /// MapCanvasControl), so a stale cached slice never affects what gets rendered.
+ public void InvalidateChunk(int cx, int cz) => _fullCache.Remove((cx, cz));
+
private static int FloorDiv(int a, int b) => (int)Math.Floor((double)a / b);
/// Fixed-capacity, most-recently-used-first cache. Not disposal-aware — callers store
@@ -107,6 +114,14 @@ private sealed class LruCache where TKey : notnull
public LruCache(int capacity) => _capacity = capacity;
+ public void Remove(TKey key)
+ {
+ lock (_lock)
+ {
+ if (_map.Remove(key, out var node)) _order.Remove(node);
+ }
+ }
+
public bool TryGetValue(TKey key, out TValue value)
{
lock (_lock)
diff --git a/src/MapExplorer.Core/World/WorldEditWriter.cs b/src/MapExplorer.Core/World/WorldEditWriter.cs
new file mode 100644
index 0000000..4ffaa1f
--- /dev/null
+++ b/src/MapExplorer.Core/World/WorldEditWriter.cs
@@ -0,0 +1,98 @@
+using MapExplorer.Core.Region;
+
+namespace MapExplorer.Core.World;
+
+public sealed record WorldEditSaveResult(int RegionsWritten, int ChunksWritten, string? Error);
+
+/// Reported as Save works through the dirty-chunk set — ChunksProcessed counts every chunk
+/// looked at (including ones that turn out to need no patch), not just ones actually written, so it
+/// always reaches TotalChunks by the time Save returns.
+public sealed record WorldEditSaveProgress(int ChunksProcessed, int TotalChunks);
+
+/// Flushes an EditOverlay's dirty chunks to disk: backs up each touched region file (once
+/// per session — an existing .bak is never overwritten, so it always holds the true pre-edit
+/// original), patches every dirty chunk's NBT, and writes the region file back atomically.
+public static class WorldEditWriter
+{
+ public static WorldEditSaveResult Save(string regionDir, EditOverlay overlay, IProgress? progress = null)
+ {
+ var dirtyChunks = overlay.DirtyChunks;
+ if (dirtyChunks.Count == 0) return new WorldEditSaveResult(0, 0, null);
+
+ int totalChunks = dirtyChunks.Count;
+ int processedChunks = 0;
+ long nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ int regionsWritten = 0, chunksWritten = 0;
+
+ foreach (var group in dirtyChunks.GroupBy(c => (FloorDiv(c.ChunkX, 32), FloorDiv(c.ChunkZ, 32))))
+ {
+ var (rx, rz) = group.Key;
+ string path = Path.Combine(regionDir, $"r.{rx}.{rz}.mca");
+
+ try
+ {
+ byte[] original;
+ try
+ {
+ original = File.ReadAllBytes(path);
+ }
+ catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
+ {
+ // Genuinely just an ungenerated area — nothing to patch, not a failure. Anything
+ // else (a real disk error, permission denial, etc.) is deliberately NOT caught
+ // here: it falls through to the outer catch below and gets surfaced as a save
+ // failure instead of being silently swallowed as "region doesn't exist" while the
+ // UI reports success.
+ //
+ // One report per chunk here too, not a single lump sum for the whole group — a
+ // caller driving a progress bar off this expects it to advance smoothly by one
+ // chunk at a time, not jump.
+ foreach (var _ in group)
+ {
+ processedChunks++;
+ progress?.Report(new WorldEditSaveProgress(processedChunks, totalChunks));
+ }
+ continue;
+ }
+
+ string backupPath = path + ".bak";
+ if (!File.Exists(backupPath)) File.Copy(path, backupPath);
+
+ var patches = new Dictionary<(int, int), byte[]>();
+ foreach (var (cx, cz) in group)
+ {
+ // Reported before each chunk's own (cheap but non-zero) patch work, so a caller
+ // watching this mid-save sees "N of Total" advance smoothly rather than in bursts.
+ processedChunks++;
+ progress?.Report(new WorldEditSaveProgress(processedChunks, totalChunks));
+
+ int localX = cx - rx * 32, localZ = cz - rz * 32;
+ var raw = MapExplorer.Core.Region.RegionFile.ParseChunk(original, Path.GetFileName(path), localX, localZ);
+ if (raw is null) continue; // ungenerated chunk — can't patch what isn't there
+
+ var edits = overlay.GetEditsForChunk(cx, cz);
+ if (edits.Count == 0) continue;
+
+ patches[(localX, localZ)] = RegionChunkPatcher.ApplyEdits(raw.Value.Data, cx, cz, edits);
+ chunksWritten++;
+ }
+
+ if (patches.Count == 0) continue;
+
+ var rebuilt = RegionFileWriter.Rebuild(original, patches, nowSeconds);
+ string tmpPath = path + ".tmp";
+ File.WriteAllBytes(tmpPath, rebuilt);
+ File.Move(tmpPath, path, overwrite: true);
+ regionsWritten++;
+ }
+ catch (Exception ex)
+ {
+ return new WorldEditSaveResult(regionsWritten, chunksWritten, ex.Message);
+ }
+ }
+
+ return new WorldEditSaveResult(regionsWritten, chunksWritten, null);
+ }
+
+ private static int FloorDiv(int a, int b) => (int)Math.Floor((double)a / b);
+}
diff --git a/src/MapExplorer.Rendering/ChunkRenderer.cs b/src/MapExplorer.Rendering/ChunkRenderer.cs
index b454b7c..735a2e6 100644
--- a/src/MapExplorer.Rendering/ChunkRenderer.cs
+++ b/src/MapExplorer.Rendering/ChunkRenderer.cs
@@ -85,11 +85,12 @@ public static Rgb GetChunkPixelColor(ChunkSummary summary, NamePalette blockName
/// Slice-mode rendering sourced from a single decoded section instead of a full
/// ChunkData — valid whenever GetDataNeed(config) is Slice. See the ChunkSummary overload above
- /// for what the optional `oreSummary` parameter means.
- public static Rgb GetChunkPixelColor(ChunkSliceData slice, LayerConfig config, int lx, int lz, OreSummary? oreSummary = null)
+ /// for what the optional `oreSummary` parameter means. `blockNameOverride`, when non-null, is used
+ /// in place of the decoded block at (lx, lz) — the edit-mode overlay's unsaved-edit lookup.
+ public static Rgb GetChunkPixelColor(ChunkSliceData slice, LayerConfig config, int lx, int lz, OreSummary? oreSummary = null, string? blockNameOverride = null)
{
int localY = config.SliceY - slice.SectionY * 16;
- Rgb color = Colors.GetBlockColor(slice.GetBlock(lx, localY, lz));
+ Rgb color = Colors.GetBlockColor(blockNameOverride ?? slice.GetBlock(lx, localY, lz));
return ApplyOreOverlay(color, oreSummary, config, lx, lz);
}
@@ -141,6 +142,17 @@ private static Rgb ApplyOreOverlay(Rgb color, OreSummary? oreSummary, LayerConfi
Rgb.ClampByte(ore.G * 0.8 + color.G * 0.2),
Rgb.ClampByte(ore.B * 0.8 + color.B * 0.2));
+ // Dark enough to read at a glance as "not the layer you're drawing on," bright enough that
+ // distinct blocks (stone vs. water vs. a tree trunk) are still tellable apart.
+ private const double BelowLayerDimFactor = 0.35;
+
+ /// Darkens a block's color for edit mode's "layer below" preview, shown through air on
+ /// the currently edited layer so painting near a drop-off or over water doesn't happen blind.
+ public static Rgb DimForBelowLayer(Rgb color) => new(
+ Rgb.ClampByte(color.R * BelowLayerDimFactor),
+ Rgb.ClampByte(color.G * BelowLayerDimFactor),
+ Rgb.ClampByte(color.B * BelowLayerDimFactor));
+
public static int FindSurfaceY(ChunkData chunk, int lx, int lz)
{
const int maxY = ChunkData.ChunkHeight - ChunkData.YOffset - 1;
diff --git a/src/MapExplorer.Rendering/Colors.cs b/src/MapExplorer.Rendering/Colors.cs
index 43e05cb..a1d3cb8 100644
--- a/src/MapExplorer.Rendering/Colors.cs
+++ b/src/MapExplorer.Rendering/Colors.cs
@@ -4,7 +4,11 @@ namespace MapExplorer.Rendering;
// -> RGB lookup, ~150 entries, plus dye/shape-suffix fallback resolution).
public static class Colors
{
- private static readonly Dictionary BlockColors = new()
+ // Exposed publicly (not just via GetBlockColor) so the edit-mode material palette can enumerate
+ // every block this app knows how to render/paint — see EditViewModel.AllMaterials. Kept as a
+ // private, concrete Dictionary internally (dictionary-initializer syntax needs a constructible
+ // type) with a read-only public view over it, so external code can enumerate but not mutate it.
+ private static readonly Dictionary BlockColorsMap = new()
{
// Air / transparent
["minecraft:air"] = new Rgb(0, 0, 0),
@@ -200,6 +204,15 @@ public static class Colors
["minecraft:chest"] = new Rgb(180, 140, 60),
};
+ // A plain `IReadOnlyDictionary => BlockColorsMap` would still just be a reference to
+ // the same mutable Dictionary underneath — a caller could cast it right back and mutate it. Wrap
+ // it in an actual ReadOnlyDictionary (a distinct type, not a Dictionary) so that cast fails
+ // instead. Built once, not per-access — this is read often (e.g. EditViewModel.AllMaterials) but
+ // never needs to reflect BlockColorsMap changing after startup.
+ private static readonly System.Collections.ObjectModel.ReadOnlyDictionary BlockColorsReadOnly = new(BlockColorsMap);
+
+ public static IReadOnlyDictionary BlockColors => BlockColorsReadOnly;
+
// 16 standard dye colors, used for wool/concrete/terracotta/glass/carpet/etc.
private static readonly Dictionary DyeColors = new()
{
@@ -280,11 +293,11 @@ private static Dictionary BuildShapeBaseOverrides()
{
if (!blockName.EndsWith(suffix, StringComparison.Ordinal)) continue;
var basename = blockName[..^suffix.Length];
- if (BlockColors.TryGetValue(basename, out var direct)) return direct;
- if (ShapeBaseOverrides.TryGetValue(basename, out var overrideName) && BlockColors.TryGetValue(overrideName, out var overrideColor))
+ if (BlockColorsMap.TryGetValue(basename, out var direct)) return direct;
+ if (ShapeBaseOverrides.TryGetValue(basename, out var overrideName) && BlockColorsMap.TryGetValue(overrideName, out var overrideColor))
return overrideColor;
// e.g. "stone_brick" -> "stone_bricks", "deepslate_tile" -> "deepslate_tiles"
- if (BlockColors.TryGetValue(basename + "s", out var plural)) return plural;
+ if (BlockColorsMap.TryGetValue(basename + "s", out var plural)) return plural;
return null;
}
return null;
@@ -293,7 +306,7 @@ private static Dictionary BuildShapeBaseOverrides()
private static readonly Rgb FallbackColor = new(128, 128, 128);
public static Rgb GetBlockColor(string blockName) =>
- BlockColors.TryGetValue(blockName, out var direct) ? direct
+ BlockColorsMap.TryGetValue(blockName, out var direct) ? direct
: TryDyeColor(blockName) ?? TryShapeColor(blockName) ?? FallbackColor;
// Biome name -> RGB
diff --git a/test/MapExplorer.Core.Tests/ChunkSectionCodecTests.cs b/test/MapExplorer.Core.Tests/ChunkSectionCodecTests.cs
new file mode 100644
index 0000000..2b9f3a8
--- /dev/null
+++ b/test/MapExplorer.Core.Tests/ChunkSectionCodecTests.cs
@@ -0,0 +1,76 @@
+using MapExplorer.Core.Chunk;
+using MapExplorer.Core.Nbt.RoundTrip;
+
+namespace MapExplorer.Core.Tests;
+
+// EncodeBlockStates/DecodeBlockNames are the write-side counterpart of ChunkDecoder.DecodeSectionBlocks
+// (see ChunkDecoderSectionTests for the read-side equivalent) — round-tripping through them must be
+// lossless for the palette sizes that cross a bits-per-block threshold (4/5/6/7/8 bits at 16/32/64/128
+// distinct values, floored at 4 bits per vanilla's own minimum).
+public sealed class ChunkSectionCodecTests
+{
+ // DecodeBlockNames reads block_states off a wrapping section compound (that's what
+ // RegionChunkPatcher hands it — a real section, with Y/block_states/etc. siblings), while
+ // EncodeBlockStates returns just the block_states compound (what gets assigned back onto that
+ // section) — wrap it the same way here.
+ private static NbtCompoundTag AsSection(NbtCompoundTag blockStates) =>
+ new(new Dictionary { ["block_states"] = blockStates });
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ [InlineData(16)]
+ [InlineData(17)]
+ [InlineData(300)]
+ public void EncodeThenDecode_RoundTripsExactly_ForVariousPaletteSizes(int distinctBlocks)
+ {
+ var names = new string[4096];
+ for (int i = 0; i < names.Length; i++) names[i] = $"minecraft:test_block_{i % distinctBlocks}";
+
+ var encoded = ChunkSectionCodec.EncodeBlockStates(names);
+ var decoded = ChunkSectionCodec.DecodeBlockNames(AsSection(encoded));
+
+ Assert.Equal(names, decoded);
+ }
+
+ [Fact]
+ public void Encode_OmitsDataTag_WhenUniform()
+ {
+ var names = new string[4096];
+ Array.Fill(names, "minecraft:stone");
+
+ var encoded = ChunkSectionCodec.EncodeBlockStates(names);
+
+ Assert.Null(encoded.Get("data"));
+ var palette = Assert.IsType(encoded.Get("palette"));
+ Assert.Single(palette.Items);
+ }
+
+ [Fact]
+ public void Decode_ReturnsAllAir_WhenSectionHasNoBlockStates()
+ {
+ var section = new NbtCompoundTag(new Dictionary { ["Y"] = new NbtIntTag(0) });
+
+ var names = ChunkSectionCodec.DecodeBlockNames(section);
+
+ Assert.All(names, n => Assert.Equal("minecraft:air", n));
+ }
+
+ [Fact]
+ public void Encode_PreservesDistinctBlockAtEachPosition()
+ {
+ var names = new string[4096];
+ Array.Fill(names, "minecraft:air");
+ names[0] = "minecraft:dirt";
+ names[4095] = "minecraft:oak_planks";
+ names[2048] = "minecraft:glass";
+
+ var decoded = ChunkSectionCodec.DecodeBlockNames(AsSection(ChunkSectionCodec.EncodeBlockStates(names)));
+
+ Assert.Equal("minecraft:dirt", decoded[0]);
+ Assert.Equal("minecraft:oak_planks", decoded[4095]);
+ Assert.Equal("minecraft:glass", decoded[2048]);
+ Assert.Equal("minecraft:air", decoded[1]);
+ }
+}
diff --git a/test/MapExplorer.Core.Tests/EditOverlayTests.cs b/test/MapExplorer.Core.Tests/EditOverlayTests.cs
new file mode 100644
index 0000000..1bd267a
--- /dev/null
+++ b/test/MapExplorer.Core.Tests/EditOverlayTests.cs
@@ -0,0 +1,193 @@
+using MapExplorer.Core.World;
+
+namespace MapExplorer.Core.Tests;
+
+public sealed class EditOverlayTests
+{
+ private static EditOverlay MakeOverlay()
+ {
+ var overlay = new EditOverlay();
+ overlay.RebindWorld(new WorldChunkStore(Path.GetTempPath())); // no region files needed for these tests
+ return overlay;
+ }
+
+ [Fact]
+ public void Set_IsVisibleImmediatelyThroughGetOverride()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(1, 64, 2, "minecraft:dirt");
+ overlay.EndBatch();
+
+ Assert.Equal("minecraft:dirt", overlay.GetOverride(1, 64, 2));
+ Assert.Null(overlay.GetOverride(1, 65, 2));
+ }
+
+ [Fact]
+ public void Undo_RestoresThePreEditValue_AndRedoReappliesIt()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt");
+ overlay.EndBatch();
+ Assert.True(overlay.CanUndo);
+
+ overlay.Undo();
+ Assert.Equal("minecraft:air", overlay.GetOverride(0, 0, 0)); // no region on disk -> WorldChunkStore falls back to air
+ Assert.False(overlay.CanUndo);
+ Assert.True(overlay.CanRedo);
+
+ overlay.Redo();
+ Assert.Equal("minecraft:dirt", overlay.GetOverride(0, 0, 0));
+ Assert.False(overlay.CanRedo);
+ }
+
+ [Fact]
+ public void EndBatch_WithNoActualChanges_DoesNotPushAnUndoStep()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:air"); // same as the (fallback) original value
+ overlay.EndBatch();
+
+ Assert.False(overlay.CanUndo);
+ }
+
+ [Fact]
+ public void Set_WithTheValueAlreadyThere_DoesNotMarkTheChunkDirty()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:air"); // same as the (fallback) original value — a true no-op
+ overlay.EndBatch();
+
+ // Not just "no undo step" (already covered above) — Save shouldn't rewrite this chunk's
+ // region file either, since nothing about it actually changed.
+ Assert.Empty(overlay.DirtyChunks);
+ Assert.Empty(overlay.GetEditsForChunk(0, 0));
+ }
+
+ [Fact]
+ public void Set_RepaintingTheSameMaterialASecondTime_DoesNotPushAPhantomUndoStep()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt");
+ overlay.EndBatch();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt"); // already dirt from the batch above — no-op
+ overlay.EndBatch();
+
+ // If the no-op batch had pushed an (empty) undo step anyway, a single Undo() here would only
+ // consume that phantom step and leave the real paint from the first batch still in place.
+ overlay.Undo();
+ Assert.Equal("minecraft:air", overlay.GetOverride(0, 0, 0));
+ Assert.False(overlay.CanUndo);
+ }
+
+ [Fact]
+ public void Set_CalledTwiceInOneBatch_CollapsesToASingleUndoStepUsingTheFirstBeforeValue()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(5, 5, 5, "minecraft:dirt");
+ overlay.Set(5, 5, 5, "minecraft:stone");
+ overlay.EndBatch();
+
+ Assert.Equal("minecraft:stone", overlay.GetOverride(5, 5, 5));
+ overlay.Undo();
+ // Restored to the true original ("air", since there's no region file backing this test) —
+ // Undo always leaves an explicit overlay entry rather than removing it, which renders
+ // identically either way.
+ Assert.Equal("minecraft:air", overlay.GetOverride(5, 5, 5));
+ }
+
+ [Fact]
+ public void DirtyChunks_TracksEveryChunkTouched()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt");
+ overlay.Set(20, 0, 0, "minecraft:dirt"); // chunk (1,0)
+ overlay.EndBatch();
+
+ var dirty = overlay.DirtyChunks;
+ Assert.Contains((0, 0), dirty);
+ Assert.Contains((1, 0), dirty);
+ }
+
+ [Fact]
+ public void GetEditsForChunk_ReturnsChunkLocalCoordinates()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(17, 64, 33, "minecraft:dirt"); // chunk (1, 2), local (1, 1)
+ overlay.EndBatch();
+
+ var edits = overlay.GetEditsForChunk(1, 2);
+ Assert.Equal("minecraft:dirt", edits[(1, 64, 1)]);
+ }
+
+ // GetEditsForChunk/DirtyChunks are backed by a per-chunk index of touched coordinates that's only
+ // ever added to (see EditOverlay's _editsByChunk) — Undo/Redo change values at already-indexed
+ // keys, never remove them from the index. This locks in that a chunk stays visible to Save even
+ // after every edit in it has been undone back to its original value.
+ [Fact]
+ public void GetEditsForChunk_StillFindsTheChunk_AfterAnUndo()
+ {
+ var overlay = MakeOverlay();
+
+ overlay.BeginBatch();
+ overlay.Set(17, 64, 33, "minecraft:dirt"); // chunk (1, 2)
+ overlay.EndBatch();
+ overlay.Undo();
+
+ Assert.Contains((1, 2), overlay.DirtyChunks);
+ var edits = overlay.GetEditsForChunk(1, 2);
+ Assert.Equal("minecraft:air", edits[(1, 64, 1)]);
+ }
+
+ [Fact]
+ public void RebindWorld_ClearsAllStateAndFiresReset()
+ {
+ var overlay = MakeOverlay();
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt");
+ overlay.EndBatch();
+
+ bool resetFired = false;
+ overlay.Reset += () => resetFired = true;
+ overlay.RebindWorld(new WorldChunkStore(Path.GetTempPath()));
+
+ Assert.True(resetFired);
+ Assert.Null(overlay.GetOverride(0, 0, 0));
+ Assert.False(overlay.CanUndo);
+ Assert.Empty(overlay.DirtyChunks);
+ }
+
+ [Fact]
+ public void ChunksInvalidated_FiresOnBatchCommitAndOnUndo()
+ {
+ var overlay = MakeOverlay();
+ var invalidated = new List<(int, int)>();
+ overlay.ChunksInvalidated += chunks => invalidated.AddRange(chunks);
+
+ overlay.BeginBatch();
+ overlay.Set(0, 0, 0, "minecraft:dirt");
+ overlay.EndBatch();
+ Assert.Contains((0, 0), invalidated);
+
+ invalidated.Clear();
+ overlay.Undo();
+ Assert.Contains((0, 0), invalidated);
+ }
+}
diff --git a/test/MapExplorer.Core.Tests/RegionChunkPatcherTests.cs b/test/MapExplorer.Core.Tests/RegionChunkPatcherTests.cs
new file mode 100644
index 0000000..443ea0f
--- /dev/null
+++ b/test/MapExplorer.Core.Tests/RegionChunkPatcherTests.cs
@@ -0,0 +1,133 @@
+using MapExplorer.Core.Chunk;
+using MapExplorer.Core.Nbt.RoundTrip;
+using MapExplorer.Core.Region;
+using static MapExplorer.Core.Tests.NbtFixtureBuilder;
+
+namespace MapExplorer.Core.Tests;
+
+public sealed class RegionChunkPatcherTests
+{
+ [Fact]
+ public void ApplyEdits_ChangesOnlyTheEditedBlocks()
+ {
+ var nbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(1, 5, 2)] = "minecraft:dirt" });
+
+ var full = ChunkDecoder.Decode(patched, 0, 0);
+ Assert.Equal("minecraft:dirt", full.GetBlock(1, 5, 2));
+ Assert.Equal("minecraft:stone", full.GetBlock(0, 5, 2));
+ Assert.Equal("minecraft:stone", full.GetBlock(1, 5, 3));
+ }
+
+ // Real Minecraft chunk NBT stores a section's "Y" as TAG_Byte — NbtFixtureBuilder always emits
+ // TAG_Int (see PackLongArray/BuildSectionCompound), so every other test in this file exercises
+ // an encoding no real .mca file actually uses. Build this one by hand to match reality: a lookup
+ // that only recognizes TAG_Int would never find the existing section, silently create a
+ // duplicate near-empty one instead, and lose the rest of the section's real blocks the next time
+ // something does a full chunk decode (see ChunkDecoder.Decode, which layers every section in
+ // list order without deduplicating by Y).
+ [Fact]
+ public void ApplyEdits_FindsTheExistingSection_WhenYIsStoredAsATagByte()
+ {
+ var blockStates = new NbtCompoundTag(new Dictionary
+ {
+ ["palette"] = new NbtListTag(NbtTagType.Compound,
+ [
+ new NbtCompoundTag(new Dictionary { ["Name"] = new NbtStringTag("minecraft:stone") })
+ ])
+ });
+ var section = new NbtCompoundTag(new Dictionary
+ {
+ ["Y"] = new NbtByteTag(0),
+ ["block_states"] = blockStates
+ });
+ var root = new NbtCompoundTag(new Dictionary
+ {
+ ["sections"] = new NbtListTag(NbtTagType.Compound, [section])
+ });
+ var nbt = NbtRoundTrip.Write(new NbtDocument("", root));
+
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(1, 5, 2)] = "minecraft:dirt" });
+
+ var full = ChunkDecoder.Decode(patched, 0, 0);
+ Assert.Equal("minecraft:dirt", full.GetBlock(1, 5, 2));
+ Assert.Equal("minecraft:stone", full.GetBlock(0, 5, 2)); // rest of the section must survive, not flip to air
+
+ var patchedDoc = NbtRoundTrip.Parse(patched);
+ var sections = Assert.IsType(patchedDoc.Root.Get("sections"));
+ Assert.Single(sections.Items); // must still be exactly one section for Y=0, not a duplicate
+ }
+
+ [Fact]
+ public void ApplyEdits_CreatesASectionThatDidNotExistBefore()
+ {
+ var nbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+
+ // Section Y=10 (world Y 160-175) doesn't exist in the fixture yet.
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(4, 162, 4)] = "minecraft:oak_planks" });
+
+ var slice = ChunkDecoder.DecodeSection(patched, 0, 0, sectionY: 10);
+ Assert.Equal("minecraft:oak_planks", slice.GetBlock(4, 2, 4));
+ }
+
+ [Fact]
+ public void ApplyEdits_RemovesStaleBlockEntityAtAnEditedPosition()
+ {
+ var sectionsNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:chest"])]);
+ var doc = NbtRoundTrip.Parse(sectionsNbt);
+ doc.Root.Set("block_entities", new NbtListTag(NbtTagType.Compound,
+ [
+ new NbtCompoundTag(new Dictionary
+ {
+ ["id"] = new NbtStringTag("minecraft:chest"),
+ ["x"] = new NbtIntTag(3),
+ ["y"] = new NbtIntTag(5),
+ ["z"] = new NbtIntTag(7)
+ })
+ ]));
+ var nbt = NbtRoundTrip.Write(doc);
+
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(3, 5, 7)] = "minecraft:stone" });
+
+ var patchedDoc = NbtRoundTrip.Parse(patched);
+ var blockEntities = Assert.IsType(patchedDoc.Root.Get("block_entities"));
+ Assert.Empty(blockEntities.Items);
+ }
+
+ [Fact]
+ public void ApplyEdits_ResetsIsLightOn()
+ {
+ var sectionsNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ var doc = NbtRoundTrip.Parse(sectionsNbt);
+ doc.Root.Set("isLightOn", new NbtByteTag(1));
+ var nbt = NbtRoundTrip.Write(doc);
+
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(0, 0, 0)] = "minecraft:dirt" });
+
+ var patchedDoc = NbtRoundTrip.Parse(patched);
+ var isLightOn = Assert.IsType(patchedDoc.Root.Get("isLightOn"));
+ Assert.Equal(0, isLightOn.Value);
+ }
+
+ [Fact]
+ public void ApplyEdits_PreservesUnrelatedFields()
+ {
+ var sectionsNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ var doc = NbtRoundTrip.Parse(sectionsNbt);
+ doc.Root.Set("InhabitedTime", new NbtLongTag(12345));
+ var nbt = NbtRoundTrip.Write(doc);
+
+ var patched = RegionChunkPatcher.ApplyEdits(nbt, chunkX: 0, chunkZ: 0,
+ new Dictionary<(int, int, int), string> { [(0, 0, 0)] = "minecraft:dirt" });
+
+ var patchedDoc = NbtRoundTrip.Parse(patched);
+ var inhabitedTime = Assert.IsType(patchedDoc.Root.Get("InhabitedTime"));
+ Assert.Equal(12345, inhabitedTime.Value);
+ }
+}
diff --git a/test/MapExplorer.Core.Tests/RegionFileWriterTests.cs b/test/MapExplorer.Core.Tests/RegionFileWriterTests.cs
new file mode 100644
index 0000000..621053e
--- /dev/null
+++ b/test/MapExplorer.Core.Tests/RegionFileWriterTests.cs
@@ -0,0 +1,114 @@
+using System.Buffers.Binary;
+using System.IO.Compression;
+using MapExplorer.Core.Region;
+using static MapExplorer.Core.Tests.NbtFixtureBuilder;
+
+namespace MapExplorer.Core.Tests;
+
+public sealed class RegionFileWriterTests
+{
+ [Fact]
+ public void Rebuild_AppliesPatchedChunk_AndLeavesUntouchedChunksByteIdentical()
+ {
+ var untouchedNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ var originalNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:dirt"])]);
+ var patchedNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:diamond_block"])]);
+
+ var original = RegionFileFixtureBuilder.BuildRegion(
+ [
+ (LocalX: 0, LocalZ: 0, Nbt: untouchedNbt),
+ (LocalX: 5, LocalZ: 5, Nbt: originalNbt)
+ ]);
+
+ var rebuilt = RegionFileWriter.Rebuild(original,
+ new Dictionary<(int, int), byte[]> { [(5, 5)] = patchedNbt },
+ timestampSeconds: 1_700_000_000);
+
+ var untouchedChunk = RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 0, 0);
+ Assert.NotNull(untouchedChunk);
+ Assert.Equal(untouchedNbt, untouchedChunk.Value.Data);
+
+ var patchedChunk = RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 5, 5);
+ Assert.NotNull(patchedChunk);
+ Assert.Equal(patchedNbt, patchedChunk.Value.Data);
+ }
+
+ [Fact]
+ public void Rebuild_PreservesEmptySlots()
+ {
+ var nbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ var original = RegionFileFixtureBuilder.BuildRegion([(LocalX: 3, LocalZ: 3, Nbt: nbt)]);
+
+ var rebuilt = RegionFileWriter.Rebuild(original, new Dictionary<(int, int), byte[]>(), timestampSeconds: 0);
+
+ Assert.Null(RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 0, 0));
+ Assert.NotNull(RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 3, 3));
+ }
+
+ [Fact]
+ public void Rebuild_CanAddANewChunkNotPresentInTheOriginal()
+ {
+ var original = RegionFileFixtureBuilder.BuildRegion([]);
+ var newNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:grass_block"])]);
+
+ var rebuilt = RegionFileWriter.Rebuild(original,
+ new Dictionary<(int, int), byte[]> { [(7, 9)] = newNbt },
+ timestampSeconds: 123);
+
+ var chunk = RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 7, 9);
+ Assert.NotNull(chunk);
+ Assert.Equal(newNbt, chunk.Value.Data);
+ }
+
+ [Fact]
+ public void Rebuild_ThrowsInsteadOfSilentlyCorruptingTheFile_WhenAChunkNeedsMoreThan255Sectors()
+ {
+ var original = RegionFileFixtureBuilder.BuildRegion([]);
+
+ // Random (so effectively incompressible) payload comfortably past the 255-sector
+ // (~1,044,480-byte) ceiling a single entry's 1-byte sectorCount field can hold — a real
+ // .mca writer would spill this into a separate .mcc file; this one doesn't support that and
+ // must fail loudly rather than let sectorCount overflow into the offset field and corrupt
+ // both this entry and, via bit overlap, its neighbors.
+ var oversized = new byte[1_100_000];
+ Random.Shared.NextBytes(oversized);
+
+ var ex = Assert.Throws(() =>
+ RegionFileWriter.Rebuild(original, new Dictionary<(int, int), byte[]> { [(1, 1)] = oversized }, timestampSeconds: 0));
+
+ Assert.Contains("255", ex.Message);
+ }
+
+ [Fact]
+ public void Rebuild_ReadsAnUntouchedEntry_WhoseRealBytesEndExactlyAtTheBufferBoundary()
+ {
+ // RegionFileFixtureBuilder always pads every entry out to a full 4096-byte sector, so it can
+ // never exercise this: an entry whose actual (unpadded) byte range ends precisely at
+ // buffer.Length, with zero bytes of trailing sector padding. Built by hand instead, to pin
+ // down the exact off-by-one boundary condition ReadRawEntryBytes' bounds check must accept.
+ var nbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+
+ using var compressedStream = new MemoryStream();
+ using (var zlib = new ZLibStream(compressedStream, CompressionLevel.Optimal, leaveOpen: true)) zlib.Write(nbt);
+ var compressed = compressedStream.ToArray();
+
+ var entry = new byte[5 + compressed.Length];
+ BinaryPrimitives.WriteUInt32BigEndian(entry, (uint)(compressed.Length + 1));
+ entry[4] = 2; // zlib
+ compressed.CopyTo(entry, 5);
+
+ var original = new byte[8192 + entry.Length]; // header (offsets + timestamps) + the entry, no padding after it
+ BinaryPrimitives.WriteUInt32BigEndian(original.AsSpan(0, 4), (2u << 8) | 1u); // slot (0,0): sector 2, count 1
+ entry.CopyTo(original, 8192);
+
+ // Patch an unrelated slot so slot (0,0) goes through the verbatim-copy path being tested.
+ var otherNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:dirt"])]);
+ var rebuilt = RegionFileWriter.Rebuild(original,
+ new Dictionary<(int, int), byte[]> { [(5, 5)] = otherNbt },
+ timestampSeconds: 0);
+
+ var chunk = RegionFile.ParseChunk(rebuilt, "r.0.0.mca", 0, 0);
+ Assert.NotNull(chunk);
+ Assert.Equal(nbt, chunk.Value.Data);
+ }
+}
diff --git a/test/MapExplorer.Core.Tests/WorldEditWriterTests.cs b/test/MapExplorer.Core.Tests/WorldEditWriterTests.cs
new file mode 100644
index 0000000..d5c2a64
--- /dev/null
+++ b/test/MapExplorer.Core.Tests/WorldEditWriterTests.cs
@@ -0,0 +1,151 @@
+using MapExplorer.Core.Chunk;
+using MapExplorer.Core.World;
+using static MapExplorer.Core.Tests.NbtFixtureBuilder;
+
+namespace MapExplorer.Core.Tests;
+
+public sealed class WorldEditWriterTests : IDisposable
+{
+ private readonly string _regionDir = Path.Combine(Path.GetTempPath(), $"region-{Guid.NewGuid():N}");
+
+ public WorldEditWriterTests()
+ {
+ Directory.CreateDirectory(_regionDir);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_regionDir, recursive: true);
+ }
+
+ private string WriteFixtureRegion(int regionX, int regionZ, params (int LocalX, int LocalZ, byte[] Nbt)[] chunks)
+ {
+ var path = Path.Combine(_regionDir, $"r.{regionX}.{regionZ}.mca");
+ File.WriteAllBytes(path, RegionFileFixtureBuilder.BuildRegion(chunks));
+ return path;
+ }
+
+ private static EditOverlay MakeOverlay(string regionDir)
+ {
+ var overlay = new EditOverlay();
+ overlay.RebindWorld(new WorldChunkStore(regionDir));
+ return overlay;
+ }
+
+ [Fact]
+ public void Save_WritesEditedBlocksAndCreatesABackupOnlyOnce()
+ {
+ var nbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ var path = WriteFixtureRegion(0, 0, (LocalX: 2, LocalZ: 3, Nbt: nbt));
+ var originalBytes = File.ReadAllBytes(path);
+
+ var overlay = MakeOverlay(_regionDir);
+ overlay.BeginBatch();
+ overlay.Set(x: 2 * 16 + 1, y: 5, z: 3 * 16 + 4, "minecraft:dirt");
+ overlay.EndBatch();
+
+ var result = WorldEditWriter.Save(_regionDir, overlay);
+
+ Assert.Null(result.Error);
+ Assert.Equal(1, result.RegionsWritten);
+ Assert.Equal(1, result.ChunksWritten);
+
+ var backupPath = path + ".bak";
+ Assert.True(File.Exists(backupPath));
+ Assert.Equal(originalBytes, File.ReadAllBytes(backupPath));
+
+ var savedBuffer = File.ReadAllBytes(path);
+ var chunk = MapExplorer.Core.Region.RegionFile.ParseChunk(savedBuffer, "r.0.0.mca", 2, 3);
+ Assert.NotNull(chunk);
+ var decoded = ChunkDecoder.Decode(chunk.Value.Data, 2, 3);
+ Assert.Equal("minecraft:dirt", decoded.GetBlock(1, 5, 4));
+ Assert.Equal("minecraft:stone", decoded.GetBlock(0, 5, 4));
+
+ // A second save (e.g. after more edits) must not clobber the backup with the already-edited file.
+ overlay.BeginBatch();
+ overlay.Set(x: 2 * 16 + 1, y: 5, z: 3 * 16 + 4, "minecraft:glass");
+ overlay.EndBatch();
+ WorldEditWriter.Save(_regionDir, overlay);
+
+ Assert.Equal(originalBytes, File.ReadAllBytes(backupPath));
+ }
+
+ [Fact]
+ public void Save_WithNoDirtyChunks_IsANoOp()
+ {
+ var overlay = MakeOverlay(_regionDir);
+
+ var result = WorldEditWriter.Save(_regionDir, overlay);
+
+ Assert.Equal(0, result.RegionsWritten);
+ Assert.Equal(0, result.ChunksWritten);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Save_SkipsRegionsThatDoNotExistOnDisk()
+ {
+ var overlay = MakeOverlay(_regionDir);
+ overlay.BeginBatch();
+ overlay.Set(x: 999, y: 5, z: 999, "minecraft:dirt");
+ overlay.EndBatch();
+
+ var result = WorldEditWriter.Save(_regionDir, overlay);
+
+ Assert.Equal(0, result.RegionsWritten);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Save_SurfacesARealIoError_InsteadOfTreatingItAsAnUngeneratedRegion()
+ {
+ // A directory sitting where the region file should be: File.ReadAllBytes throws for this
+ // (UnauthorizedAccessException on both Windows and Unix, per .NET's directory handling) but
+ // it is NOT FileNotFoundException/DirectoryNotFoundException — the path genuinely exists.
+ // Must surface as a save failure, not be silently swallowed as "ungenerated area."
+ Directory.CreateDirectory(Path.Combine(_regionDir, "r.0.0.mca"));
+
+ var overlay = MakeOverlay(_regionDir);
+ overlay.BeginBatch();
+ overlay.Set(x: 0, y: 5, z: 0, "minecraft:dirt");
+ overlay.EndBatch();
+
+ var result = WorldEditWriter.Save(_regionDir, overlay);
+
+ Assert.NotNull(result.Error);
+ Assert.Equal(0, result.RegionsWritten);
+ }
+
+ [Fact]
+ public void Save_ReportsProgressForEveryDirtyChunkAndEndsAtTheTotal()
+ {
+ var stoneNbt = BuildChunkNbt([new SectionFixture(Y: 0, BlockPalette: ["minecraft:stone"])]);
+ WriteFixtureRegion(0, 0,
+ (LocalX: 0, LocalZ: 0, Nbt: stoneNbt),
+ (LocalX: 1, LocalZ: 0, Nbt: stoneNbt),
+ (LocalX: 2, LocalZ: 0, Nbt: stoneNbt));
+
+ var overlay = MakeOverlay(_regionDir);
+ overlay.BeginBatch();
+ overlay.Set(x: 0, y: 5, z: 0, "minecraft:dirt");
+ overlay.Set(x: 16, y: 5, z: 0, "minecraft:dirt");
+ overlay.Set(x: 32, y: 5, z: 0, "minecraft:dirt");
+ overlay.EndBatch();
+
+ var reports = new List();
+ WorldEditWriter.Save(_regionDir, overlay, new SynchronousProgress(reports.Add));
+
+ Assert.Equal(3, reports.Count);
+ Assert.All(reports, r => Assert.Equal(3, r.TotalChunks));
+ Assert.Equal([1, 2, 3], reports.Select(r => r.ChunksProcessed));
+ }
+
+ // Progress posts its callback back through whatever SynchronizationContext was current at
+ // construction time — in a test host that's often no context at all, which falls back to an
+ // asynchronous ThreadPool post, so Save returning wouldn't guarantee every report has landed yet.
+ // This trivial synchronous IProgress sidesteps that entirely for deterministic assertions.
+ private sealed class SynchronousProgress(Action report) : IProgress
+ {
+ public void Report(T value) => report(value);
+ }
+}
diff --git a/test/MapExplorer.Rendering.Tests/ChunkRendererTests.cs b/test/MapExplorer.Rendering.Tests/ChunkRendererTests.cs
index f6e8a53..09203c4 100644
--- a/test/MapExplorer.Rendering.Tests/ChunkRendererTests.cs
+++ b/test/MapExplorer.Rendering.Tests/ChunkRendererTests.cs
@@ -156,4 +156,23 @@ public void SurfaceModeRendersSnowAndIceAsLightNotDark(string blockName, int sur
Assert.True(color.R > 80 && color.G > 80 && color.B > 80,
$"{blockName} at y={surfaceY} rendered as ({color.R},{color.G},{color.B}), expected a light color");
}
+
+ [Fact]
+ public void DimForBelowLayerDarkensButPreservesHue()
+ {
+ var color = new Rgb(200, 100, 50);
+
+ var dimmed = ChunkRenderer.DimForBelowLayer(color);
+
+ Assert.True(dimmed.R < color.R && dimmed.G < color.G && dimmed.B < color.B);
+ // Relative ordering between channels should survive dimming, so distinct blocks
+ // still read as distinct colors, just darker.
+ Assert.True(dimmed.R > dimmed.G && dimmed.G > dimmed.B);
+ }
+
+ [Fact]
+ public void DimForBelowLayerKeepsBlackAtBlack()
+ {
+ Assert.Equal(new Rgb(0, 0, 0), ChunkRenderer.DimForBelowLayer(new Rgb(0, 0, 0)));
+ }
}