Fix engine-wide code review findings
CI / build-test (push) Failing after 1m7s

Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 21:18:08 +03:00
co-authored by Claude Fable 5
parent 56f2a85478
commit 501d81e19f
35 changed files with 926 additions and 98 deletions
+28 -13
View File
@@ -54,6 +54,9 @@ public static class AtlasBuilder
{
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks);
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
@@ -87,10 +90,10 @@ public static class AtlasBuilder
return (name, key);
}
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
{
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
{
@@ -113,14 +116,15 @@ public static class AtlasBuilder
groups.Add(atlasName, list);
}
list.Add((fullPath, key));
var info = new FileInfo(fullPath);
list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
string name, List<SourceFile> files, AtlasBuildOptions options)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
@@ -151,14 +155,14 @@ public static class AtlasBuilder
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
WriteMetadata(name, packed, options, metadataPath);
WriteMetadata(name, packed, files, options, metadataPath);
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
}
private static bool IsUpToDate(
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
string metadataPath, List<SourceFile> files, AtlasBuildOptions options, out int pages)
{
pages = 0;
if (!File.Exists(metadataPath))
@@ -176,7 +180,8 @@ public static class AtlasBuilder
return false;
}
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
if (metadata.Version != AtlasMetadata.CurrentVersion ||
metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
{
return false;
}
@@ -187,16 +192,21 @@ public static class AtlasBuilder
return false;
}
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
if (metadata.Sources.Count != files.Count)
{
return false;
}
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
foreach (var file in files)
{
return false;
if (!sourcesByKey.TryGetValue(file.Key, out var source) ||
source.Size != file.Size || source.Modified != file.ModifiedTicks)
{
return false;
}
}
pages = metadata.Pages.Count;
@@ -233,13 +243,18 @@ public static class AtlasBuilder
});
}
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
private static void WriteMetadata(
string name, PackResult packed, List<SourceFile> files, AtlasBuildOptions options, string metadataPath)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
Padding = options.Padding,
Sources = files
.OrderBy(f => f.Key, StringComparer.Ordinal)
.Select(f => new AtlasSource { Key = f.Key, Size = f.Size, Modified = f.ModifiedTicks })
.ToList(),
Pages = packed.PageSizes
.Select((size, index) => new AtlasPage
{
+25 -2
View File
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>Format version, bumped on breaking metadata changes.</summary>
public int Version { get; init; } = 1;
/// <summary>Current format version. Bumped on breaking metadata changes.</summary>
public const int CurrentVersion = 2;
/// <summary>Format version of this file; readers reject other versions.</summary>
public int Version { get; init; } = CurrentVersion;
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
public string Name { get; init; } = "";
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
/// <summary>Packed regions, sorted by key.</summary>
public List<AtlasRegion> Regions { get; init; } = [];
/// <summary>Source files the atlas was built from, sorted by key (staleness check input).</summary>
public List<AtlasSource> Sources { get; init; } = [];
/// <summary>Serializes this metadata to indented JSON.</summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
@@ -55,6 +61,23 @@ public sealed class AtlasPage
public int Height { get; init; }
}
/// <summary>
/// Snapshot of one source file at build time. The incremental rebuild compares key, size and
/// modification time instead of relying on "source newer than metadata", so timestamp-preserving
/// renames and copies still invalidate the atlas.
/// </summary>
public sealed class AtlasSource
{
/// <summary>Region key of the source file.</summary>
public string Key { get; init; } = "";
/// <summary>Source file size in bytes.</summary>
public long Size { get; init; }
/// <summary>Source file <c>LastWriteTimeUtc</c> in ticks.</summary>
public long Modified { get; init; }
}
/// <summary>One packed source texture inside an atlas.</summary>
public sealed class AtlasRegion
{
+23 -6
View File
@@ -17,7 +17,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y,
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
/// <param name="PageSizes">
/// Width/height of each page: the next power of two covering its content, clamped to the
/// page-size limit. Dedicated pages of oversized items keep their exact (padded) size.
/// </param>
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
/// <summary>
@@ -65,7 +68,9 @@ public static class ShelfPacker
{
if (open)
{
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
pageSizes.Add((
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)));
open = false;
}
}
@@ -81,15 +86,22 @@ public static class ShelfPacker
usedHeight = 0;
}
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
foreach (var item in sorted)
{
// Слишком большой для общей страницы — отдельная страница точно под него.
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
CloseOpenPage();
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
continue;
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
}
}
foreach (var item in sorted)
{
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
continue; // уже размещён на отдельной странице
}
if (!open)
@@ -119,6 +131,11 @@ public static class ShelfPacker
return new PackResult(placements, pageSizes);
}
// POT удобен GPU, но страница не должна превышать заявленный лимит,
// когда maxPageSize сам не степень двойки.
private static int PageDimension(int used, int maxPageSize) =>
Math.Min(NextPowerOfTwo(used), maxPageSize);
internal static int NextPowerOfTwo(int value)
{
var result = 1;
+22 -3
View File
@@ -56,12 +56,31 @@ public sealed class TextureAtlas : IDisposable
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
{
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
if (metadata.Version != AtlasMetadata.CurrentVersion)
{
throw new InvalidDataException(
$"Atlas '{metadataPath}' has format version {metadata.Version}, expected " +
$"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool.");
}
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
var pages = new Texture2D[metadata.Pages.Count];
for (var i = 0; i < pages.Length; i++)
try
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
for (var i = 0; i < pages.Length; i++)
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
}
}
catch
{
foreach (var page in pages)
{
page?.Dispose(); // частично загруженные страницы не должны утекать
}
throw;
}
return new TextureAtlas(metadata, pages);