diff --git a/docs/PublicApi.md b/docs/PublicApi.md index e788f16..b099f28 100644 --- a/docs/PublicApi.md +++ b/docs/PublicApi.md @@ -246,6 +246,7 @@ namespace ArcNET.Archive public System.Collections.Generic.IReadOnlyCollection Entries { get; } public void Dispose() { } public ArcNET.Archive.ArchiveEntry? FindEntry(string virtualPath) { } + public System.ReadOnlyMemory GetEntryData(ArcNET.Archive.ArchiveEntry entry) { } public System.ReadOnlyMemory GetEntryData(string name) { } public System.IO.Stream OpenEntry(string name) { } public byte[] ReadEntry(ArcNET.Archive.ArchiveEntry entry) { } @@ -3324,6 +3325,7 @@ namespace ArcNET.GameData.Workspace public sealed class WorkspaceAssetSource { public WorkspaceAssetSource() { } + public int? ByteLength { get; init; } public string? SourceEntryPath { get; init; } public required ArcNET.GameData.Workspace.WorkspaceAssetSourceKind SourceKind { get; init; } public required string SourcePath { get; init; } @@ -3390,6 +3392,25 @@ namespace ArcNET.GameData.Workspace public static bool TryResolveSingleModuleDirectory(string gameDirectory, out string moduleDirectory) { } public static bool TryResolveWorkspaceModuleDirectory(string path, out string moduleDirectory) { } } + public static class WorkspaceJumpPointCatalogBuilder + { + public static System.Collections.Generic.IReadOnlyList Build(ArcNET.GameData.GameDataStore gameData) { } + } + public sealed class WorkspaceJumpPointCatalogEntry : System.IEquatable + { + public WorkspaceJumpPointCatalogEntry(string SourceAssetPath, string SourceMapName, uint Flags, long SourcePackedLocation, int SourceTileX, int SourceTileY, int DestinationMapId, long DestinationPackedLocation, int DestinationTileX, int DestinationTileY, string SummaryText) { } + public int DestinationMapId { get; init; } + public long DestinationPackedLocation { get; init; } + public int DestinationTileX { get; init; } + public int DestinationTileY { get; init; } + public uint Flags { get; init; } + public string SourceAssetPath { get; init; } + public string SourceMapName { get; init; } + public long SourcePackedLocation { get; init; } + public int SourceTileX { get; init; } + public int SourceTileY { get; init; } + public string SummaryText { get; init; } + } public sealed class WorkspaceLoadReport { public WorkspaceLoadReport(System.Collections.Generic.IReadOnlyList skippedArchiveCandidates, System.Collections.Generic.IReadOnlyList skippedAssets) { } @@ -3418,15 +3439,39 @@ namespace ArcNET.GameData.Workspace } public sealed class WorkspacePrototypeCatalogEntry : System.IEquatable { - public WorkspacePrototypeCatalogEntry(int ProtoNumber, ArcNET.GameObjects.ObjectType ObjectType, string AssetPath, string? DisplayName, string? Description, string? PaletteGroup, ArcNET.Core.Primitives.ArtId? CurrentArtId, string? ArtAssetPath) { } + public WorkspacePrototypeCatalogEntry( + int ProtoNumber, + ArcNET.GameObjects.ObjectType ObjectType, + string AssetPath, + string? DisplayName, + string? Description, + string? PaletteGroup, + ArcNET.Core.Primitives.ArtId? CurrentArtId, + ArcNET.Core.Primitives.ArtId? DestroyedArtId, + string? ArtAssetPath, + ArcNET.GameObjects.PortalFlags? PortalFlags = default, + ArcNET.GameObjects.ContainerFlags? ContainerFlags = default, + ArcNET.GameObjects.SceneryFlags? SceneryFlags = default, + int? PortalLockDifficulty = default, + int? PortalKeyId = default, + int? ContainerLockDifficulty = default, + int? ContainerKeyId = default) { } public string? ArtAssetPath { get; init; } public string AssetPath { get; init; } + public ArcNET.GameObjects.ContainerFlags? ContainerFlags { get; init; } + public int? ContainerKeyId { get; init; } + public int? ContainerLockDifficulty { get; init; } public ArcNET.Core.Primitives.ArtId? CurrentArtId { get; init; } public string? Description { get; init; } + public ArcNET.Core.Primitives.ArtId? DestroyedArtId { get; init; } public string? DisplayName { get; init; } public ArcNET.GameObjects.ObjectType ObjectType { get; init; } public string? PaletteGroup { get; init; } + public ArcNET.GameObjects.PortalFlags? PortalFlags { get; init; } + public int? PortalKeyId { get; init; } + public int? PortalLockDifficulty { get; init; } public int ProtoNumber { get; init; } + public ArcNET.GameObjects.SceneryFlags? SceneryFlags { get; init; } } public sealed class WorkspaceSkippedArchiveCandidate { @@ -3444,6 +3489,19 @@ namespace ArcNET.GameData.Workspace public required ArcNET.GameData.Workspace.WorkspaceAssetSourceKind SourceKind { get; init; } public required string SourcePath { get; init; } } + public static class WorkspaceSpellCatalogBuilder + { + public static System.Collections.Generic.IReadOnlyList Build(ArcNET.GameData.GameDataStore gameData) { } + } + public sealed class WorkspaceSpellCatalogEntry : System.IEquatable + { + public WorkspaceSpellCatalogEntry(int SpellId, string Name, int CollegeId, string CollegeName, int Level) { } + public int CollegeId { get; init; } + public string CollegeName { get; init; } + public int Level { get; init; } + public string Name { get; init; } + public int SpellId { get; init; } + } public static class WorkspaceStaticObjectCatalogBuilder { public static System.Collections.Generic.IReadOnlyList Build(ArcNET.GameData.GameDataStore gameData, System.Collections.Generic.IReadOnlyDictionary prototypesByNumber) { } @@ -3451,15 +3509,43 @@ namespace ArcNET.GameData.Workspace } public sealed class WorkspaceStaticObjectCatalogEntry : System.IEquatable { - public WorkspaceStaticObjectCatalogEntry(string SourceKindText, string DisplayName, ArcNET.GameObjects.ObjectType ObjectType, string ObjectIdText, string ObjectGuidText, int? ProtoNumber, string PrototypeText, string SourceAssetPath, string LocationText, string SummaryText) { } + public WorkspaceStaticObjectCatalogEntry( + string SourceKindText, + string DisplayName, + ArcNET.GameObjects.ObjectType ObjectType, + string ObjectIdText, + string ObjectGuidText, + int? ProtoNumber, + string PrototypeText, + ArcNET.Core.Primitives.ArtId? CurrentArtId, + ArcNET.Core.Primitives.ArtId? DestroyedArtId, + string SourceAssetPath, + string LocationText, + string SummaryText, + ArcNET.GameObjects.PortalFlags? PortalFlags = default, + ArcNET.GameObjects.ContainerFlags? ContainerFlags = default, + ArcNET.GameObjects.SceneryFlags? SceneryFlags = default, + int? PortalLockDifficulty = default, + int? PortalKeyId = default, + int? ContainerLockDifficulty = default, + int? ContainerKeyId = default) { } + public ArcNET.GameObjects.ContainerFlags? ContainerFlags { get; init; } + public int? ContainerKeyId { get; init; } + public int? ContainerLockDifficulty { get; init; } + public ArcNET.Core.Primitives.ArtId? CurrentArtId { get; init; } + public ArcNET.Core.Primitives.ArtId? DestroyedArtId { get; init; } public string DisplayName { get; init; } public bool HasPrototype { get; } public string LocationText { get; init; } public string ObjectGuidText { get; init; } public string ObjectIdText { get; init; } public ArcNET.GameObjects.ObjectType ObjectType { get; init; } + public ArcNET.GameObjects.PortalFlags? PortalFlags { get; init; } + public int? PortalKeyId { get; init; } + public int? PortalLockDifficulty { get; init; } public int? ProtoNumber { get; init; } public string PrototypeText { get; init; } + public ArcNET.GameObjects.SceneryFlags? SceneryFlags { get; init; } public string SourceAssetPath { get; init; } public string SourceKindText { get; init; } public string SummaryText { get; init; } @@ -3562,6 +3648,7 @@ namespace ArcNET.GameData.Workspace public WorkspaceWorldAreaMapEntry() { } public int EntryTileX { get; init; } public int EntryTileY { get; init; } + public int MapId { get; init; } public required string MapName { get; init; } public string? Type { get; init; } public int? WorldMapId { get; init; } @@ -6399,6 +6486,7 @@ namespace ArcNET.Patch [assembly: System.Reflection.AssemblyMetadata("IsTrimmable", "True")] [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Bia10/ArcNET/")] [assembly: System.Resources.NeutralResourcesLanguage("en")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ArcNET.Benchmarks")] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ArcNET.Editor.Tests")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] namespace ArcNET.Editor @@ -7303,7 +7391,7 @@ namespace ArcNET.Editor } public static class EditorMapPaintableSceneBuilder { - public static ArcNET.Editor.EditorMapPaintableScene Build(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorMapPlacementPreview? placementPreview = null, ArcNET.Editor.IEditorMapRenderSpriteSource? spriteSource = null, ArcNET.Editor.EditorMapRenderSpriteCoverage? existingSpriteCoverage = null, System.Threading.CancellationToken cancellationToken = default) { } + public static ArcNET.Editor.EditorMapPaintableScene Build(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorMapPlacementPreview? placementPreview = null, ArcNET.Editor.IEditorMapRenderSpriteSource? spriteSource = null, ArcNET.Editor.EditorMapRenderSpriteCoverage? existingSpriteCoverage = null, System.Threading.CancellationToken cancellationToken = default, bool includeVirtualTerrainSpriteCoverage = true) { } public static ArcNET.Editor.EditorMapPaintableScene BuildPlacementOverlay(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorMapPlacementPreview? placementPreview, ArcNET.Editor.IEditorMapRenderSpriteSource? spriteSource = null, System.Threading.CancellationToken cancellationToken = default) { } } public readonly struct EditorMapPaintableSceneGeometry : System.IEquatable @@ -7347,7 +7435,9 @@ namespace ArcNET.Editor public uint? SuggestedTintColor { get; init; } public bool SuppressFallback { get; init; } public ArcNET.Editor.EditorMapTileLightDiagnostics? TileLightDiagnostics { get; init; } + public string? TileOverlayDetail { get; init; } public ArcNET.Editor.EditorMapTileOverlayKind? TileOverlayKind { get; init; } + public string? TileOverlayLabel { get; init; } public bool TintIgnoresLightVisibility { get; init; } public required double Top { get; init; } public bool UseGrayscalePaletteOverride { get; init; } @@ -7479,6 +7569,14 @@ namespace ArcNET.Editor public required ulong LimitX { get; init; } public required ulong LimitY { get; init; } } + public readonly struct EditorMapRenderBounds : System.IEquatable + { + public EditorMapRenderBounds(double Left, double Top, double Right, double Bottom) { } + public double Bottom { get; init; } + public double Left { get; init; } + public double Right { get; init; } + public double Top { get; init; } + } public sealed class EditorMapRenderHit { public EditorMapRenderHit() { } @@ -7605,6 +7703,9 @@ namespace ArcNET.Editor public EditorMapRenderViewportState() { } public double CenterRenderX { get; init; } public double CenterRenderY { get; init; } + public double PitchDegrees { get; init; } + public double RollDegrees { get; init; } + public double YawDegrees { get; init; } public double Zoom { get; init; } } public readonly struct EditorMapRoofAlphaLerp : System.IEquatable @@ -7659,14 +7760,21 @@ namespace ArcNET.Editor } public static class EditorMapSceneRenderSpaceMath { + public const double MaxCameraTiltDegrees = 60D; + public static double ClampCameraTiltDegrees(double value) { } public static ArcNET.Editor.EditorMapSceneViewportLayout CreateViewportLayout(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, double viewportWidth, double viewportHeight, ArcNET.Editor.EditorMapRenderViewportState? viewportState = null) { } public static ArcNET.Editor.EditorMapRenderViewportState CreateViewportState(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorProjectMapCameraState? camera = null) { } + public static ArcNET.Editor.EditorMapSceneViewportTransform CreateViewportTransform(ArcNET.Editor.EditorMapSceneViewportLayout layout) { } + public static ArcNET.Editor.EditorMapRenderBounds CreateVisibleRenderBounds(ArcNET.Editor.EditorMapSceneViewportLayout layout) { } public static ArcNET.Editor.EditorMapRenderHit? HitTestScene(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorMapSceneViewportLayout layout, double viewportX, double viewportY) { } public static ArcNET.Editor.EditorProjectMapSelectionState? HitTestSceneSelection(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, ArcNET.Editor.EditorMapSceneViewportLayout layout, double viewportX, double viewportY) { } + public static double NormalizeRollDegrees(double value) { } public static ArcNET.Editor.EditorMapRenderPoint ProjectMapTileCenter(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, double mapTileX, double mapTileY) { } + public static ArcNET.Editor.EditorMapRenderPoint RenderToViewportPoint(ArcNET.Editor.EditorMapSceneViewportLayout layout, double renderX, double renderY) { } public static double RenderToViewportX(ArcNET.Editor.EditorMapSceneViewportLayout layout, double renderX) { } public static double RenderToViewportY(ArcNET.Editor.EditorMapSceneViewportLayout layout, double renderY) { } public static ArcNET.Editor.EditorMapTilePoint UnprojectMapTile(ArcNET.Editor.EditorMapFloorRenderPreview sceneRender, double renderX, double renderY) { } + public static ArcNET.Editor.EditorMapRenderPoint ViewportToRenderPoint(ArcNET.Editor.EditorMapSceneViewportLayout layout, double viewportX, double viewportY) { } public static double ViewportToRenderX(ArcNET.Editor.EditorMapSceneViewportLayout layout, double viewportX) { } public static double ViewportToRenderY(ArcNET.Editor.EditorMapSceneViewportLayout layout, double viewportY) { } } @@ -7689,6 +7797,8 @@ namespace ArcNET.Editor public EditorMapSceneViewportLayout() { } public required double CenterRenderX { get; init; } public required double CenterRenderY { get; init; } + public double PitchDegrees { get; init; } + public double RollDegrees { get; init; } public required double SceneHeight { get; init; } public required double SceneWidth { get; init; } public required double ViewportHeight { get; init; } @@ -7697,8 +7807,19 @@ namespace ArcNET.Editor public double VisibleLeft { get; } public double VisibleRight { get; } public double VisibleTop { get; } + public double YawDegrees { get; init; } public required double Zoom { get; init; } } + public readonly struct EditorMapSceneViewportTransform : System.IEquatable + { + public EditorMapSceneViewportTransform(double M11, double M12, double M21, double M22, double OffsetX, double OffsetY) { } + public double M11 { get; init; } + public double M12 { get; init; } + public double M21 { get; init; } + public double M22 { get; init; } + public double OffsetX { get; init; } + public double OffsetY { get; init; } + } public enum EditorMapSectorDensityBand { None = 0, @@ -7793,8 +7914,10 @@ namespace ArcNET.Editor public System.Collections.Generic.IReadOnlyList UniqueTerrainFloorArtIds { get; } public System.Collections.Generic.IReadOnlyList UniqueTerrainLightArtIds { get; } public System.Collections.Generic.IReadOnlyList UniqueTerrainRoofArtIds { get; } + public System.Collections.Generic.IReadOnlyList GetJumpPointsAtIndex(int tileIndex) { } public uint? GetRoofArtId(int roofX, int roofY) { } public uint GetTileArtId(int tileX, int tileY) { } + public System.Collections.Generic.IReadOnlyList GetTileScriptsAtIndex(int tileIndex) { } public bool IsTileBlocked(int tileX, int tileY) { } } public enum EditorMapSpriteBlendMode @@ -7862,8 +7985,10 @@ namespace ArcNET.Editor public EditorMapTileOverlayRenderItem() { } public required double CenterX { get; init; } public required double CenterY { get; init; } + public string? Detail { get; init; } public required int DrawOrder { get; init; } public required ArcNET.Editor.EditorMapTileOverlayKind Kind { get; init; } + public string? Label { get; init; } public required int MapTileX { get; init; } public required int MapTileY { get; init; } public required string SectorAssetPath { get; init; } @@ -7918,6 +8043,9 @@ namespace ArcNET.Editor public ArcNET.Editor.EditorMapRenderSpriteCoverage? ExistingSpriteCoverage { get; init; } public int? FocusedObjectSectorRadius { get; init; } public int? FocusedTerrainSectorRadius { get; init; } + public bool? IncludeSpecialTileOverlays { get; init; } + public bool IncludeSpriteCoverage { get; init; } + public bool IncludeVirtualTerrainSpriteCoverage { get; init; } public ArcNET.Editor.EditorObjectPalettePlacementRequest? PlacementRequest { get; init; } public bool PreloadSceneSprites { get; init; } public ArcNET.Editor.EditorMapFloorRenderRequest? RenderRequest { get; init; } @@ -7967,7 +8095,10 @@ namespace ArcNET.Editor public bool IncludeEditorObjectStateTint { get; init; } public bool IncludeFloorLightTint { get; init; } public bool IncludeFullObjectPaletteBrowse { get; init; } + public bool IncludeSpecialTileOverlays { get; init; } + public bool IncludeSpriteCoverage { get; init; } public bool IncludeTrackedPlacementPreview { get; init; } + public bool IncludeVirtualTerrainSpriteCoverage { get; init; } public string? ObjectPaletteCategory { get; init; } public string? ObjectPaletteSearchText { get; init; } public bool PreloadSceneSprites { get; init; } @@ -8432,6 +8563,9 @@ namespace ArcNET.Editor public EditorProjectMapCameraState() { } public double CenterTileX { get; init; } public double CenterTileY { get; init; } + public double PitchDegrees { get; init; } + public double RollDegrees { get; init; } + public double YawDegrees { get; init; } public double Zoom { get; init; } } public sealed class EditorProjectMapObjectInspectorState @@ -8522,6 +8656,7 @@ namespace ArcNET.Editor public EditorProjectMapWorldEditShellState() { } public bool IncludeEditorObjectStateTint { get; init; } public bool IncludeFloorLightTint { get; init; } + public bool IncludeSpecialTileOverlays { get; init; } public bool IncludeTrackedPlacementPreview { get; init; } public string? ObjectPaletteCategory { get; init; } public string? ObjectPaletteSearchText { get; init; } @@ -8676,6 +8811,7 @@ namespace ArcNET.Editor Sector = 4, Save = 5, Message = 6, + Jump = 7, } public sealed class EditorSessionChangeKindSummary { @@ -9161,7 +9297,9 @@ namespace ArcNET.Editor public ArcNET.Editor.EditorMapLayerBrushResult ApplyTrackedTerrainTool(string mapViewStateId) { } public ArcNET.Editor.EditorSessionChange ApplyValidationRepairCandidate(ArcNET.Editor.EditorSessionValidationRepairCandidate candidate) { } public ArcNET.Editor.EditorSessionChangeGroup BeginChangeGroup(string? label = null) { } + public System.Collections.Generic.IReadOnlyList ClearMapJumpPoints(string mapName, System.Collections.Generic.IReadOnlyList sectorHitGroups) { } public ArcNET.Editor.EditorSessionChange? ClearProtoInspectorScriptAttachment(int protoNumber, ArcNET.Formats.ScriptAttachmentPoint attachmentPoint) { } + public System.Collections.Generic.IReadOnlyList ClearSectorTileScripts(System.Collections.Generic.IReadOnlyList sectorHitGroups) { } public ArcNET.Editor.EditorSessionChange? ClearTrackedObjectInspectorScriptAttachment(string mapViewStateId, ArcNET.Formats.ScriptAttachmentPoint attachmentPoint) { } public void CloseAllAssets(bool discardPendingChanges = false) { } public bool CloseAsset(string assetPath, bool discardPendingChanges = false) { } @@ -9310,6 +9448,7 @@ namespace ArcNET.Editor public ArcNET.Editor.EditorProjectMapObjectPlacementToolState SelectTrackedObjectPaletteEntry(string mapViewStateId, int protoNumber, string? searchText = null, string? category = null, bool activateTool = false) { } public ArcNET.Editor.EditorProjectMapObjectPlacementToolState SelectTrackedObjectPlacementPreset(string mapViewStateId, string presetId, bool activateTool = true) { } public void SetActiveAsset(string? assetPath) { } + public System.Collections.Generic.IReadOnlyList SetMapJumpPoints(string mapName, System.Collections.Generic.IReadOnlyList sectorHitGroups, int destinationMapId, int destinationTileX, int destinationTileY, uint flags = 0) { } public ArcNET.Editor.EditorProjectMapViewState SetMapViewState(ArcNET.Editor.EditorProjectMapViewState mapViewState) { } public ArcNET.Editor.EditorProjectMapWorldEditState SetMapWorldEditState(string mapViewStateId, ArcNET.Editor.EditorProjectMapWorldEditState worldEditState) { } public ArcNET.Editor.EditorSessionChange? SetMessageEntry(string assetPath, int messageIndex, string text, string? soundId = null) { } @@ -9329,6 +9468,7 @@ namespace ArcNET.Editor public ArcNET.Editor.EditorSessionChange? SetSectorRoofArt(string assetPath, int roofX, int roofY, uint artId) { } public System.Collections.Generic.IReadOnlyList SetSectorTileArt(System.Collections.Generic.IReadOnlyList sectorHitGroups, uint artId) { } public ArcNET.Editor.EditorSessionChange? SetSectorTileArt(string assetPath, int tileX, int tileY, uint artId) { } + public System.Collections.Generic.IReadOnlyList SetSectorTileScripts(System.Collections.Generic.IReadOnlyList sectorHitGroups, int scriptId, uint nodeFlags = 0, uint scriptFlags = 0, uint scriptCounters = 0) { } public ArcNET.Editor.EditorProjectToolState SetToolState(ArcNET.Editor.EditorProjectToolState toolState) { } public ArcNET.Editor.EditorProjectMapWorldEditShellState SetTrackedMapWorldEditShellPreferences(string mapViewStateId, ArcNET.Editor.EditorMapWorldEditShellRequest request) { } public ArcNET.Editor.EditorSessionChange? SetTrackedObjectInspectorBlending(string mapViewStateId, ArcNET.Editor.EditorObjectInspectorBlendingUpdate update) { } @@ -9414,6 +9554,7 @@ namespace ArcNET.Editor public EditorWorldAreaMapEntry() { } public int EntryTileX { get; init; } public int EntryTileY { get; init; } + public int MapId { get; init; } public required string MapName { get; init; } public string? Type { get; init; } public int? WorldMapId { get; init; } diff --git a/src/Archive/ArcNET.Archive/DatArchive.cs b/src/Archive/ArcNET.Archive/DatArchive.cs index b4505e1..4041bb0 100644 --- a/src/Archive/ArcNET.Archive/DatArchive.cs +++ b/src/Archive/ArcNET.Archive/DatArchive.cs @@ -64,6 +64,14 @@ public ReadOnlyMemory GetEntryData(string name) return DatEntryReader.ReadEntryData(_mmf, entry); } + /// Returns the decompressed bytes of an already-resolved entry. + public ReadOnlyMemory GetEntryData(ArchiveEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + ObjectDisposedException.ThrowIf(_disposed, this); + return DatEntryReader.ReadEntryData(_mmf, entry); + } + /// Opens a readable stream over the given entry, decompressing on-the-fly if needed. public Stream OpenEntry(string name) { @@ -77,8 +85,7 @@ public Stream OpenEntry(string name) /// Reads and decompresses the given entry. public byte[] ReadEntry(ArchiveEntry entry) { - ObjectDisposedException.ThrowIf(_disposed, this); - return DatEntryReader.ReadEntryData(_mmf, entry).ToArray(); + return GetEntryData(entry).ToArray(); } /// diff --git a/src/Benchmarks/ArcNET.Benchmarks/ArcNET.Benchmarks.csproj b/src/Benchmarks/ArcNET.Benchmarks/ArcNET.Benchmarks.csproj index 0a0932b..87897ad 100644 --- a/src/Benchmarks/ArcNET.Benchmarks/ArcNET.Benchmarks.csproj +++ b/src/Benchmarks/ArcNET.Benchmarks/ArcNET.Benchmarks.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Benchmarks/ArcNET.Benchmarks/DiagnosticsBytePatternBench.cs b/src/Benchmarks/ArcNET.Benchmarks/DiagnosticsBytePatternBench.cs new file mode 100644 index 0000000..086a79a --- /dev/null +++ b/src/Benchmarks/ArcNET.Benchmarks/DiagnosticsBytePatternBench.cs @@ -0,0 +1,70 @@ +using ArcNET.Diagnostics; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ArcNET.Benchmarks; + +[MemoryDiagnoser] +[ShortRunJob] +public class DiagnosticsBytePatternBench +{ + private BytePattern _pattern = null!; + private byte?[] _legacyPattern = []; + private byte[] _haystack = []; + + [Params(64 * 1024)] + public int HaystackLength { get; set; } + + [Params(97, 8)] + public int MatchStride { get; set; } + + [GlobalSetup] + public void Setup() + { + _pattern = BytePattern.Parse("8B ?? FF 10"); + _legacyPattern = _pattern.Bytes; + _haystack = new byte[HaystackLength]; + + for (var index = 0; index < _haystack.Length; index++) + _haystack[index] = (byte)((index * 31) & 0x7F); + + for (var start = 0; start + _pattern.Length <= _haystack.Length; start += MatchStride) + { + _haystack[start] = 0x8B; + _haystack[start + 1] = (byte)(start & 0xFF); + _haystack[start + 2] = 0xFF; + _haystack[start + 3] = 0x10; + } + } + + [Benchmark(Baseline = true)] + public int[] FindMatches_Legacy() + { + if (_haystack.Length < _legacyPattern.Length) + return []; + + List matches = []; + for (var start = 0; start <= _haystack.Length - _legacyPattern.Length; start++) + { + if (MatchesAtLegacy(start)) + matches.Add(start); + } + + return [.. matches]; + } + + [Benchmark] + public int[] FindMatches_Anchored() => _pattern.FindMatches(_haystack); + + private bool MatchesAtLegacy(int start) + { + for (var index = 0; index < _legacyPattern.Length; index++) + { + var expected = _legacyPattern[index]; + if (expected.HasValue && _haystack[start + index] != expected.Value) + return false; + } + + return true; + } +} diff --git a/src/Benchmarks/ArcNET.Benchmarks/EditorPixelPipelineBench.cs b/src/Benchmarks/ArcNET.Benchmarks/EditorPixelPipelineBench.cs new file mode 100644 index 0000000..f54e1ae --- /dev/null +++ b/src/Benchmarks/ArcNET.Benchmarks/EditorPixelPipelineBench.cs @@ -0,0 +1,206 @@ +using ArcNET.Core.Primitives; +using ArcNET.Editor; +using ArcNET.Formats; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ArcNET.Benchmarks; + +[MemoryDiagnoser] +[ShortRunJob] +public class EditorArtPreviewBuilderBench +{ + private ArtFile _art = null!; + private ArtPaletteEntry[] _palette = []; + private EditorArtPreviewOptions _options = null!; + + [Params(64, 256)] + public int Edge { get; set; } + + [Params(EditorArtPreviewPixelFormat.Rgba32, EditorArtPreviewPixelFormat.Bgra32)] + public EditorArtPreviewPixelFormat PixelFormat { get; set; } + + [Params(false, true)] + public bool FlipVertically { get; set; } + + [GlobalSetup] + public void Setup() + { + var pixels = new byte[checked(Edge * Edge)]; + for (var index = 0; index < pixels.Length; index++) + pixels[index] = index % 9 == 0 ? (byte)0 : (byte)((index % 255) + 1); + + _palette = CreatePalette(); + _art = CreateArtFile(Edge, Edge, pixels, _palette); + _options = new EditorArtPreviewOptions { PixelFormat = PixelFormat, FlipVertically = FlipVertically }; + } + + [Benchmark(Baseline = true)] + public byte[] BuildFrame_Legacy() + { + var frame = _art.Frames[0][0]; + var width = checked((int)frame.Header.Width); + var height = checked((int)frame.Header.Height); + var pixelData = new byte[checked(width * height * 4)]; + var usePaletteAlpha = UsesExplicitPaletteAlpha(_palette); + + for (var destinationRow = 0; destinationRow < height; destinationRow++) + { + var sourceRow = _options.FlipVertically ? height - 1 - destinationRow : destinationRow; + for (var column = 0; column < width; column++) + { + var sourceIndex = sourceRow * width + column; + var destinationIndex = checked((destinationRow * width + column) * 4); + WritePixelLegacy(pixelData, destinationIndex, frame.Pixels[sourceIndex], usePaletteAlpha); + } + } + + return pixelData; + } + + [Benchmark] + public byte[] BuildFrame_PackedLookup() => EditorArtPreviewBuilder.BuildFrame(_art, 0, 0, _options).PixelData; + + private static ArtPaletteEntry[] CreatePalette() + { + var palette = new ArtPaletteEntry[byte.MaxValue + 1]; + for (var index = 1; index < palette.Length; index++) + { + palette[index] = new ArtPaletteEntry( + Blue: (byte)((index * 17) & 0xFF), + Green: (byte)((index * 29) & 0xFF), + Red: (byte)((index * 43) & 0xFF), + Alpha: (byte)(32 + (index % 224)) + ); + } + + return palette; + } + + private static ArtFile CreateArtFile(int width, int height, byte[] pixels, ArtPaletteEntry[] palette) => + new() + { + Flags = ArtFlags.Static, + FrameRate = 8, + ActionFrame = 0, + FrameCount = 1, + DataSizes = new uint[8], + PaletteData1 = new uint[8], + PaletteData2 = new uint[8], + PaletteIds = [1, 0, 0, 0], + Palettes = [palette, null, null, null], + Frames = + [ + [ + new ArtFrame + { + Header = new ArtFrameHeader((uint)width, (uint)height, (uint)pixels.Length, 0, 0, 0, 0), + Pixels = pixels, + }, + ], + ], + }; + + private void WritePixelLegacy(byte[] pixelData, int destinationIndex, byte paletteIndex, bool usePaletteAlpha) + { + if (paletteIndex == 0) + return; + + var color = _palette[paletteIndex]; + var alpha = usePaletteAlpha ? color.Alpha : byte.MaxValue; + switch (_options.PixelFormat) + { + case EditorArtPreviewPixelFormat.Rgba32: + pixelData[destinationIndex] = color.Red; + pixelData[destinationIndex + 1] = color.Green; + pixelData[destinationIndex + 2] = color.Blue; + pixelData[destinationIndex + 3] = alpha; + break; + case EditorArtPreviewPixelFormat.Bgra32: + pixelData[destinationIndex] = color.Blue; + pixelData[destinationIndex + 1] = color.Green; + pixelData[destinationIndex + 2] = color.Red; + pixelData[destinationIndex + 3] = alpha; + break; + default: + throw new ArgumentOutOfRangeException(nameof(_options.PixelFormat), _options.PixelFormat, null); + } + } + + private static bool UsesExplicitPaletteAlpha(ArtPaletteEntry[] palette) + { + for (var index = 1; index < palette.Length; index++) + { + if (palette[index].Alpha != 0) + return true; + } + + return false; + } +} + +[MemoryDiagnoser] +[ShortRunJob] +public class EditorSpriteMirrorBench +{ + private static readonly ArtId MirroredTileArtId = new(0x000121C1u); + private byte[] _source = []; + + [Params(78, 256)] + public int Width { get; set; } + + [Params(40, 256)] + public int Height { get; set; } + + [GlobalSetup] + public void Setup() + { + _source = new byte[checked(Width * Height * 4)]; + for (var index = 0; index < _source.Length; index++) + _source[index] = (byte)((index * 31) & 0xFF); + } + + [Benchmark(Baseline = true)] + public byte[] Flip_Legacy() + { + var pixelData = (byte[])_source.Clone(); + FlipPixelDataHorizontallyLegacy(pixelData, Width, Height); + return pixelData; + } + + [Benchmark] + public byte[] Flip_PackedPixels() + { + var pixelData = (byte[])_source.Clone(); + EditorWorkspaceMapRenderSpriteSource.ApplyCeHorizontalTileMirror( + EditorMapRenderQueueItemKind.FloorTile, + MirroredTileArtId, + pixelData, + Width, + Height + ); + return pixelData; + } + + private static void FlipPixelDataHorizontallyLegacy(byte[] pixelData, int width, int height) + { + const int bytesPerPixel = 4; + if (width <= 1 || height <= 0) + return; + + for (var row = 0; row < height; row++) + { + var rowStart = checked(row * width * bytesPerPixel); + for (int left = 0, right = width - 1; left < right; left++, right--) + { + var leftIndex = rowStart + (left * bytesPerPixel); + var rightIndex = rowStart + (right * bytesPerPixel); + for (var channel = 0; channel < bytesPerPixel; channel++) + (pixelData[leftIndex + channel], pixelData[rightIndex + channel]) = ( + pixelData[rightIndex + channel], + pixelData[leftIndex + channel] + ); + } + } + } +} diff --git a/src/Diagnostics/ArcNET.Diagnostics.FileTime/BytePattern.cs b/src/Diagnostics/ArcNET.Diagnostics.FileTime/BytePattern.cs index e633686..31a0fe6 100644 --- a/src/Diagnostics/ArcNET.Diagnostics.FileTime/BytePattern.cs +++ b/src/Diagnostics/ArcNET.Diagnostics.FileTime/BytePattern.cs @@ -4,10 +4,22 @@ namespace ArcNET.Diagnostics; public sealed class BytePattern { + private readonly int _anchorIndex = -1; + private readonly byte _anchorValue; + private BytePattern(byte?[] bytes, string normalizedText) { Bytes = bytes; NormalizedText = normalizedText; + for (var index = 0; index < bytes.Length; index++) + { + if (bytes[index] is not { } value) + continue; + + _anchorIndex = index; + _anchorValue = value; + break; + } } public byte?[] Bytes { get; } @@ -52,8 +64,28 @@ public int[] FindMatches(ReadOnlySpan haystack) return []; List matches = []; + if (_anchorIndex < 0) + { + for (var start = 0; start <= haystack.Length - Bytes.Length; start++) + { + if (MatchesAt(haystack, start)) + matches.Add(start); + } + + return [.. matches]; + } + for (var start = 0; start <= haystack.Length - Bytes.Length; start++) { + var anchorOffset = start + _anchorIndex; + var maxAnchorOffset = haystack.Length - Bytes.Length + _anchorIndex; + var nextAnchorOffset = haystack + .Slice(anchorOffset, maxAnchorOffset - anchorOffset + 1) + .IndexOf(_anchorValue); + if (nextAnchorOffset < 0) + break; + + start += nextAnchorOffset; if (MatchesAt(haystack, start)) matches.Add(start); } diff --git a/src/Diagnostics/ArcNET.Diagnostics.Tests/BytePatternTests.cs b/src/Diagnostics/ArcNET.Diagnostics.Tests/BytePatternTests.cs index d49dae4..2c0a44d 100644 --- a/src/Diagnostics/ArcNET.Diagnostics.Tests/BytePatternTests.cs +++ b/src/Diagnostics/ArcNET.Diagnostics.Tests/BytePatternTests.cs @@ -13,4 +13,26 @@ public async Task ParseAndFindMatches_WhenPatternUsesWildcards_ReturnsExpectedOf await Assert.That(pattern.NormalizedText).IsEqualTo("8B ?? FF"); await Assert.That(matches).IsEquivalentTo([0, 3]); } + + [Test] + public async Task FindMatches_WhenPatternIsOnlyWildcards_ReturnsEveryValidOffset() + { + var pattern = BytePattern.Parse("?? ??"); + var haystack = new byte[] { 0x10, 0x20, 0x30, 0x40 }; + + var matches = pattern.FindMatches(haystack); + + await Assert.That(matches).IsEquivalentTo([0, 1, 2]); + } + + [Test] + public async Task FindMatches_WhenAnchorIsNotFirstByte_DoesNotReadPastLastValidCandidate() + { + var pattern = BytePattern.Parse("?? FE 10"); + var haystack = new byte[] { 0x00, 0xFE, 0x10, 0x11, 0x22, 0xFE }; + + var matches = pattern.FindMatches(haystack); + + await Assert.That(matches).IsEquivalentTo([0]); + } } diff --git a/src/Editor/ArcNET.Editor.Tests/EditorMapFloorRenderBuilderTests.cs b/src/Editor/ArcNET.Editor.Tests/EditorMapFloorRenderBuilderTests.cs index 3c7bb26..7ba7ba3 100644 --- a/src/Editor/ArcNET.Editor.Tests/EditorMapFloorRenderBuilderTests.cs +++ b/src/Editor/ArcNET.Editor.Tests/EditorMapFloorRenderBuilderTests.cs @@ -218,9 +218,9 @@ public async Task Build_Isometric_ProjectsTilesInStableDrawOrderWithNormalizedBo TileX = 1, TileY = 63, ScriptId = 77, - NodeFlags = 0u, - ScriptFlags = 0u, - ScriptCounters = 0u, + NodeFlags = 0x00000002u, + ScriptFlags = 0x00000004u, + ScriptCounters = 0x00000008u, }, ], Objects = [], @@ -273,18 +273,25 @@ public async Task Build_Isometric_ProjectsTilesInStableDrawOrderWithNormalizedBo await Assert.That(preview.Overlays[0].CenterX).IsEqualTo(64d); await Assert.That(preview.Overlays[0].CenterY).IsEqualTo(32d); await Assert.That(preview.Overlays[0].SuggestedTintColor).IsEqualTo(0x88CC6666u); + await Assert.That(preview.Overlays[0].Label).IsEqualTo("BLOCK"); + await Assert.That(preview.Overlays[0].Detail).IsEqualTo("Pathing blocked"); await Assert.That(preview.Overlays[1].Kind).IsEqualTo(EditorMapTileOverlayKind.Light); await Assert.That(preview.Overlays[1].MapTileX).IsEqualTo(0); await Assert.That(preview.Overlays[1].MapTileY).IsEqualTo(63); await Assert.That(preview.Overlays[1].SuggestedTintColor).IsEqualTo(0x88E0C85Au); + await Assert.That(preview.Overlays[1].Label).IsNull(); await Assert.That(preview.Overlays[2].Kind).IsEqualTo(EditorMapTileOverlayKind.Script); await Assert.That(preview.Overlays[2].MapTileX).IsEqualTo(1); await Assert.That(preview.Overlays[2].MapTileY).IsEqualTo(63); await Assert.That(preview.Overlays[2].CenterX).IsEqualTo(32d); await Assert.That(preview.Overlays[2].CenterY).IsEqualTo(48d); - await Assert.That(preview.Overlays[2].SuggestedTintColor).IsEqualTo(0x88996CCCu); + await Assert.That(preview.Overlays[2].SuggestedTintColor).IsEqualTo(0x8866CC88u); + await Assert.That(preview.Overlays[2].Label).IsEqualTo("SCRIPT 77\nC:0x8"); + await Assert + .That(preview.Overlays[2].Detail) + .IsEqualTo("Script 77; node=0x00000002; flags=0x00000004; counters=0x00000008"); await Assert.That(preview.Lights).HasSingleItem(); await Assert.That(preview.Lights[0].ArtId).IsEqualTo(new ArtId(0x01020304u)); @@ -1742,7 +1749,7 @@ public async Task Build_ProjectsJumpPointTileOverlays() DestinationMapId = 42, DestinationTileX = 10, DestinationTileY = 11, - Flags = 0u, + Flags = 0x00000005u, }, ], Objects = [], @@ -1763,8 +1770,14 @@ public async Task Build_ProjectsJumpPointTileOverlays() await Assert.That(preview.Overlays[0].CenterX).IsEqualTo(16d); await Assert.That(preview.Overlays[0].CenterY).IsEqualTo(16d); await Assert.That(preview.Overlays[0].SuggestedTintColor).IsEqualTo(0x8866BBDDu); + await Assert.That(preview.Overlays[0].Label).IsEqualTo("MAP 42\n10,11"); + await Assert.That(preview.Overlays[0].Detail).IsEqualTo("Jump to map 42 at 10, 11; flags=0x00000005"); await Assert.That(preview.RenderQueue.Count).IsEqualTo(2); await Assert.That(preview.RenderQueue[1].TileOverlay?.Kind).IsEqualTo(EditorMapTileOverlayKind.JumpPoint); + await Assert.That(preview.RenderQueue[1].TileOverlay?.Label).IsEqualTo("MAP 42\n10,11"); + await Assert + .That(preview.RenderQueue[1].TileOverlay?.Detail) + .IsEqualTo("Jump to map 42 at 10, 11; flags=0x00000005"); } private static EditorMapScenePreview CreateScenePreview(params EditorMapSectorScenePreview[] sectors) => diff --git a/src/Editor/ArcNET.Editor.Tests/EditorMapPaintableSceneBuilderTests.cs b/src/Editor/ArcNET.Editor.Tests/EditorMapPaintableSceneBuilderTests.cs index 18a4689..0a1e4af 100644 --- a/src/Editor/ArcNET.Editor.Tests/EditorMapPaintableSceneBuilderTests.cs +++ b/src/Editor/ArcNET.Editor.Tests/EditorMapPaintableSceneBuilderTests.cs @@ -507,11 +507,24 @@ public async Task Build_IsometricFloorLightQuadrantsUseCeTerrainSubrectLayout() new EditorMapPaintableSceneSpriteDestinationRect(100d, 200d, 39d, 20d) ), }; + uint[][] expectedColors = + [ + [0xFF101010u, 0xFF202020u, 0xFF505050u, 0xFF404040u], + [0xFF202020u, 0xFF303030u, 0xFF606060u, 0xFF505050u], + [0xFF404040u, 0xFF505050u, 0xFF808080u, 0xFF707070u], + [0xFF505050u, 0xFF606060u, 0xFF909090u, 0xFF808080u], + ]; for (var index = 0; index < expected.Length; index++) { await Assert.That(paintableScene.Items[index].SpriteSourceRect).IsEqualTo(expected[index].Item1); await Assert.That(paintableScene.Items[index].SpriteDestinationRect).IsEqualTo(expected[index].Item2); + + var objectColorArray = paintableScene.Items[index].ObjectColorArray; + await Assert.That(objectColorArray).IsNotNull(); + await Assert.That(objectColorArray!.Count).IsEqualTo(4); + for (var colorIndex = 0; colorIndex < objectColorArray.Count; colorIndex++) + await Assert.That(objectColorArray[colorIndex]).IsEqualTo(expectedColors[index][colorIndex]); } } @@ -2088,6 +2101,25 @@ public async Task Build_PartialTerrainSpriteCoverageIncludesVirtualTerrainArt() await Assert.That(paintableScene.SpriteCoverage.ReferencedSpriteReferenceCount).IsEqualTo(4); } + [Test] + public async Task Build_CanSkipPartialVirtualTerrainSpriteCoverage() + { + var sceneRender = CreatePartialTerrainSceneRender(includeVirtualRoofAndLight: true); + + var paintableScene = EditorMapPaintableSceneBuilder.Build( + sceneRender, + spriteSource: new StubSpriteSource(0, 0), + includeVirtualTerrainSpriteCoverage: false + ); + var referencedArtIds = paintableScene.SpriteCoverage.ReferencedArtIds; + + await Assert.That(referencedArtIds).Contains(new ArtId(101u)); + await Assert.That(referencedArtIds).DoesNotContain(new ArtId(202u)); + await Assert.That(referencedArtIds).DoesNotContain(new ArtId(0x90000000u)); + await Assert.That(referencedArtIds).DoesNotContain(new ArtId(0xA0008000u)); + await Assert.That(paintableScene.SpriteCoverage.ReferencedSpriteReferenceCount).IsEqualTo(1); + } + private static EditorMapFloorRenderPreview CreatePartialTerrainSceneRender(bool includeVirtualRoofAndLight = false) { var scenePreview = new EditorMapScenePreview diff --git a/src/Editor/ArcNET.Editor.Tests/EditorMapRenderSpriteTests.cs b/src/Editor/ArcNET.Editor.Tests/EditorMapRenderSpriteTests.cs index 8f7e9d0..d4bd87b 100644 --- a/src/Editor/ArcNET.Editor.Tests/EditorMapRenderSpriteTests.cs +++ b/src/Editor/ArcNET.Editor.Tests/EditorMapRenderSpriteTests.cs @@ -274,6 +274,54 @@ public async Task ApplyCeHorizontalTileMirror_MirroredNonFlippableFloorTile_Stil await Assert.That(pixelData.SequenceEqual(new byte[] { 0, 0, 255, 255, 255, 0, 0, 255 })).IsTrue(); } + [Test] + public async Task ApplyCeHorizontalTileMirror_MirroredOddWidthRows_FlipsEachPackedPixelRow() + { + var pixelData = new byte[] + { + 1, + 0, + 0, + 255, + 2, + 0, + 0, + 255, + 3, + 0, + 0, + 255, + 4, + 0, + 0, + 255, + 5, + 0, + 0, + 255, + 6, + 0, + 0, + 255, + }; + + EditorWorkspaceMapRenderSpriteSource.ApplyCeHorizontalTileMirror( + EditorMapRenderQueueItemKind.FloorTile, + new ArtId(0x000121C1u), + pixelData, + width: 3, + height: 2 + ); + + await Assert + .That( + pixelData.SequenceEqual( + new byte[] { 3, 0, 0, 255, 2, 0, 0, 255, 1, 0, 0, 255, 6, 0, 0, 255, 5, 0, 0, 255, 4, 0, 0, 255 } + ) + ) + .IsTrue(); + } + [Test] public async Task ApplyCeHorizontalTileMirror_FacadeWalkableBit_DoesNotFlipPixels() { diff --git a/src/Editor/ArcNET.Editor.Tests/EditorWorkspaceSessionTests.cs b/src/Editor/ArcNET.Editor.Tests/EditorWorkspaceSessionTests.cs index cbc881f..15ff4bc 100644 --- a/src/Editor/ArcNET.Editor.Tests/EditorWorkspaceSessionTests.cs +++ b/src/Editor/ArcNET.Editor.Tests/EditorWorkspaceSessionTests.cs @@ -5092,6 +5092,214 @@ public async Task SectorCompositionHelpers_ApplyLayerBrushRequest_StagesTileRoof } } + [Test] + public async Task SectorCompositionHelpers_SetSectorTileScripts_StagesReplacementAndClear() + { + const string sectorAssetPath = "maps/map01/sector_a.sec"; + const int tileX = 7; + const int tileY = 0; + const int replacementScriptId = 88; + const uint replacementNodeFlags = 11u; + const uint replacementScriptFlags = 22u; + const uint replacementScriptCounters = 33u; + var contentDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(Path.Combine(contentDir, "maps", "map01")); + + try + { + SectorFormat.WriteToFile( + MakeSectorWithScriptRefs(77), + Path.Combine(contentDir, "maps", "map01", "sector_a.sec") + ); + var workspace = await EditorWorkspaceLoader.LoadAsync(contentDir); + var sectorHitGroups = new EditorMapSceneSectorHitGroup[] + { + new() + { + SectorAssetPath = sectorAssetPath, + LocalX = 0, + LocalY = 0, + Hits = + [ + new EditorMapSceneHit + { + MapTileX = tileX, + MapTileY = tileY, + SectorAssetPath = sectorAssetPath, + Tile = new Location(tileX, tileY), + ObjectHits = [], + }, + ], + }, + }; + + var session = workspace.CreateSession(); + var changes = session.SetSectorTileScripts( + sectorHitGroups, + replacementScriptId, + replacementNodeFlags, + replacementScriptFlags, + replacementScriptCounters + ); + + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Kind).IsEqualTo(EditorSessionChangeKind.Sector); + await Assert.That(changes[0].Target).IsEqualTo(sectorAssetPath); + + var updatedWorkspace = session.BeginChangeGroup("Paint script tile").ApplyPendingChanges(); + var updatedSector = updatedWorkspace.FindSector(sectorAssetPath); + + await Assert.That(updatedSector).IsNotNull(); + await Assert.That(updatedSector!.TileScripts).HasSingleItem(); + await Assert.That(updatedSector.TileScripts[0].TileId).IsEqualTo((uint)((tileY * 64) + tileX)); + await Assert.That(updatedSector.TileScripts[0].ScriptNum).IsEqualTo(replacementScriptId); + await Assert.That(updatedSector.TileScripts[0].NodeFlags).IsEqualTo(replacementNodeFlags); + await Assert.That(updatedSector.TileScripts[0].ScriptFlags).IsEqualTo(replacementScriptFlags); + await Assert.That(updatedSector.TileScripts[0].ScriptCounters).IsEqualTo(replacementScriptCounters); + + var clearSession = updatedWorkspace.CreateSession(); + var clearChanges = clearSession.ClearSectorTileScripts(sectorHitGroups); + + await Assert.That(clearChanges).HasSingleItem(); + await Assert.That(clearChanges[0].Kind).IsEqualTo(EditorSessionChangeKind.Sector); + + var clearedWorkspace = clearSession.BeginChangeGroup("Clear script tile").ApplyPendingChanges(); + var clearedSector = clearedWorkspace.FindSector(sectorAssetPath); + + await Assert.That(clearedSector).IsNotNull(); + await Assert.That(clearedSector!.TileScripts).IsEmpty(); + } + finally + { + if (Directory.Exists(contentDir)) + Directory.Delete(contentDir, recursive: true); + } + } + + [Test] + public async Task SectorCompositionHelpers_SetMapJumpPoints_StagesNewJumpAssetAndPendingOverlay() + { + const ulong sectorKey = 101334386389UL; + const int sectorX = 1749; + const int sectorY = 1510; + const int tileX = 7; + const int tileY = 8; + const int destinationMapId = 42; + const int destinationTileX = 10; + const int destinationTileY = 11; + const uint jumpFlags = 5u; + var sourceLoc = ((long)(uint)((sectorY * 64) + tileY) << 32) | (uint)((sectorX * 64) + tileX); + var destinationLoc = ((long)(uint)destinationTileY << 32) | (uint)destinationTileX; + var sectorAssetPath = $"maps/map01/{sectorKey}.sec"; + + var contentDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(Path.Combine(contentDir, "maps", "map01")); + + try + { + var sector = new SectorBuilder(MakeSector()).SetTile(tileX, tileY, 100u).Build(); + SectorFormat.WriteToFile(sector, Path.Combine(contentDir, "maps", "map01", $"{sectorKey}.sec")); + var workspace = await EditorWorkspaceLoader.LoadAsync(contentDir); + var sectorHitGroups = new EditorMapSceneSectorHitGroup[] + { + new() + { + SectorAssetPath = sectorAssetPath, + LocalX = sectorX, + LocalY = sectorY, + Hits = + [ + new EditorMapSceneHit + { + MapTileX = (sectorX * 64) + tileX, + MapTileY = (sectorY * 64) + tileY, + SectorAssetPath = sectorAssetPath, + Tile = new Location(tileX, tileY), + ObjectHits = [], + }, + ], + }, + }; + + var session = workspace.CreateSession(); + session.SetMapViewState( + new EditorProjectMapViewState + { + Id = "map-view-scene", + MapName = "map01", + ViewId = "scene", + Camera = new EditorProjectMapCameraState(), + Selection = new EditorProjectMapSelectionState(), + Preview = new EditorProjectMapPreviewState + { + UseScenePreview = true, + ShowObjects = false, + ShowRoofs = false, + ShowLights = false, + ShowBlockedTiles = false, + ShowScripts = false, + ShowJumpPoints = true, + }, + } + ); + + var changes = session.SetMapJumpPoints( + "map01", + sectorHitGroups, + destinationMapId, + destinationTileX, + destinationTileY, + jumpFlags + ); + + await Assert.That(changes).HasSingleItem(); + await Assert.That(changes[0].Kind).IsEqualTo(EditorSessionChangeKind.Jump); + await Assert.That(changes[0].Target).IsEqualTo("maps/map01/map.jmp"); + + var pendingPreview = session.CreateMapFloorRenderPreview( + "map-view-scene", + new EditorMapFloorRenderRequest + { + ViewMode = EditorMapSceneViewMode.TopDown, + TileWidthPixels = 32d, + TileHeightPixels = 32d, + } + ); + + await Assert.That(pendingPreview.Overlays).HasSingleItem(); + await Assert.That(pendingPreview.Overlays[0].Kind).IsEqualTo(EditorMapTileOverlayKind.JumpPoint); + await Assert.That(pendingPreview.Overlays[0].MapTileX).IsEqualTo(tileX); + await Assert.That(pendingPreview.Overlays[0].MapTileY).IsEqualTo(tileY); + + var updatedWorkspace = session.BeginChangeGroup("Paint jump tile").ApplyPendingChanges(); + var updatedJumpFile = updatedWorkspace.FindMapJumpFile("map01"); + + await Assert.That(updatedJumpFile).IsNotNull(); + await Assert.That(updatedJumpFile!.Jumps).HasSingleItem(); + await Assert.That(updatedJumpFile.Jumps[0].Flags).IsEqualTo(jumpFlags); + await Assert.That(updatedJumpFile.Jumps[0].SourceLoc).IsEqualTo(sourceLoc); + await Assert.That(updatedJumpFile.Jumps[0].DestinationMapId).IsEqualTo(destinationMapId); + await Assert.That(updatedJumpFile.Jumps[0].DestinationLoc).IsEqualTo(destinationLoc); + + var clearSession = updatedWorkspace.CreateSession(); + var clearChanges = clearSession.ClearMapJumpPoints("map01", sectorHitGroups); + + await Assert.That(clearChanges).HasSingleItem(); + await Assert.That(clearChanges[0].Kind).IsEqualTo(EditorSessionChangeKind.Jump); + + var clearedWorkspace = clearSession.BeginChangeGroup("Clear jump tile").ApplyPendingChanges(); + var clearedJumpFile = clearedWorkspace.FindMapJumpFile("map01"); + + await Assert.That(clearedJumpFile).IsNotNull(); + await Assert.That(clearedJumpFile!.Jumps).IsEmpty(); + } + finally + { + if (Directory.Exists(contentDir)) + Directory.Delete(contentDir, recursive: true); + } + } + [Test] public async Task SectorCompositionHelpers_ApplyLayerBrushRequest_FromAreaSelection_StagesProjectedRectangleTiles() { diff --git a/src/Editor/ArcNET.Editor/ArcNET.Editor.csproj b/src/Editor/ArcNET.Editor/ArcNET.Editor.csproj index 6679742..30628af 100644 --- a/src/Editor/ArcNET.Editor/ArcNET.Editor.csproj +++ b/src/Editor/ArcNET.Editor/ArcNET.Editor.csproj @@ -45,5 +45,8 @@ <_Parameter1>ArcNET.Editor.Tests + + <_Parameter1>ArcNET.Benchmarks + diff --git a/src/Editor/ArcNET.Editor/EditorArtPreviewBuilder.cs b/src/Editor/ArcNET.Editor/EditorArtPreviewBuilder.cs index 3459cdb..d06b47f 100644 --- a/src/Editor/ArcNET.Editor/EditorArtPreviewBuilder.cs +++ b/src/Editor/ArcNET.Editor/EditorArtPreviewBuilder.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using ArcNET.Formats; namespace ArcNET.Editor; @@ -76,18 +77,26 @@ int frameIndex ); } - var pixelData = new byte[checked(expectedPixels * 4)]; var usePaletteAlpha = UsesExplicitPaletteAlpha(palette); - for (var destinationRow = 0; destinationRow < height; destinationRow++) + var pixelData = new byte[checked(expectedPixels * 4)]; + if (BitConverter.IsLittleEndian && palette.Length >= byte.MaxValue + 1) { - var sourceRow = options.FlipVertically ? height - 1 - destinationRow : destinationRow; - for (var column = 0; column < width; column++) + Span paletteLookup = stackalloc uint[byte.MaxValue + 1]; + PopulatePackedPaletteLookup(palette, options.PixelFormat, usePaletteAlpha, paletteLookup); + var destinationPixels = MemoryMarshal.Cast(pixelData.AsSpan()); + + for (var destinationRow = 0; destinationRow < height; destinationRow++) { - var sourceIndex = sourceRow * width + column; - var destinationIndex = checked((destinationRow * width + column) * 4); - WritePixel(pixelData, destinationIndex, frame.Pixels[sourceIndex], palette, options, usePaletteAlpha); + var sourceRow = options.FlipVertically ? height - 1 - destinationRow : destinationRow; + var source = frame.Pixels.AsSpan(sourceRow * width, width); + var destination = destinationPixels.Slice(destinationRow * width, width); + ExpandPaletteIndexedRow(source, destination, paletteLookup); } } + else + { + WritePixelDataPortable(frame.Pixels, pixelData, width, height, palette, options, usePaletteAlpha); + } return new EditorArtPreviewFrame { @@ -98,6 +107,65 @@ int frameIndex }; } + private static void PopulatePackedPaletteLookup( + ArtPaletteEntry[] palette, + EditorArtPreviewPixelFormat pixelFormat, + bool usePaletteAlpha, + Span paletteLookup + ) + { + for (var paletteIndex = 1; paletteIndex < paletteLookup.Length; paletteIndex++) + { + var color = palette[paletteIndex]; + var alpha = usePaletteAlpha ? color.Alpha : byte.MaxValue; + paletteLookup[paletteIndex] = pixelFormat switch + { + EditorArtPreviewPixelFormat.Rgba32 => PackLittleEndianPixel(color.Red, color.Green, color.Blue, alpha), + EditorArtPreviewPixelFormat.Bgra32 => PackLittleEndianPixel(color.Blue, color.Green, color.Red, alpha), + _ => throw new ArgumentOutOfRangeException( + nameof(pixelFormat), + pixelFormat, + "Unsupported ART preview pixel format." + ), + }; + } + } + + private static void ExpandPaletteIndexedRow( + ReadOnlySpan source, + Span destination, + ReadOnlySpan paletteLookup + ) + { + for (var index = 0; index < source.Length; index++) + destination[index] = paletteLookup[source[index]]; + } + + private static uint PackLittleEndianPixel(byte first, byte second, byte third, byte alpha) => + (uint)first | ((uint)second << 8) | ((uint)third << 16) | ((uint)alpha << 24); + + private static void WritePixelDataPortable( + byte[] sourcePixels, + byte[] pixelData, + int width, + int height, + ArtPaletteEntry[] palette, + EditorArtPreviewOptions options, + bool usePaletteAlpha + ) + { + for (var destinationRow = 0; destinationRow < height; destinationRow++) + { + var sourceRow = options.FlipVertically ? height - 1 - destinationRow : destinationRow; + for (var column = 0; column < width; column++) + { + var sourceIndex = sourceRow * width + column; + var destinationIndex = checked((destinationRow * width + column) * 4); + WritePixel(pixelData, destinationIndex, sourcePixels[sourceIndex], palette, options, usePaletteAlpha); + } + } + } + private static void WritePixel( byte[] pixelData, int destinationIndex, diff --git a/src/Editor/ArcNET.Editor/EditorAssetCatalogBuilder.cs b/src/Editor/ArcNET.Editor/EditorAssetCatalogBuilder.cs index 29012d5..6928789 100644 --- a/src/Editor/ArcNET.Editor/EditorAssetCatalogBuilder.cs +++ b/src/Editor/ArcNET.Editor/EditorAssetCatalogBuilder.cs @@ -74,7 +74,7 @@ private static void AddEntries( Func resolveSource ) { - foreach (var (assetPath, assets) in assetsBySource.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + foreach (var (assetPath, assets) in assetsBySource) { var source = resolveSource(assetPath); entries.Add( @@ -100,12 +100,7 @@ private static void AddDiscoveredEntries( return; var knownPaths = entries.Select(static entry => entry.AssetPath).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach ( - var (assetPath, source) in discoveredAssetSources.OrderBy( - pair => pair.Key, - StringComparer.OrdinalIgnoreCase - ) - ) + foreach (var (assetPath, source) in discoveredAssetSources) { var normalizedPath = NormalizeAssetPath(assetPath); if (!knownPaths.Add(normalizedPath)) diff --git a/src/Editor/ArcNET.Editor/EditorAssetIndex.cs b/src/Editor/ArcNET.Editor/EditorAssetIndex.cs index da60fbe..f153beb 100644 --- a/src/Editor/ArcNET.Editor/EditorAssetIndex.cs +++ b/src/Editor/ArcNET.Editor/EditorAssetIndex.cs @@ -61,7 +61,7 @@ public IReadOnlyList FindMapAssets(string mapName) => /// when the asset path was not present in the workspace asset catalog. /// public EditorAssetDependencySummary? FindAssetDependencySummary(string assetPath) => - _data.AssetDependencySummariesByAssetPath.TryGetValue(assetPath, out var summary) ? summary : null; + _data.AssetDependencySummariesByAssetPath.Value.TryGetValue(assetPath, out var summary) ? summary : null; /// /// Returns parsed sector summaries for all indexed sector assets that belong to one map. @@ -244,12 +244,12 @@ public IReadOnlyList SearchArtDetails(string text) /// Returns all assets that reference the supplied art identifier. /// public IReadOnlyList FindArtReferences(uint artId) => - _data.ArtReferencesById.TryGetValue(artId, out var references) ? references : []; + _data.ArtReferencesById.Value.TryGetValue(artId, out var references) ? references : []; /// /// Returns all distinct ART identifiers referenced by indexed game-data assets. /// - public IReadOnlyCollection GetReferencedArtIds() => _data.ArtReferencesById.Keys.ToArray(); + public IReadOnlyCollection GetReferencedArtIds() => _data.ArtReferencesById.Value.Keys.ToArray(); /// /// Returns the indexed jump-file detail for one asset path, or diff --git a/src/Editor/ArcNET.Editor/EditorAssetIndexBuilder.cs b/src/Editor/ArcNET.Editor/EditorAssetIndexBuilder.cs index 3ad213b..cc5a3be 100644 --- a/src/Editor/ArcNET.Editor/EditorAssetIndexBuilder.cs +++ b/src/Editor/ArcNET.Editor/EditorAssetIndexBuilder.cs @@ -1,4 +1,6 @@ using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks.Dataflow; using ArcNET.Core; using ArcNET.Formats; using ArcNET.GameData; @@ -23,95 +25,232 @@ public static (EditorAssetIndex Index, EditorWorkspaceValidationReport Validatio ArgumentNullException.ThrowIfNull(assets); const int TotalPhases = 13; + var progressGate = new object(); var completedPhases = 0; - void Report(string activity) => - progress?.Report( - new EditorAssetIndexBuildProgress( - activity, - completedPhases / (float)TotalPhases, - completedPhases, - TotalPhases - ) - ); + void Report(string activity) + { + lock (progressGate) + { + progress?.Report( + new EditorAssetIndexBuildProgress( + activity, + completedPhases / (float)TotalPhases, + completedPhases, + TotalPhases + ) + ); + } + } + + void CompletePhase() + { + lock (progressGate) + completedPhases++; + } Report("Indexing asset paths"); var assetsByPath = assets.Entries.ToDictionary(entry => entry.AssetPath, StringComparer.OrdinalIgnoreCase); - completedPhases++; + CompletePhase(); Report("Indexing map assets"); var (mapNames, mapAssetsByName, mapNameByAssetPath) = BuildMapAssets(assets.Entries); - completedPhases++; + CompletePhase(); + + IReadOnlyDictionary>? mapSectorsByName = null; + IReadOnlyDictionary? sectorSummariesByAssetPath = null; + IReadOnlyList? sectorSummaries = null; + IReadOnlyDictionary>? messageAssetsByIndex = null; + IReadOnlySet? protoDisplayNameMessageIndices = null; + IReadOnlyDictionary? protoDefinitionsByNumber = null; + IReadOnlyDictionary>? scriptDefinitionsById = null; + IReadOnlyDictionary>? dialogDefinitionsById = null; + IReadOnlyDictionary>? scriptDetailsById = null; + IReadOnlyDictionary>? dialogDetailsById = null; + IReadOnlyDictionary? artDetailsByAssetPath = null; + IReadOnlyDictionary? jumpDetailsByAssetPath = null; + IReadOnlyDictionary? mapPropertiesDetailsByAssetPath = null; + IReadOnlyDictionary? terrainDetailsByAssetPath = null; + IReadOnlyDictionary? facadeWalkDetailsByAssetPath = null; + IReadOnlyDictionary>? protoReferencesByNumber = null; + IReadOnlyDictionary>? protoReferencesByAssetPath = null; + IReadOnlyDictionary>? scriptReferencesById = null; + IReadOnlyDictionary>? scriptReferencesByAssetPath = null; + + RunIndexBuildPhases( + new IndexBuildPhase( + "Summarizing sectors", + () => + { + Report("Summarizing sectors"); + (mapSectorsByName, sectorSummariesByAssetPath) = BuildSectorSummaries( + gameData, + assetsByPath, + mapNameByAssetPath + ); + sectorSummaries = sectorSummariesByAssetPath.Values.ToArray(); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Indexing message assets", + () => + { + Report("Indexing message assets"); + messageAssetsByIndex = BuildMessageAssetsByIndex(gameData, assetsByPath); + protoDisplayNameMessageIndices = BuildProtoDisplayNameMessageIndices(gameData); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Indexing asset definitions", + () => + { + Report("Indexing asset definitions"); + protoDefinitionsByNumber = BuildProtoDefinitionsByNumber(assets.Entries); + scriptDefinitionsById = BuildScriptDefinitionsById(assets.Entries); + dialogDefinitionsById = BuildDialogDefinitionsById(assets.Entries); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Indexing script details", + () => + { + Report("Indexing script details"); + scriptDetailsById = BuildScriptDetailsById(gameData, assetsByPath); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Indexing dialog details", + () => + { + Report("Indexing dialog details"); + dialogDetailsById = BuildDialogDetailsById(gameData, assetsByPath); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Indexing art and map details", + () => + { + Report("Indexing art and map details"); + artDetailsByAssetPath = BuildArtDetailsByAssetPath(gameData, assetsByPath); + jumpDetailsByAssetPath = BuildJumpDetailsByAssetPath(gameData, assetsByPath); + mapPropertiesDetailsByAssetPath = BuildMapPropertiesDetailsByAssetPath(gameData, assetsByPath); + terrainDetailsByAssetPath = BuildTerrainDetailsByAssetPath(gameData, assetsByPath); + facadeWalkDetailsByAssetPath = BuildFacadeWalkDetailsByAssetPath(gameData, assetsByPath); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Counting asset references", + () => + { + Report("Counting asset references"); + EditorAssetReferenceCounter.CountReferences( + gameData, + assetsByPath, + out var protoRefsByNumber, + out var protoRefsByAssetPath, + out var scriptRefsById, + out var scriptRefsByAssetPath, + out _, + out _, + includeArtReferences: false + ); + protoReferencesByNumber = protoRefsByNumber; + protoReferencesByAssetPath = protoRefsByAssetPath; + scriptReferencesById = scriptRefsById; + scriptReferencesByAssetPath = scriptRefsByAssetPath; + CompletePhase(); + } + ) + ); - Report("Summarizing sectors"); - var (mapSectorsByName, sectorSummariesByAssetPath) = BuildSectorSummaries( - gameData, - assetsByPath, - mapNameByAssetPath + var artReferenceIndexes = CreateLazyArtReferenceIndexes(gameData, assetsByPath); + var artReferencesById = new Lazy>>( + () => artReferenceIndexes.Value.ById, + LazyThreadSafetyMode.ExecutionAndPublication ); - var sectorSummaries = sectorSummariesByAssetPath.Values.ToArray(); - completedPhases++; - - Report("Building scheme lookups"); - var (lightSchemeSectorsByIndex, musicSchemeSectorsByIndex, ambientSchemeSectorsByIndex) = - BuildSectorSchemeLookups(sectorSummaries); - completedPhases++; - - Report("Projecting map sectors"); - var mapProjectionsByName = EditorSectorProjectionBuilder.Build(mapSectorsByName); - completedPhases++; - - Report("Indexing message assets"); - var messageAssetsByIndex = BuildMessageAssetsByIndex(gameData, assetsByPath); - var protoDisplayNameMessageIndices = BuildProtoDisplayNameMessageIndices(gameData); - completedPhases++; - - Report("Indexing asset definitions"); - var protoDefinitionsByNumber = BuildProtoDefinitionsByNumber(assets.Entries); - var scriptDefinitionsById = BuildScriptDefinitionsById(assets.Entries); - var dialogDefinitionsById = BuildDialogDefinitionsById(assets.Entries); - completedPhases++; - - Report("Indexing script details"); - var scriptDetailsById = BuildScriptDetailsById(gameData, assetsByPath); - completedPhases++; - - Report("Indexing dialog details"); - var dialogDetailsById = BuildDialogDetailsById(gameData, assetsByPath); - completedPhases++; - - Report("Indexing art and map details"); - var artDetailsByAssetPath = BuildArtDetailsByAssetPath(gameData, assetsByPath); - var jumpDetailsByAssetPath = BuildJumpDetailsByAssetPath(gameData, assetsByPath); - var mapPropertiesDetailsByAssetPath = BuildMapPropertiesDetailsByAssetPath(gameData, assetsByPath); - var terrainDetailsByAssetPath = BuildTerrainDetailsByAssetPath(gameData, assetsByPath); - var facadeWalkDetailsByAssetPath = BuildFacadeWalkDetailsByAssetPath(gameData, assetsByPath); - completedPhases++; - - Report("Counting asset references"); - EditorAssetReferenceCounter.CountReferences( - gameData, - assetsByPath, - out var protoReferencesByNumber, - out var protoReferencesByAssetPath, - out var scriptReferencesById, - out var scriptReferencesByAssetPath, - out var artReferencesById, - out var artReferencesByAssetPath + + IReadOnlyDictionary>? lightSchemeSectorsByIndex = null; + IReadOnlyDictionary>? musicSchemeSectorsByIndex = null; + IReadOnlyDictionary>? ambientSchemeSectorsByIndex = null; + IReadOnlyDictionary? mapProjectionsByName = null; + + RunIndexBuildPhases( + new IndexBuildPhase( + "Building scheme lookups", + () => + { + Report("Building scheme lookups"); + (lightSchemeSectorsByIndex, musicSchemeSectorsByIndex, ambientSchemeSectorsByIndex) = + BuildSectorSchemeLookups(sectorSummaries!); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Projecting map sectors", + () => + { + Report("Projecting map sectors"); + mapProjectionsByName = EditorSectorProjectionBuilder.Build(mapSectorsByName!); + CompletePhase(); + } + ) ); - completedPhases++; - - Report("Building dependency summaries"); - var assetDependencySummariesByAssetPath = BuildAssetDependencySummaries( - assets.Entries, - mapNameByAssetPath, - protoReferencesByNumber, - protoReferencesByAssetPath, - scriptReferencesById, - scriptReferencesByAssetPath, - artReferencesById, - artReferencesByAssetPath + + Lazy>? assetDependencySummariesByAssetPath = null; + EditorWorkspaceValidationReport? validation = null; + + RunIndexBuildPhases( + new IndexBuildPhase( + "Building dependency summaries", + () => + { + Report("Building dependency summaries"); + assetDependencySummariesByAssetPath = new Lazy< + IReadOnlyDictionary + >( + () => + { + var artReferences = artReferenceIndexes.Value; + return BuildAssetDependencySummaries( + assets.Entries, + mapNameByAssetPath, + protoReferencesByNumber!, + protoReferencesByAssetPath!, + scriptReferencesById!, + scriptReferencesByAssetPath!, + artReferences.ById, + artReferences.ByAssetPath + ); + }, + LazyThreadSafetyMode.ExecutionAndPublication + ); + CompletePhase(); + } + ), + new IndexBuildPhase( + "Validating workspace", + () => + { + Report("Validating workspace"); + validation = new EditorWorkspaceValidator().Build( + protoDefinitionsByNumber!, + scriptDefinitionsById!, + scriptDetailsById!, + dialogDetailsById!, + protoReferencesByNumber!, + scriptReferencesById!, + protoDisplayNameMessageIndices!, + installationType + ); + CompletePhase(); + } + ) ); - completedPhases++; var index = EditorAssetIndex.Create( new EditorAssetIndexData @@ -119,47 +258,81 @@ out var artReferencesByAssetPath MapNames = mapNames, MapAssetsByName = mapAssetsByName, MapNameByAssetPath = mapNameByAssetPath, - AssetDependencySummariesByAssetPath = assetDependencySummariesByAssetPath, - MapSectorsByName = mapSectorsByName, - SectorSummariesByAssetPath = sectorSummariesByAssetPath, - LightSchemeSectorsByIndex = lightSchemeSectorsByIndex, - MusicSchemeSectorsByIndex = musicSchemeSectorsByIndex, - AmbientSchemeSectorsByIndex = ambientSchemeSectorsByIndex, - MapProjectionsByName = mapProjectionsByName, - MessageAssetsByIndex = messageAssetsByIndex, - ProtoDefinitionsByNumber = protoDefinitionsByNumber, - ScriptDefinitionsById = scriptDefinitionsById, - DialogDefinitionsById = dialogDefinitionsById, - ScriptDetailsById = scriptDetailsById, - DialogDetailsById = dialogDetailsById, - ArtDetailsByAssetPath = artDetailsByAssetPath, - JumpDetailsByAssetPath = jumpDetailsByAssetPath, - MapPropertiesDetailsByAssetPath = mapPropertiesDetailsByAssetPath, - TerrainDetailsByAssetPath = terrainDetailsByAssetPath, - FacadeWalkDetailsByAssetPath = facadeWalkDetailsByAssetPath, - ProtoReferencesByNumber = protoReferencesByNumber, - ScriptReferencesById = scriptReferencesById, + AssetDependencySummariesByAssetPath = assetDependencySummariesByAssetPath!, + MapSectorsByName = mapSectorsByName!, + SectorSummariesByAssetPath = sectorSummariesByAssetPath!, + LightSchemeSectorsByIndex = lightSchemeSectorsByIndex!, + MusicSchemeSectorsByIndex = musicSchemeSectorsByIndex!, + AmbientSchemeSectorsByIndex = ambientSchemeSectorsByIndex!, + MapProjectionsByName = mapProjectionsByName!, + MessageAssetsByIndex = messageAssetsByIndex!, + ProtoDefinitionsByNumber = protoDefinitionsByNumber!, + ScriptDefinitionsById = scriptDefinitionsById!, + DialogDefinitionsById = dialogDefinitionsById!, + ScriptDetailsById = scriptDetailsById!, + DialogDetailsById = dialogDetailsById!, + ArtDetailsByAssetPath = artDetailsByAssetPath!, + JumpDetailsByAssetPath = jumpDetailsByAssetPath!, + MapPropertiesDetailsByAssetPath = mapPropertiesDetailsByAssetPath!, + TerrainDetailsByAssetPath = terrainDetailsByAssetPath!, + FacadeWalkDetailsByAssetPath = facadeWalkDetailsByAssetPath!, + ProtoReferencesByNumber = protoReferencesByNumber!, + ScriptReferencesById = scriptReferencesById!, ArtReferencesById = artReferencesById, } ); - Report("Validating workspace"); - var validation = new EditorWorkspaceValidator().Build( - protoDefinitionsByNumber, - scriptDefinitionsById, - scriptDetailsById, - dialogDetailsById, - protoReferencesByNumber, - scriptReferencesById, - protoDisplayNameMessageIndices, - installationType - ); - completedPhases++; progress?.Report(new EditorAssetIndexBuildProgress("Asset index complete", 1f, TotalPhases, TotalPhases)); - return (index, validation); + return (index, validation!); + } + + private static void RunIndexBuildPhases(params IndexBuildPhase[] phases) + { + var block = new ActionBlock( + phase => phase.Execute(), + new ExecutionDataflowBlockOptions + { + BoundedCapacity = phases.Length, + EnsureOrdered = false, + MaxDegreeOfParallelism = GetIndexBuildParallelism(), + } + ); + + for (var index = 0; index < phases.Length; index++) + { + if (!block.Post(phases[index])) + throw new InvalidOperationException("The workspace index pipeline declined a phase before completion."); + } + + block.Complete(); + block.Completion.GetAwaiter().GetResult(); } + private static int GetIndexBuildParallelism() => Math.Clamp(Environment.ProcessorCount / 3, 2, 4); + + private static Lazy CreateLazyArtReferenceIndexes( + GameDataStore gameData, + IReadOnlyDictionary assetsByPath + ) => + new( + () => + { + EditorAssetReferenceCounter.CountReferences( + gameData, + assetsByPath, + out _, + out _, + out _, + out _, + out var artReferencesById, + out var artReferencesByAssetPath + ); + return new ArtReferenceIndexes(artReferencesById, artReferencesByAssetPath); + }, + LazyThreadSafetyMode.ExecutionAndPublication + ); + private static ( IReadOnlyList MapNames, IReadOnlyDictionary> AssetsByMap, @@ -878,5 +1051,12 @@ private static bool TryGetMapNameFromAssetPath(string assetPath, out string mapN [GeneratedRegex(@"(?:^|/)(?\d+)[^/]*\.dlg$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex DialogAssetPathPattern(); + private readonly record struct IndexBuildPhase(string Activity, Action Execute); + + private readonly record struct ArtReferenceIndexes( + IReadOnlyDictionary> ById, + IReadOnlyDictionary> ByAssetPath + ); + private delegate bool TryGetAssetId(string assetPath, out int assetId); } diff --git a/src/Editor/ArcNET.Editor/EditorAssetIndexData.cs b/src/Editor/ArcNET.Editor/EditorAssetIndexData.cs index 0bc0b3f..2b57981 100644 --- a/src/Editor/ArcNET.Editor/EditorAssetIndexData.cs +++ b/src/Editor/ArcNET.Editor/EditorAssetIndexData.cs @@ -1,4 +1,6 @@ -namespace ArcNET.Editor; +using System.Threading; + +namespace ArcNET.Editor; internal sealed record EditorAssetIndexData { @@ -8,8 +10,9 @@ internal sealed record EditorAssetIndexData MapNames = [], MapAssetsByName = new Dictionary>(StringComparer.OrdinalIgnoreCase), MapNameByAssetPath = new Dictionary(StringComparer.OrdinalIgnoreCase), - AssetDependencySummariesByAssetPath = new Dictionary( - StringComparer.OrdinalIgnoreCase + AssetDependencySummariesByAssetPath = new Lazy>( + () => new Dictionary(StringComparer.OrdinalIgnoreCase), + LazyThreadSafetyMode.ExecutionAndPublication ), MapSectorsByName = new Dictionary>( StringComparer.OrdinalIgnoreCase @@ -38,15 +41,17 @@ internal sealed record EditorAssetIndexData ), ProtoReferencesByNumber = new Dictionary>(), ScriptReferencesById = new Dictionary>(), - ArtReferencesById = new Dictionary>(), + ArtReferencesById = new Lazy>>( + () => new Dictionary>(), + LazyThreadSafetyMode.ExecutionAndPublication + ), }; public required IReadOnlyList MapNames { get; init; } public required IReadOnlyDictionary> MapAssetsByName { get; init; } public required IReadOnlyDictionary MapNameByAssetPath { get; init; } - public required IReadOnlyDictionary< - string, - EditorAssetDependencySummary + public required Lazy< + IReadOnlyDictionary > AssetDependencySummariesByAssetPath { get; init; } public required IReadOnlyDictionary> MapSectorsByName { get; init; } public required IReadOnlyDictionary SectorSummariesByAssetPath { get; init; } @@ -79,11 +84,10 @@ public required IReadOnlyDictionary< public required IReadOnlyDictionary FacadeWalkDetailsByAssetPath { get; init; } public required IReadOnlyDictionary> ProtoReferencesByNumber { get; init; } public required IReadOnlyDictionary> ScriptReferencesById { get; init; } - public required IReadOnlyDictionary> ArtReferencesById { get; init; } + public required Lazy>> ArtReferencesById { get; init; } public bool IsEmpty => MapNames.Count == 0 - && AssetDependencySummariesByAssetPath.Count == 0 && SectorSummariesByAssetPath.Count == 0 && LightSchemeSectorsByIndex.Count == 0 && MusicSchemeSectorsByIndex.Count == 0 @@ -101,6 +105,5 @@ public required IReadOnlyDictionary< && TerrainDetailsByAssetPath.Count == 0 && FacadeWalkDetailsByAssetPath.Count == 0 && ProtoReferencesByNumber.Count == 0 - && ScriptReferencesById.Count == 0 - && ArtReferencesById.Count == 0; + && ScriptReferencesById.Count == 0; } diff --git a/src/Editor/ArcNET.Editor/EditorAssetReferenceCounter.cs b/src/Editor/ArcNET.Editor/EditorAssetReferenceCounter.cs index fa6dbf7..520a537 100644 --- a/src/Editor/ArcNET.Editor/EditorAssetReferenceCounter.cs +++ b/src/Editor/ArcNET.Editor/EditorAssetReferenceCounter.cs @@ -21,7 +21,8 @@ public static void CountReferences( out IReadOnlyDictionary> scriptReferencesById, out IReadOnlyDictionary> scriptReferencesByAssetPath, out IReadOnlyDictionary> artReferencesById, - out IReadOnlyDictionary> artReferencesByAssetPath + out IReadOnlyDictionary> artReferencesByAssetPath, + bool includeArtReferences = true ) { var rawProtoByNumber = new Dictionary>(); @@ -35,7 +36,7 @@ out IReadOnlyDictionary> artReferences var tempProtoCounts = new Dictionary(); var tempScriptCounts = new Dictionary(); - var tempArtCounts = new Dictionary(); + Dictionary? tempArtCounts = includeArtReferences ? [] : null; foreach (var (assetPath, mobs) in gameData.MobsBySource) { @@ -59,7 +60,7 @@ out IReadOnlyDictionary> artReferences } var sectorGroups = CreateSourceGroups(gameData.SectorsBySource, assetsByPath); - var sectorCounts = CountSectorReferences(sectorGroups); + var sectorCounts = CountSectorReferences(sectorGroups, includeArtReferences); for (var index = 0; index < sectorCounts.Length; index++) { var counts = sectorCounts[index]; @@ -96,13 +97,16 @@ IReadOnlyDictionary assetsByPath return [.. groups]; } - private static SourceReferenceCounts[] CountSectorReferences(SourceGroup[] groups) + private static SourceReferenceCounts[] CountSectorReferences( + SourceGroup[] groups, + bool includeArtReferences + ) { var counts = new SourceReferenceCounts[groups.Length]; if (groups.Length < ParallelSectorSourceThreshold || Environment.ProcessorCount <= 1) { for (var index = 0; index < groups.Length; index++) - counts[index] = CountSectorReferences(groups[index]); + counts[index] = CountSectorReferences(groups[index], includeArtReferences); return counts; } @@ -111,12 +115,12 @@ private static SourceReferenceCounts[] CountSectorReferences(SourceGroup 0, groups.Length, new ParallelOptions { MaxDegreeOfParallelism = EditorParallelism.InteractiveMaxDegreeOfParallelism }, - index => counts[index] = CountSectorReferences(groups[index]) + index => counts[index] = CountSectorReferences(groups[index], includeArtReferences) ); return counts; } - private static SourceReferenceCounts CountSectorReferences(SourceGroup group) + private static SourceReferenceCounts CountSectorReferences(SourceGroup group, bool includeArtReferences) { var counts = new SourceReferenceCounts(group.AssetPath, group.Asset); @@ -134,16 +138,15 @@ private static SourceReferenceCounts CountSectorReferences(SourceGroup g counts.IncrementScript(tileScript.ScriptNum); } - for (var j = 0; j < sector.Lights.Count; j++) - counts.IncrementNonZeroArt(sector.Lights[j].ArtId); + if (includeArtReferences) + { + for (var j = 0; j < sector.Lights.Count; j++) + counts.IncrementNonZeroArt(sector.Lights[j].ArtId); - for (var j = 0; j < sector.Tiles.Length; j++) - counts.IncrementNonZeroArt(sector.Tiles[j]); + counts.IncrementNonZeroArts(sector.Tiles); - if (sector.Roofs is not null) - { - for (var j = 0; j < sector.Roofs.Length; j++) - counts.IncrementNonZeroArt(sector.Roofs[j]); + if (sector.Roofs is not null) + counts.IncrementNonZeroArts(sector.Roofs); } for (var j = 0; j < sector.Objects.Count; j++) @@ -153,7 +156,7 @@ private static SourceReferenceCounts CountSectorReferences(SourceGroup g if (protoNumber.HasValue) counts.IncrementProto(protoNumber.Value); - AddObjectReferences(counts, obj.Properties); + AddObjectReferences(counts, obj.Properties, includeArtReferences); } } @@ -164,12 +167,12 @@ private static void CountMobReferences( IReadOnlyList mobs, Dictionary protoCounts, Dictionary scriptCounts, - Dictionary artCounts + Dictionary? artCounts ) { protoCounts.Clear(); scriptCounts.Clear(); - artCounts.Clear(); + artCounts?.Clear(); for (var index = 0; index < mobs.Count; index++) { @@ -185,11 +188,11 @@ Dictionary artCounts private static void CountProtoReferences( IReadOnlyList protos, Dictionary scriptCounts, - Dictionary artCounts + Dictionary? artCounts ) { scriptCounts.Clear(); - artCounts.Clear(); + artCounts?.Clear(); for (var index = 0; index < protos.Count; index++) AddObjectReferences(scriptCounts, artCounts, protos[index].Properties); @@ -197,7 +200,7 @@ Dictionary artCounts private static void AddObjectReferences( Dictionary scriptCounts, - Dictionary artCounts, + Dictionary? artCounts, IReadOnlyList properties ) { @@ -214,14 +217,18 @@ IReadOnlyList properties case ObjectField.LightAid: case ObjectField.Aid: case ObjectField.DestroyedAid: - if (TryGetArtId(property, out var artId)) + if (artCounts is not null && TryGetArtId(property, out var artId)) IncrementNonZeroCount(artCounts, artId); break; } } } - private static void AddObjectReferences(SourceReferenceCounts counts, IReadOnlyList properties) + private static void AddObjectReferences( + SourceReferenceCounts counts, + IReadOnlyList properties, + bool includeArtReferences + ) { for (var index = 0; index < properties.Count; index++) { @@ -236,7 +243,7 @@ private static void AddObjectReferences(SourceReferenceCounts counts, IReadOnlyL case ObjectField.LightAid: case ObjectField.Aid: case ObjectField.DestroyedAid: - if (TryGetArtId(property, out var artId)) + if (includeArtReferences && TryGetArtId(property, out var artId)) counts.IncrementNonZeroArt(artId); break; } @@ -448,28 +455,46 @@ Dictionary> rawArtByAsset ) { foreach (var list in rawProtoByNumber.Values) - list.Sort( - static (a, b) => - string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) - ); + { + if (list.Count > 1) + list.Sort( + static (a, b) => + string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) + ); + } foreach (var list in rawProtoByAsset.Values) - list.Sort(static (a, b) => a.ProtoNumber.CompareTo(b.ProtoNumber)); + { + if (list.Count > 1) + list.Sort(static (a, b) => a.ProtoNumber.CompareTo(b.ProtoNumber)); + } foreach (var list in rawScriptById.Values) - list.Sort( - static (a, b) => - string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) - ); + { + if (list.Count > 1) + list.Sort( + static (a, b) => + string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) + ); + } foreach (var list in rawScriptByAsset.Values) - list.Sort(static (a, b) => a.ScriptId.CompareTo(b.ScriptId)); + { + if (list.Count > 1) + list.Sort(static (a, b) => a.ScriptId.CompareTo(b.ScriptId)); + } foreach (var list in rawArtById.Values) - list.Sort( - static (a, b) => - string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) - ); + { + if (list.Count > 1) + list.Sort( + static (a, b) => + string.Compare(a.Asset.AssetPath, b.Asset.AssetPath, StringComparison.OrdinalIgnoreCase) + ); + } foreach (var list in rawArtByAsset.Values) - list.Sort(static (a, b) => a.ArtId.CompareTo(b.ArtId)); + { + if (list.Count > 1) + list.Sort(static (a, b) => a.ArtId.CompareTo(b.ArtId)); + } } private static void IncrementCount(Dictionary counts, TKey key) @@ -534,5 +559,20 @@ public void IncrementNonZeroArt(uint artId) IncrementCount(ArtCounts ??= [], artId); } + + public void IncrementNonZeroArts(ReadOnlySpan artIds) + { + Dictionary? artCounts = null; + for (var index = 0; index < artIds.Length; index++) + { + var artId = artIds[index]; + if (artId == 0) + continue; + + artCounts ??= ArtCounts ??= []; + ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(artCounts, artId, out _); + count++; + } + } } } diff --git a/src/Editor/ArcNET.Editor/EditorAudioAssetLoader.cs b/src/Editor/ArcNET.Editor/EditorAudioAssetLoader.cs index 11ab549..f87c3dc 100644 --- a/src/Editor/ArcNET.Editor/EditorAudioAssetLoader.cs +++ b/src/Editor/ArcNET.Editor/EditorAudioAssetLoader.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks.Dataflow; using ArcNET.Archive; using ArcNET.GameData.Workspace; @@ -5,6 +6,45 @@ namespace ArcNET.Editor; internal static class EditorAudioAssetLoader { + private const int AudioProgressReportStride = 64; + + public static EditorAudioAssetLoadResult CreateFromAssetSources( + IReadOnlyDictionary assetSources, + IProgress? progress = null + ) + { + ArgumentNullException.ThrowIfNull(assetSources); + + progress?.Report(new EditorAssetLoadProgress("Indexing audio assets", 0f, 0, assetSources.Count, "assets")); + + List entries = []; + foreach (var (assetPath, source) in assetSources) + { + if (!IsSupportedAudioAsset(assetPath)) + continue; + + var normalizedPath = NormalizeVirtualPath(assetPath); + entries.Add( + new EditorAudioAssetEntry + { + AssetPath = normalizedPath, + SourceKind = EditorAssetSourceKindAdapter.FromWorkspaceSourceKind(source.SourceKind), + SourcePath = source.SourcePath, + SourceEntryPath = source.SourceEntryPath is null + ? null + : NormalizeVirtualPath(source.SourceEntryPath), + ByteLength = ResolveAudioByteLength(source), + } + ); + } + + progress?.Report( + new EditorAssetLoadProgress("Indexing audio assets", 1f, entries.Count, entries.Count, "audio files") + ); + + return new EditorAudioAssetLoadResult(EditorAudioAssetCatalog.Create(entries)); + } + public static async Task LoadFromContentDirectoryAsync( string contentDirectory, CancellationToken cancellationToken = default, @@ -16,16 +56,14 @@ public static async Task LoadFromContentDirectoryAsy throw new DirectoryNotFoundException($"Content directory not found: {contentDirectory}"); var aggregator = new ProgressAggregator(progress); - var overlaySource = await LoadLooseFilesAsync( - contentDirectory, - skipSaveDirectory: false, - cancellationToken, - aggregator.CreateSubProgress("looseFiles"), - "Loading audio assets" + var overlaySources = await LoadSourcesAsync( + [AudioSourceRequest.LooseFiles(contentDirectory, skipSaveDirectory: false, "looseFiles")], + aggregator, + cancellationToken ) .ConfigureAwait(false); - return CreateLoadResult([overlaySource]); + return CreateLoadResult(overlaySources); } public static async Task LoadFromGameInstallAsync( @@ -39,25 +77,21 @@ public static async Task LoadFromGameInstallAsync( throw new DirectoryNotFoundException($"Game directory not found: {gameDir}"); var aggregator = new ProgressAggregator(progress); - var sourceTasks = WorkspaceArchiveDiscovery - .DiscoverGameInstallArchives(gameDir) - .ArchivePaths.Select(archivePath => - LoadArchiveAsync(archivePath, cancellationToken, aggregator.CreateSubProgress(archivePath)) - ) - .ToList(); + List sourceRequests = + [ + .. WorkspaceArchiveDiscovery + .DiscoverGameInstallArchives(gameDir) + .ArchivePaths.Select(AudioSourceRequest.Archive), + ]; var looseDataDirectory = Path.Combine(gameDir, "data"); if (Directory.Exists(looseDataDirectory)) - sourceTasks.Add( - LoadLooseFilesAsync( - looseDataDirectory, - skipSaveDirectory: false, - cancellationToken, - aggregator.CreateSubProgress("looseFiles") - ) + sourceRequests.Add( + AudioSourceRequest.LooseFiles(looseDataDirectory, skipSaveDirectory: false, looseDataDirectory) ); - var overlaySources = await Task.WhenAll(sourceTasks).ConfigureAwait(false); + var overlaySources = await LoadSourcesAsync(sourceRequests, aggregator, cancellationToken) + .ConfigureAwait(false); return CreateLoadResult(overlaySources); } @@ -72,60 +106,103 @@ public static async Task LoadFromModuleDirectoryAsyn throw new DirectoryNotFoundException($"Module content not found: {moduleDirectory}"); var aggregator = new ProgressAggregator(progress); - var sourceTasks = new List>(); + var sourceRequests = new List(); var hasLooseModuleDirectory = Directory.Exists(moduleDirectory); var gameDirectory = ResolveOwningGameDirectory(moduleDirectory); if (gameDirectory is not null) { - sourceTasks.AddRange( + sourceRequests.AddRange( WorkspaceArchiveDiscovery .DiscoverGameInstallArchives(gameDirectory) - .ArchivePaths.Select(archivePath => - LoadArchiveAsync(archivePath, cancellationToken, aggregator.CreateSubProgress(archivePath)) - ) + .ArchivePaths.Select(AudioSourceRequest.Archive) ); var looseDataDirectory = Path.Combine(gameDirectory, "data"); if (Directory.Exists(looseDataDirectory)) - sourceTasks.Add( - LoadLooseFilesAsync( - looseDataDirectory, - skipSaveDirectory: false, - cancellationToken, - aggregator.CreateSubProgress("looseFiles") - ) + sourceRequests.Add( + AudioSourceRequest.LooseFiles(looseDataDirectory, skipSaveDirectory: false, looseDataDirectory) ); } - sourceTasks.AddRange( + sourceRequests.AddRange( WorkspaceArchiveDiscovery .DiscoverModuleArchives(moduleDirectory) - .ArchivePaths.Select(archivePath => - LoadArchiveAsync(archivePath, cancellationToken, aggregator.CreateSubProgress(archivePath)) - ) + .ArchivePaths.Select(AudioSourceRequest.Archive) ); if (hasLooseModuleDirectory) - { - sourceTasks.Add( - LoadLooseFilesAsync( - moduleDirectory, - skipSaveDirectory: true, - cancellationToken, - aggregator.CreateSubProgress("looseFiles") - ) + sourceRequests.Add( + AudioSourceRequest.LooseFiles(moduleDirectory, skipSaveDirectory: true, moduleDirectory) ); - } - var overlaySources = await Task.WhenAll(sourceTasks).ConfigureAwait(false); + var overlaySources = await LoadSourcesAsync(sourceRequests, aggregator, cancellationToken) + .ConfigureAwait(false); return CreateLoadResult(overlaySources); } - private static Task LoadArchiveAsync( - string archivePath, + private static async Task> LoadSourcesAsync( + IReadOnlyList sourceRequests, + ProgressAggregator aggregator, + CancellationToken cancellationToken + ) + { + if (sourceRequests.Count == 0) + return []; + + var overlaySources = new AudioOverlaySource?[sourceRequests.Count]; + var parallelism = GetAudioSourceParallelism(sourceRequests.Count); + var sourceBlock = new TransformBlock( + source => new AudioSourceResult( + source.Index, + LoadSource(source.Request, cancellationToken, aggregator.CreateSubProgress(source.Request.ProgressKey)) + ), + new ExecutionDataflowBlockOptions + { + BoundedCapacity = parallelism * 2, + CancellationToken = cancellationToken, + EnsureOrdered = false, + MaxDegreeOfParallelism = parallelism, + } + ); + var writeBlock = new ActionBlock( + result => overlaySources[result.Index] = result.Source, + new ExecutionDataflowBlockOptions + { + BoundedCapacity = parallelism * 2, + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = 1, + } + ); + + sourceBlock.LinkTo(writeBlock, new DataflowLinkOptions { PropagateCompletion = true }); + + for (var index = 0; index < sourceRequests.Count; index++) + await sourceBlock + .SendAsync(new IndexedAudioSourceRequest(index, sourceRequests[index]), cancellationToken) + .ConfigureAwait(false); + + sourceBlock.Complete(); + await writeBlock.Completion.ConfigureAwait(false); + + return overlaySources.Select(source => source ?? new AudioOverlaySource()).ToArray(); + } + + private static AudioOverlaySource LoadSource( + AudioSourceRequest request, CancellationToken cancellationToken, IProgress? progress = null - ) => Task.Run(() => ReadArchive(archivePath, cancellationToken, progress), cancellationToken); + ) => + request.Kind switch + { + AudioSourceKind.Archive => ReadArchive(request.Path, cancellationToken, progress), + AudioSourceKind.LooseFiles => ReadLooseFiles( + request.Path, + request.SkipSaveDirectory, + cancellationToken, + progress + ), + _ => throw new InvalidOperationException($"Unsupported audio source kind '{request.Kind}'."), + }; private static AudioOverlaySource ReadArchive( string archivePath, @@ -133,10 +210,10 @@ private static AudioOverlaySource ReadArchive( IProgress? progress = null ) { - var overlaySource = new AudioOverlaySource(); using var archive = DatArchive.Open(archivePath); var audioEntries = archive.Entries.Where(e => IsSupportedAudioAsset(e.Path)).ToArray(); + var overlaySource = new AudioOverlaySource(audioEntries.Length); if (audioEntries.Length == 0) return overlaySource; @@ -148,41 +225,23 @@ private static AudioOverlaySource ReadArchive( new EditorAssetLoadProgress($"Reading {archiveName}", 0f, 0, audioEntries.Length, "audio files") ); - Parallel.ForEach( - audioEntries, - new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = EditorParallelism.InteractiveMaxDegreeOfParallelism, - }, - entry => - { - var assetPath = NormalizeVirtualPath(entry.Path); + foreach (var entry in audioEntries) + { + cancellationToken.ThrowIfCancellationRequested(); - lock (overlaySource) - { - overlaySource.EntriesByPath[assetPath] = new EditorAudioAssetEntry - { - AssetPath = assetPath, - SourceKind = EditorAssetSourceKind.DatArchive, - SourcePath = archivePath, - SourceEntryPath = assetPath, - ByteLength = entry.UncompressedSize, - }; - } + var assetPath = NormalizeVirtualPath(entry.Path); + overlaySource.EntriesByPath[assetPath] = new EditorAudioAssetEntry + { + AssetPath = assetPath, + SourceKind = EditorAssetSourceKind.DatArchive, + SourcePath = archivePath, + SourceEntryPath = assetPath, + ByteLength = entry.UncompressedSize, + }; - var completed = Interlocked.Increment(ref completedCount); - progress?.Report( - new EditorAssetLoadProgress( - $"Reading {archiveName}", - completed / (float)audioEntries.Length, - completed, - audioEntries.Length, - "audio files" - ) - ); - } - ); + completedCount++; + ReportAudioProgress(progress, $"Reading {archiveName}", completedCount, audioEntries.Length); + } progress?.Report( new EditorAssetLoadProgress( @@ -197,7 +256,7 @@ private static AudioOverlaySource ReadArchive( return overlaySource; } - private static Task LoadLooseFilesAsync( + private static AudioOverlaySource ReadLooseFiles( string rootDirectory, bool skipSaveDirectory, CancellationToken cancellationToken, @@ -214,7 +273,7 @@ private static Task LoadLooseFilesAsync( progress?.Report(new EditorAssetLoadProgress(activity, 0f, 0, filePaths.Length, "audio files")); - var overlaySource = new AudioOverlaySource(); + var overlaySource = new AudioOverlaySource(filePaths.Length); for (var index = 0; index < filePaths.Length; index++) { cancellationToken.ThrowIfCancellationRequested(); @@ -230,21 +289,37 @@ private static Task LoadLooseFilesAsync( ByteLength = checked((int)new FileInfo(filePath).Length), }; - var loadedCount = Interlocked.Increment(ref completedCount); - progress?.Report( - new EditorAssetLoadProgress( - activity, - loadedCount / (float)Math.Max(filePaths.Length, 1), - loadedCount, - filePaths.Length, - "audio files" - ) - ); + completedCount++; + ReportAudioProgress(progress, activity, completedCount, filePaths.Length); } progress?.Report(new EditorAssetLoadProgress(activity, 1f, filePaths.Length, filePaths.Length, "audio files")); - return Task.FromResult(overlaySource); + return overlaySource; + } + + private static int GetAudioSourceParallelism(int sourceCount) => + sourceCount <= 1 ? 1 : Math.Clamp(Environment.ProcessorCount / 4, 2, 4); + + private static void ReportAudioProgress( + IProgress? progress, + string activity, + int completedCount, + int totalCount + ) + { + if (progress is null || (completedCount % AudioProgressReportStride != 0 && completedCount != totalCount)) + return; + + progress.Report( + new EditorAssetLoadProgress( + activity, + completedCount / (float)Math.Max(totalCount, 1), + completedCount, + totalCount, + "audio files" + ) + ); } private static EditorAudioAssetLoadResult CreateLoadResult(IReadOnlyList overlaySources) @@ -278,9 +353,20 @@ private static bool IsSaveFilePath(string filePath) => private static string NormalizeVirtualPath(string path) => ArcNET.Core.VirtualPath.Normalize(path); - private sealed class AudioOverlaySource + private static int ResolveAudioByteLength(WorkspaceAssetSource source) + { + if (source.ByteLength is { } byteLength) + return byteLength; + + return source.SourceKind == WorkspaceAssetSourceKind.LooseFile && File.Exists(source.SourcePath) + ? checked((int)new FileInfo(source.SourcePath).Length) + : 0; + } + + private sealed class AudioOverlaySource(int capacity = 0) { - public Dictionary EntriesByPath { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary EntriesByPath { get; } = + new(capacity, StringComparer.OrdinalIgnoreCase); } internal sealed class EditorAudioAssetLoadResult(EditorAudioAssetCatalog catalog) @@ -288,6 +374,30 @@ internal sealed class EditorAudioAssetLoadResult(EditorAudioAssetCatalog catalog public EditorAudioAssetCatalog Catalog { get; } = catalog; } + private enum AudioSourceKind + { + Archive, + LooseFiles, + } + + private readonly record struct AudioSourceRequest( + AudioSourceKind Kind, + string Path, + bool SkipSaveDirectory, + string ProgressKey + ) + { + public static AudioSourceRequest Archive(string archivePath) => + new(AudioSourceKind.Archive, archivePath, SkipSaveDirectory: false, archivePath); + + public static AudioSourceRequest LooseFiles(string rootDirectory, bool skipSaveDirectory, string progressKey) => + new(AudioSourceKind.LooseFiles, rootDirectory, skipSaveDirectory, progressKey); + } + + private readonly record struct IndexedAudioSourceRequest(int Index, AudioSourceRequest Request); + + private readonly record struct AudioSourceResult(int Index, AudioOverlaySource Source); + private sealed class ProgressAggregator( IProgress? destination, string activity = "Loading audio assets" diff --git a/src/Editor/ArcNET.Editor/EditorMapFloorRenderBuilder.cs b/src/Editor/ArcNET.Editor/EditorMapFloorRenderBuilder.cs index d71c7d4..1a7f79b 100644 --- a/src/Editor/ArcNET.Editor/EditorMapFloorRenderBuilder.cs +++ b/src/Editor/ArcNET.Editor/EditorMapFloorRenderBuilder.cs @@ -186,7 +186,9 @@ private sealed record RawTileOverlayRenderItem( double CenterX, double CenterY, double SuggestedOpacity, - uint SuggestedTintColor + uint SuggestedTintColor, + string? Label, + string? Detail ); private sealed record RawObjectRenderItem( @@ -584,6 +586,8 @@ private static void AddOverlayRevision(ref StableRevisionHash hash, EditorMapTil hash.Add(overlay.CenterY); hash.Add(overlay.SuggestedOpacity); hash.Add(overlay.SuggestedTintColor); + hash.Add(overlay.Label); + hash.Add(overlay.Detail); } private static void AddObjectRevision(ref StableRevisionHash hash, EditorMapObjectRenderItem obj) @@ -1473,7 +1477,9 @@ int mapTileWidth CenterX: overlay.CenterX - existingPreview.OffsetX, CenterY: overlay.CenterY - existingPreview.OffsetY, SuggestedOpacity: overlay.SuggestedOpacity, - SuggestedTintColor: overlay.SuggestedTintColor + SuggestedTintColor: overlay.SuggestedTintColor, + Label: overlay.Label, + Detail: overlay.Detail ); } @@ -2359,7 +2365,9 @@ SectorAccumulator local CenterX: centerX, CenterY: centerY, SuggestedOpacity: GetTileOverlaySuggestedOpacity(EditorMapTileOverlayKind.BlockedTile), - SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.BlockedTile) + SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.BlockedTile), + Label: CreateTileOverlayLabel(EditorMapTileOverlayKind.BlockedTile), + Detail: CreateTileOverlayDetail(EditorMapTileOverlayKind.BlockedTile) ) ); } @@ -2377,13 +2385,16 @@ SectorAccumulator local CenterX: centerX, CenterY: centerY, SuggestedOpacity: GetTileOverlaySuggestedOpacity(EditorMapTileOverlayKind.Light), - SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.Light) + SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.Light), + Label: CreateTileOverlayLabel(EditorMapTileOverlayKind.Light), + Detail: CreateTileOverlayDetail(EditorMapTileOverlayKind.Light) ) ); } if (scriptedTileIndices.Contains(tileIndex) && request.IncludeScriptOverlays) { + var tileScripts = sector.GetTileScriptsAtIndex(tileIndex); local.RawTileOverlays.Add( new RawTileOverlayRenderItem( SectorAssetPath: sector.AssetPath, @@ -2395,13 +2406,16 @@ SectorAccumulator local CenterX: centerX, CenterY: centerY, SuggestedOpacity: GetTileOverlaySuggestedOpacity(EditorMapTileOverlayKind.Script), - SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.Script) + SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.Script), + Label: CreateTileOverlayLabel(EditorMapTileOverlayKind.Script, tileScripts), + Detail: CreateTileOverlayDetail(EditorMapTileOverlayKind.Script, tileScripts) ) ); } if (jumpPointTileIndices.Contains(tileIndex) && request.IncludeJumpPointOverlays) { + var jumpPoints = sector.GetJumpPointsAtIndex(tileIndex); local.RawTileOverlays.Add( new RawTileOverlayRenderItem( SectorAssetPath: sector.AssetPath, @@ -2413,7 +2427,9 @@ SectorAccumulator local CenterX: centerX, CenterY: centerY, SuggestedOpacity: GetTileOverlaySuggestedOpacity(EditorMapTileOverlayKind.JumpPoint), - SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.JumpPoint) + SuggestedTintColor: GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind.JumpPoint), + Label: CreateTileOverlayLabel(EditorMapTileOverlayKind.JumpPoint, jumpPoints: jumpPoints), + Detail: CreateTileOverlayDetail(EditorMapTileOverlayKind.JumpPoint, jumpPoints: jumpPoints) ) ); } @@ -3169,6 +3185,8 @@ out var sectorColors CenterY = o.CenterY + offsetY, SuggestedOpacity = o.SuggestedOpacity, SuggestedTintColor = o.SuggestedTintColor, + Label = o.Label, + Detail = o.Detail, }; var (sliceBuilder, sliceIndex) = GetOrAddSlice(overlay.SectorAssetPath); @@ -3521,11 +3539,81 @@ internal static uint GetTileOverlaySuggestedTintColor(EditorMapTileOverlayKind k { EditorMapTileOverlayKind.BlockedTile => 0x88CC6666u, EditorMapTileOverlayKind.Light => 0x88E0C85Au, - EditorMapTileOverlayKind.Script => 0x88996CCCu, + EditorMapTileOverlayKind.Script => 0x8866CC88u, EditorMapTileOverlayKind.JumpPoint => 0x8866BBDDu, _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported tile overlay kind."), }; + internal static string? CreateTileOverlayLabel( + EditorMapTileOverlayKind kind, + IReadOnlyList? tileScripts = null, + IReadOnlyList? jumpPoints = null + ) => + kind switch + { + EditorMapTileOverlayKind.BlockedTile => "BLOCK", + EditorMapTileOverlayKind.Light => null, + EditorMapTileOverlayKind.Script => CreateScriptTileOverlayLabel(tileScripts), + EditorMapTileOverlayKind.JumpPoint => CreateJumpPointTileOverlayLabel(jumpPoints), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported tile overlay kind."), + }; + + internal static string? CreateTileOverlayDetail( + EditorMapTileOverlayKind kind, + IReadOnlyList? tileScripts = null, + IReadOnlyList? jumpPoints = null + ) => + kind switch + { + EditorMapTileOverlayKind.BlockedTile => "Pathing blocked", + EditorMapTileOverlayKind.Light => null, + EditorMapTileOverlayKind.Script => CreateScriptTileOverlayDetail(tileScripts), + EditorMapTileOverlayKind.JumpPoint => CreateJumpPointTileOverlayDetail(jumpPoints), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported tile overlay kind."), + }; + + private static string CreateScriptTileOverlayLabel(IReadOnlyList? tileScripts) + { + if (tileScripts is null || tileScripts.Count == 0) + return "SCRIPT"; + + var first = tileScripts[0]; + var suffix = tileScripts.Count == 1 ? string.Empty : $"+{tileScripts.Count - 1}"; + return $"SCRIPT {first.ScriptId}{suffix}\nC:0x{first.ScriptCounters:X}"; + } + + private static string CreateScriptTileOverlayDetail(IReadOnlyList? tileScripts) + { + if (tileScripts is null || tileScripts.Count == 0) + return "Tile script"; + + var first = tileScripts[0]; + var detail = + $"Script {first.ScriptId}; node=0x{first.NodeFlags:X8}; flags=0x{first.ScriptFlags:X8}; counters=0x{first.ScriptCounters:X8}"; + return tileScripts.Count == 1 ? detail : $"{detail}; +{tileScripts.Count - 1} more"; + } + + private static string CreateJumpPointTileOverlayLabel(IReadOnlyList? jumpPoints) + { + if (jumpPoints is null || jumpPoints.Count == 0) + return "JUMP"; + + var first = jumpPoints[0]; + var suffix = jumpPoints.Count == 1 ? string.Empty : $"+{jumpPoints.Count - 1}"; + return $"MAP {first.DestinationMapId}{suffix}\n{first.DestinationTileX},{first.DestinationTileY}"; + } + + private static string CreateJumpPointTileOverlayDetail(IReadOnlyList? jumpPoints) + { + if (jumpPoints is null || jumpPoints.Count == 0) + return "Jump point"; + + var first = jumpPoints[0]; + var detail = + $"Jump to map {first.DestinationMapId} at {first.DestinationTileX}, {first.DestinationTileY}; flags=0x{first.Flags:X8}"; + return jumpPoints.Count == 1 ? detail : $"{detail}; +{jumpPoints.Count - 1} more"; + } + private static int FloorDivide(int value, int divisor) { var quotient = value / divisor; diff --git a/src/Editor/ArcNET.Editor/EditorMapPaintableScene.cs b/src/Editor/ArcNET.Editor/EditorMapPaintableScene.cs index 8a80685..c5596e3 100644 --- a/src/Editor/ArcNET.Editor/EditorMapPaintableScene.cs +++ b/src/Editor/ArcNET.Editor/EditorMapPaintableScene.cs @@ -28,6 +28,8 @@ public sealed class EditorMapPaintableSceneItem public SectorLightFlags? LightFlags { get; init; } public EditorMapTileLightDiagnostics? TileLightDiagnostics { get; init; } public EditorMapTileOverlayKind? TileOverlayKind { get; init; } + public string? TileOverlayLabel { get; init; } + public string? TileOverlayDetail { get; init; } public EditorMapPaintableSceneSpriteSourceRect? SpriteSourceRect { get; init; } public EditorMapPaintableSceneSpriteDestinationRect? SpriteDestinationRect { get; init; } public bool IsRoofCovered { get; init; } @@ -550,6 +552,8 @@ private void EnsurePlanCache() var items = new EditorMapPaintableSceneItem[_count]; var destIndex = 0; + Span quadrantBounds = + stackalloc EditorMapPaintableSceneItemBounds[EditorMapPaintableSceneBuilder.FloorTileQuadrantCount]; for (var queueIndex = 0; queueIndex < QueueCount; queueIndex++) { var queueItem = GetQueueItem(queueIndex); @@ -561,13 +565,14 @@ queueItem.Kind is EditorMapRenderQueueItemKind.FloorTile && tile.LightDiagnostics?.HasInterpolationVariance == true ) { - var quadrantBounds = EditorMapPaintableSceneBuilder.BuildFloorTileQuadrantBounds( + EditorMapPaintableSceneBuilder.BuildFloorTileQuadrantBounds( _sceneRender, queueItem, _spriteSource, - _spriteReferenceCache + _spriteReferenceCache, + quadrantBounds ); - planCountByQueueIndex[queueIndex] = checked((byte)quadrantBounds.Length); + planCountByQueueIndex[queueIndex] = EditorMapPaintableSceneBuilder.FloorTileQuadrantCount; for (var quadrantIndex = 0; quadrantIndex < quadrantBounds.Length; quadrantIndex++) { plans[destIndex++] = new EditorMapPaintableSceneItemPlan( @@ -695,6 +700,8 @@ queueItem.Kind is EditorMapRenderQueueItemKind.FloorTile /// public static class EditorMapPaintableSceneBuilder { + internal const int FloorTileQuadrantCount = 4; + private const double CeTerrainBlitWidth = 78d; private const double CeTerrainBlitHeight = 40d; private const double CeTerrainLayoutCenterX = 39d; @@ -741,7 +748,8 @@ public static EditorMapPaintableScene Build( EditorMapPlacementPreview? placementPreview = null, IEditorMapRenderSpriteSource? spriteSource = null, EditorMapRenderSpriteCoverage? existingSpriteCoverage = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + bool includeVirtualTerrainSpriteCoverage = true ) { ArgumentNullException.ThrowIfNull(sceneRender); @@ -753,7 +761,12 @@ public static EditorMapPaintableScene Build( existingSpriteCoverage ?? ( placementPreview is null && sceneRender.Slices.Count > 0 - ? BuildSpriteCoverage(sceneRender, spriteSource, cancellationToken) + ? BuildSpriteCoverage( + sceneRender, + spriteSource, + includeVirtualTerrainSpriteCoverage, + cancellationToken + ) : BuildSpriteCoverage(queue, spriteSource, cancellationToken) ); cancellationToken.ThrowIfCancellationRequested(); @@ -842,26 +855,28 @@ CancellationToken cancellationToken : EditorMapPaintableSceneItemSource.CreateFlat(sceneRender, queue, spriteSource); } - internal static EditorMapPaintableSceneItemBounds[] BuildFloorTileQuadrantBounds( + internal static void BuildFloorTileQuadrantBounds( EditorMapFloorRenderPreview sceneRender, EditorMapRenderQueueItem queueItem, IEditorMapRenderSpriteSource? spriteSource, - EditorMapPaintableSceneSpriteReferenceCache spriteReferenceCache + EditorMapPaintableSceneSpriteReferenceCache spriteReferenceCache, + Span bounds ) { + if (bounds.Length < FloorTileQuadrantCount) + throw new ArgumentException("Floor tile quadrant bounds must provide four slots.", nameof(bounds)); + var layout = GetFloorTileQuadrantLayout(sceneRender, queueItem, spriteSource, spriteReferenceCache); - var bounds = new EditorMapPaintableSceneItemBounds[4]; - for (var quadrantIndex = 0; quadrantIndex < 4; quadrantIndex++) + for (var quadrantIndex = 0; quadrantIndex < FloorTileQuadrantCount; quadrantIndex++) { + var quadrant = layout.GetQuadrant(quadrantIndex); bounds[quadrantIndex] = new EditorMapPaintableSceneItemBounds( - layout.Quadrants[quadrantIndex].Left, - layout.Quadrants[quadrantIndex].Top, - layout.Quadrants[quadrantIndex].Width, - layout.Quadrants[quadrantIndex].Height + quadrant.Left, + quadrant.Top, + quadrant.Width, + quadrant.Height ); } - - return bounds; } internal static EditorMapPaintableSceneItem BuildFloorTileQuadrant( @@ -889,15 +904,10 @@ int quadrantIndex var c7 = g.BottomCenter ?? defaultColor; var c8 = g.BottomRight ?? defaultColor; - uint[][] quadrantColors = - [ - [c0, c1, c4, c3], // Q0: Top-Left - [c1, c2, c5, c4], // Q1: Top-Right - [c3, c4, c7, c6], // Q2: Bottom-Left - [c4, c5, c8, c7], // Q3: Bottom-Right - ]; + Span quadrantColors = stackalloc uint[FloorTileQuadrantCount]; + FillFloorTileQuadrantColors(quadrantIndex, quadrantColors, c0, c1, c2, c3, c4, c5, c6, c7, c8); - var quadrant = layout.Quadrants[quadrantIndex]; + var quadrant = layout.GetQuadrant(quadrantIndex); return new EditorMapPaintableSceneItem { Kind = queueItem.Kind, @@ -911,7 +921,7 @@ int quadrantIndex AnchorY = quadrant.AnchorY, SuggestedOpacity = 1d, SuggestedTintColor = null, - ObjectColorArray = new EditorMapObjectColorArray(quadrantColors[quadrantIndex]), + ObjectColorArray = new EditorMapObjectColorArray(quadrantColors), TileLightDiagnostics = tile.LightDiagnostics, SpriteSourceRect = quadrant.SourceRect, SpriteDestinationRect = quadrant.DestinationRect, @@ -921,6 +931,38 @@ int quadrantIndex }; } + private static void FillFloorTileQuadrantColors( + int quadrantIndex, + Span colors, + uint topLeft, + uint topCenter, + uint topRight, + uint middleLeft, + uint middleCenter, + uint middleRight, + uint bottomLeft, + uint bottomCenter, + uint bottomRight + ) + { + if (colors.Length < FloorTileQuadrantCount) + throw new ArgumentException("Floor tile quadrant colors must provide four slots.", nameof(colors)); + + var (c0, c1, c2, c3) = quadrantIndex switch + { + 0 => (topLeft, topCenter, middleCenter, middleLeft), + 1 => (topCenter, topRight, middleRight, middleCenter), + 2 => (middleLeft, middleCenter, bottomCenter, bottomLeft), + 3 => (middleCenter, middleRight, bottomRight, bottomCenter), + _ => throw new ArgumentOutOfRangeException(nameof(quadrantIndex)), + }; + + colors[0] = c0; + colors[1] = c1; + colors[2] = c2; + colors[3] = c3; + } + private static EditorMapRenderSpriteCoverage BuildSpriteCoverage( IReadOnlyList queue, IEditorMapRenderSpriteSource? spriteSource, @@ -946,6 +988,7 @@ CancellationToken cancellationToken private static EditorMapRenderSpriteCoverage BuildSpriteCoverage( EditorMapFloorRenderPreview sceneRender, IEditorMapRenderSpriteSource? spriteSource, + bool includeVirtualTerrainSpriteCoverage, CancellationToken cancellationToken ) { @@ -991,7 +1034,8 @@ CancellationToken cancellationToken AddSpriteReference(referencedSet, slice.Lights[i].ArtId, EditorMapRenderQueueItemKind.Light); } - AddVirtualTerrainSpriteReferences(referencedSet, sceneRender, cancellationToken); + if (includeVirtualTerrainSpriteCoverage) + AddVirtualTerrainSpriteReferences(referencedSet, sceneRender, cancellationToken); return BuildSpriteCoverage(referencedSet, spriteSource, cancellationToken); } @@ -1304,7 +1348,9 @@ EditorMapRenderQueueItem queueItem spriteReference: null, geometry, overlay.SuggestedOpacity, - overlay.SuggestedTintColor + overlay.SuggestedTintColor, + tileOverlayLabel: overlay.Label, + tileOverlayDetail: overlay.Detail ); } @@ -1587,6 +1633,8 @@ private static EditorMapPaintableSceneItem CreateItem( double? layoutCenterY = null, EditorMapRoofAlphaLerp? roofAlphaLerp = null, bool suppressFallback = false, + string? tileOverlayLabel = null, + string? tileOverlayDetail = null, double sceneScaleX = 1d, double sceneScaleY = 1d ) @@ -1699,6 +1747,8 @@ private static EditorMapPaintableSceneItem CreateItem( LightFlags = lightFlags, TileLightDiagnostics = queueItem.Tile?.LightDiagnostics, TileOverlayKind = queueItem.TileOverlay?.Kind, + TileOverlayLabel = tileOverlayLabel, + TileOverlayDetail = tileOverlayDetail, SpriteSourceRect = layout.SourceRect, SpriteDestinationRect = layout.DestinationRect, IsRoofCovered = queueItem.Object?.IsRoofCovered ?? queueItem.ObjectAuxiliaryItem?.IsRoofCovered ?? false, @@ -2290,9 +2340,9 @@ EditorMapPaintableSceneSpriteReferenceCache spriteReferenceCache var halfFootprintHeight = footprintHeight / 2d; var footprintLeft = tile.CenterX - halfFootprintWidth; var footprintTop = tile.CenterY - halfFootprintHeight; - var quadrants = new FloorTileQuadrantInfo[4]; + Span quadrants = stackalloc FloorTileQuadrantInfo[FloorTileQuadrantCount]; - for (int i = 0; i < 4; i++) + for (var i = 0; i < FloorTileQuadrantCount; i++) { var isRight = i % 2 == 1; var isBottom = i >= 2; @@ -2346,13 +2396,27 @@ sceneRender.ViewMode is EditorMapSceneViewMode.TopDown ); } - return new FloorTileQuadrantLayout(spriteReference, quadrants); + return new FloorTileQuadrantLayout(spriteReference, quadrants[0], quadrants[1], quadrants[2], quadrants[3]); } private readonly record struct FloorTileQuadrantLayout( EditorMapPaintableSceneSpriteReference? SpriteReference, - FloorTileQuadrantInfo[] Quadrants - ); + FloorTileQuadrantInfo TopLeft, + FloorTileQuadrantInfo TopRight, + FloorTileQuadrantInfo BottomLeft, + FloorTileQuadrantInfo BottomRight + ) + { + public FloorTileQuadrantInfo GetQuadrant(int quadrantIndex) => + quadrantIndex switch + { + 0 => TopLeft, + 1 => TopRight, + 2 => BottomLeft, + 3 => BottomRight, + _ => throw new ArgumentOutOfRangeException(nameof(quadrantIndex)), + }; + } private readonly record struct FloorTileQuadrantInfo( double Left, diff --git a/src/Editor/ArcNET.Editor/EditorMapRenderSprite.cs b/src/Editor/ArcNET.Editor/EditorMapRenderSprite.cs index 33a6683..bcd19db 100644 --- a/src/Editor/ArcNET.Editor/EditorMapRenderSprite.cs +++ b/src/Editor/ArcNET.Editor/EditorMapRenderSprite.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using ArcNET.Core.Primitives; using ArcNET.Formats; @@ -803,23 +804,16 @@ int height private static void FlipPixelDataHorizontally(byte[] pixelData, int width, int height) { - const int bytesPerPixel = 4; if (width <= 1 || height <= 0) return; + var pixels = MemoryMarshal.Cast(pixelData.AsSpan()); for (var row = 0; row < height; row++) { - var rowStart = checked(row * width * bytesPerPixel); - for (int left = 0, right = width - 1; left < right; left++, right--) - { - var leftIndex = rowStart + (left * bytesPerPixel); - var rightIndex = rowStart + (right * bytesPerPixel); - for (var channel = 0; channel < bytesPerPixel; channel++) - (pixelData[leftIndex + channel], pixelData[rightIndex + channel]) = ( - pixelData[rightIndex + channel], - pixelData[leftIndex + channel] - ); - } + var rowStart = checked(row * width); + var rowPixels = pixels.Slice(rowStart, width); + for (int left = 0, right = rowPixels.Length - 1; left < right; left++, right--) + (rowPixels[left], rowPixels[right]) = (rowPixels[right], rowPixels[left]); } } diff --git a/src/Editor/ArcNET.Editor/EditorMapSectorScenePreview.cs b/src/Editor/ArcNET.Editor/EditorMapSectorScenePreview.cs index afe47e7..868b4af 100644 --- a/src/Editor/ArcNET.Editor/EditorMapSectorScenePreview.cs +++ b/src/Editor/ArcNET.Editor/EditorMapSectorScenePreview.cs @@ -207,6 +207,24 @@ public HashSet JumpPointTileIndices } } + /// + /// Returns tile-script previews attached to one tile index. + /// + public IReadOnlyList GetTileScriptsAtIndex(int tileIndex) + { + _tileScriptsByIndex ??= CreateTileScriptsByIndex(); + return _tileScriptsByIndex.TryGetValue(tileIndex, out var tileScripts) ? tileScripts : []; + } + + /// + /// Returns jump-point previews attached to one tile index. + /// + public IReadOnlyList GetJumpPointsAtIndex(int tileIndex) + { + _jumpPointsByIndex ??= CreateJumpPointsByIndex(); + return _jumpPointsByIndex.TryGetValue(tileIndex, out var jumpPoints) ? jumpPoints : []; + } + /// /// Precomputed roof-tile row bitmasks. Each bit represents one column. /// A zero row can be skipped entirely during iteration. Returns when the sector has no roofs. @@ -269,6 +287,8 @@ public ulong[]? RoofRowMasks private HashSet? _lightTileIndices; private HashSet? _scriptedTileIndices; private HashSet? _jumpPointTileIndices; + private Dictionary>? _tileScriptsByIndex; + private Dictionary>? _jumpPointsByIndex; private ArtId[]? _uniqueTerrainFloorArtIds; private ArtId[]? _uniqueTerrainRoofArtIds; private ArtId[]? _uniqueTerrainLightArtIds; @@ -311,6 +331,42 @@ public bool IsTileBlocked(int tileX, int tileY) return (BlockMask[tileIndex / 32] & (1u << (tileIndex % 32))) != 0; } + private Dictionary> CreateTileScriptsByIndex() + { + var lookup = new Dictionary>(); + for (var index = 0; index < TileScripts.Count; index++) + { + var tileScript = TileScripts[index]; + if (!lookup.TryGetValue(tileScript.TileIndex, out var existing)) + { + lookup[tileScript.TileIndex] = [tileScript]; + continue; + } + + lookup[tileScript.TileIndex] = [.. existing, tileScript]; + } + + return lookup; + } + + private Dictionary> CreateJumpPointsByIndex() + { + var lookup = new Dictionary>(); + for (var index = 0; index < JumpPoints.Count; index++) + { + var jumpPoint = JumpPoints[index]; + if (!lookup.TryGetValue(jumpPoint.TileIndex, out var existing)) + { + lookup[jumpPoint.TileIndex] = [jumpPoint]; + continue; + } + + lookup[jumpPoint.TileIndex] = [.. existing, jumpPoint]; + } + + return lookup; + } + private static void ValidateTileCoordinates(int tileX, int tileY) { ArgumentOutOfRangeException.ThrowIfNegative(tileX); diff --git a/src/Editor/ArcNET.Editor/EditorMapTileOverlayRenderItem.cs b/src/Editor/ArcNET.Editor/EditorMapTileOverlayRenderItem.cs index b041ab8..d5e06b2 100644 --- a/src/Editor/ArcNET.Editor/EditorMapTileOverlayRenderItem.cs +++ b/src/Editor/ArcNET.Editor/EditorMapTileOverlayRenderItem.cs @@ -56,4 +56,14 @@ public sealed class EditorMapTileOverlayRenderItem /// Suggested overlay tint color for hosts that do not supply their own styling. /// public required uint SuggestedTintColor { get; init; } + + /// + /// Short overlay label suitable for drawing inside the tile marker. + /// + public string? Label { get; init; } + + /// + /// Longer overlay detail suitable for inspector text or tooltips. + /// + public string? Detail { get; init; } } diff --git a/src/Editor/ArcNET.Editor/EditorMapVirtualTerrainPaintableSceneSource.cs b/src/Editor/ArcNET.Editor/EditorMapVirtualTerrainPaintableSceneSource.cs index 6c833c7..d485698 100644 --- a/src/Editor/ArcNET.Editor/EditorMapVirtualTerrainPaintableSceneSource.cs +++ b/src/Editor/ArcNET.Editor/EditorMapVirtualTerrainPaintableSceneSource.cs @@ -284,10 +284,24 @@ List queue AddOverlay(sector.AssetPath, tile, EditorMapTileOverlayKind.Light, tileSortOrdinal, queue); if (sceneRender.IncludeTerrainScriptOverlays && scriptedTileIndices.Contains(tileIndex)) - AddOverlay(sector.AssetPath, tile, EditorMapTileOverlayKind.Script, tileSortOrdinal, queue); + AddOverlay( + sector.AssetPath, + tile, + EditorMapTileOverlayKind.Script, + tileSortOrdinal, + queue, + tileScripts: sector.GetTileScriptsAtIndex(tileIndex) + ); if (sceneRender.IncludeTerrainJumpPointOverlays && jumpPointTileIndices.Contains(tileIndex)) - AddOverlay(sector.AssetPath, tile, EditorMapTileOverlayKind.JumpPoint, tileSortOrdinal, queue); + AddOverlay( + sector.AssetPath, + tile, + EditorMapTileOverlayKind.JumpPoint, + tileSortOrdinal, + queue, + jumpPoints: sector.GetJumpPointsAtIndex(tileIndex) + ); } private static void AddOverlay( @@ -295,7 +309,9 @@ private static void AddOverlay( EditorMapFloorTileRenderItem tile, EditorMapTileOverlayKind kind, double tileSortOrdinal, - List queue + List queue, + IReadOnlyList? tileScripts = null, + IReadOnlyList? jumpPoints = null ) { var overlay = new EditorMapTileOverlayRenderItem @@ -310,6 +326,8 @@ List queue CenterY = tile.CenterY, SuggestedOpacity = EditorMapFloorRenderBuilder.GetTileOverlaySuggestedOpacity(kind), SuggestedTintColor = EditorMapFloorRenderBuilder.GetTileOverlaySuggestedTintColor(kind), + Label = EditorMapFloorRenderBuilder.CreateTileOverlayLabel(kind, tileScripts, jumpPoints), + Detail = EditorMapFloorRenderBuilder.CreateTileOverlayDetail(kind, tileScripts, jumpPoints), }; queue.Add( new EditorMapRenderQueueItem diff --git a/src/Editor/ArcNET.Editor/EditorMapWorldEditScene.cs b/src/Editor/ArcNET.Editor/EditorMapWorldEditScene.cs index ce28739..fb418b4 100644 --- a/src/Editor/ArcNET.Editor/EditorMapWorldEditScene.cs +++ b/src/Editor/ArcNET.Editor/EditorMapWorldEditScene.cs @@ -11,6 +11,12 @@ public sealed class EditorMapWorldEditSceneRequest /// public EditorMapFloorRenderRequest? RenderRequest { get; init; } + /// + /// Optional override for blocked, script, and jump overlay preview visibility. + /// When omitted, the owning map view preview state controls each overlay independently. + /// + public bool? IncludeSpecialTileOverlays { get; init; } + /// /// Optional radius, in sectors around the current camera, whose terrain should be materialized. /// Objects remain projected for every sector so editor object catalogs stay complete. @@ -62,6 +68,18 @@ public sealed class EditorMapWorldEditSceneRequest /// public bool PreloadSceneSprites { get; init; } = true; + /// + /// Indicates whether paintable scene sprite coverage should be resolved while composing this scene. + /// Hosts that defer sprite warm-up to retained/on-demand render caches can disable this for the first shell. + /// + public bool IncludeSpriteCoverage { get; init; } = true; + + /// + /// Indicates whether partial terrain sprite coverage should include non-materialized virtual terrain sectors. + /// Hosts that render virtual terrain through retained chunk sources can disable this to reduce first-shell latency. + /// + public bool IncludeVirtualTerrainSpriteCoverage { get; init; } = true; + /// /// Optional existing render preview to use for delta building. /// diff --git a/src/Editor/ArcNET.Editor/EditorMapWorldEditShell.cs b/src/Editor/ArcNET.Editor/EditorMapWorldEditShell.cs index 4712639..f2e1a9e 100644 --- a/src/Editor/ArcNET.Editor/EditorMapWorldEditShell.cs +++ b/src/Editor/ArcNET.Editor/EditorMapWorldEditShell.cs @@ -23,6 +23,18 @@ public sealed class EditorMapWorldEditShellRequest /// public bool PreloadSceneSprites { get; init; } = true; + /// + /// Indicates whether paintable scene sprite coverage should be resolved while composing this shell. + /// Hosts that defer sprite warm-up to retained/on-demand render caches can disable this for the first shell. + /// + public bool IncludeSpriteCoverage { get; init; } = true; + + /// + /// Indicates whether partial terrain sprite coverage should include non-materialized virtual terrain sectors. + /// Hosts that render virtual terrain through retained chunk sources can disable this to reduce first-shell latency. + /// + public bool IncludeVirtualTerrainSpriteCoverage { get; init; } = true; + /// /// Scene/view preset used for the committed render and tracked placement preview. /// @@ -83,6 +95,11 @@ public sealed class EditorMapWorldEditShellRequest /// public bool IncludeFloorLightTint { get; init; } + /// + /// Indicates whether blocked, script, and jump tile overlays should be emitted. + /// + public bool IncludeSpecialTileOverlays { get; init; } = true; + /// /// Optional explicit ambient-lighting context used when composing the shell render. /// When omitted, the session resolves the current CE light scheme and hour from workspace data. diff --git a/src/Editor/ArcNET.Editor/EditorProjectMapWorldEditShellState.cs b/src/Editor/ArcNET.Editor/EditorProjectMapWorldEditShellState.cs index d5d20a5..380aa3b 100644 --- a/src/Editor/ArcNET.Editor/EditorProjectMapWorldEditShellState.cs +++ b/src/Editor/ArcNET.Editor/EditorProjectMapWorldEditShellState.cs @@ -45,4 +45,9 @@ public sealed class EditorProjectMapWorldEditShellState /// Indicates whether committed floor tiles should expose floor-light tint diagnostics. /// public bool IncludeFloorLightTint { get; init; } + + /// + /// Indicates whether blocked, script, and jump tile overlays should be included by default. + /// + public bool IncludeSpecialTileOverlays { get; init; } = true; } diff --git a/src/Editor/ArcNET.Editor/EditorSessionChangeKind.cs b/src/Editor/ArcNET.Editor/EditorSessionChangeKind.cs index e464b83..fe696a5 100644 --- a/src/Editor/ArcNET.Editor/EditorSessionChangeKind.cs +++ b/src/Editor/ArcNET.Editor/EditorSessionChangeKind.cs @@ -39,4 +39,9 @@ public enum EditorSessionChangeKind /// Pending message-file edits. /// Message = 6, + + /// + /// Pending jump-point file edits. + /// + Jump = 7, } diff --git a/src/Editor/ArcNET.Editor/EditorWorkspaceLoader.cs b/src/Editor/ArcNET.Editor/EditorWorkspaceLoader.cs index 5494f1f..08f0b50 100644 --- a/src/Editor/ArcNET.Editor/EditorWorkspaceLoader.cs +++ b/src/Editor/ArcNET.Editor/EditorWorkspaceLoader.cs @@ -89,8 +89,6 @@ public static async Task LoadAsync( "Content.BuildAssetCatalog", () => EditorAssetCatalogBuilder.CreateForContentDirectory(contentDirectory, gameData) ); - var audioAssets = await audioAssetsTask.ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); LoadedSave? save = null; @@ -107,17 +105,19 @@ public static async Task LoadAsync( .ConfigureAwait(false); } - return BuildWorkspaceWithProgress( - contentDirectory, - options, - gameData, - assets, - audioAssets, - WorkspaceLoadReport.Empty, - save, - progressTracker, - stageRecorder - ); + return await BuildWorkspaceWithProgress( + contentDirectory, + options, + gameData, + assets, + audioAssetsTask, + WorkspaceLoadReport.Empty, + save, + progressTracker, + stageRecorder, + cancellationToken + ) + .ConfigureAwait(false); } private static async Task LoadWithInstallOverlayAsync( @@ -170,18 +170,20 @@ private static async Task LoadWithInstallOverlayAsync( .ConfigureAwait(false); } - return BuildWorkspaceWithProgress( - contentDirectory, - effectiveOptions, - baseGameData, - baseAssets, - baseAudioAssets, - baseLoadReport, - overlaySave, - progressTracker, - stageRecorder, - baseModuleContext - ); + return await BuildWorkspaceWithProgress( + contentDirectory, + effectiveOptions, + baseGameData, + baseAssets, + Task.FromResult(baseAudioAssets), + baseLoadReport, + overlaySave, + progressTracker, + stageRecorder, + cancellationToken, + baseModuleContext + ) + .ConfigureAwait(false); } var contentGameDataTask = stageRecorder.MeasureAsync( @@ -229,18 +231,20 @@ private static async Task LoadWithInstallOverlayAsync( .ConfigureAwait(false); } - return BuildWorkspaceWithProgress( - contentDirectory, - effectiveOptions, - GameDataStore.Overlay(installGameData, contentGameData), - OverlayAssetCatalogs(installAssets, contentAssets), - OverlayAudioAssets(installAudioAssets, contentAudioAssets), - loadReport, - save, - progressTracker, - stageRecorder, - moduleContext - ); + return await BuildWorkspaceWithProgress( + contentDirectory, + effectiveOptions, + GameDataStore.Overlay(installGameData, contentGameData), + OverlayAssetCatalogs(installAssets, contentAssets), + Task.FromResult(OverlayAudioAssets(installAudioAssets, contentAudioAssets)), + loadReport, + save, + progressTracker, + stageRecorder, + cancellationToken, + moduleContext + ) + .ConfigureAwait(false); } /// @@ -295,24 +299,20 @@ public static async Task LoadFromGameInstallAsync( gameDataLoadOptions: CreateGameDataLoadOptions(effectiveOptions) ) ); - var audioAssetsTask = stageRecorder.MeasureAsync( - "AudioLoad.Total", - () => - EditorAudioAssetLoader.LoadFromGameInstallAsync( - effectiveOptions.GameDirectory!, - cancellationToken, - progressTracker.CreateAssetProgress(BaseAudioSegment) - ) - ); - var installContent = await installContentTask.ConfigureAwait(false); var assets = stageRecorder.Measure( "InstallContent.BuildAssetCatalog", () => EditorAssetCatalogBuilder.CreateForInstall(installContent.GameData, installContent.AssetSources) ); var loadReport = installContent.LoadReport; - var audioAssets = await audioAssetsTask.ConfigureAwait(false); - + var audioAssets = stageRecorder.Measure( + "AudioLoad.Total", + () => + EditorAudioAssetLoader.CreateFromAssetSources( + installContent.AssetSources, + progressTracker.CreateAssetProgress(BaseAudioSegment) + ) + ); cancellationToken.ThrowIfCancellationRequested(); LoadedSave? save = null; @@ -329,17 +329,19 @@ public static async Task LoadFromGameInstallAsync( .ConfigureAwait(false); } - return BuildWorkspaceWithProgress( - GetLooseDataDirectory(effectiveOptions.GameDirectory!), - effectiveOptions, - installContent.GameData, - assets, - audioAssets, - loadReport, - save, - progressTracker, - stageRecorder - ); + return await BuildWorkspaceWithProgress( + GetLooseDataDirectory(effectiveOptions.GameDirectory!), + effectiveOptions, + installContent.GameData, + assets, + Task.FromResult(audioAssets), + loadReport, + save, + progressTracker, + stageRecorder, + cancellationToken + ) + .ConfigureAwait(false); } /// @@ -372,24 +374,20 @@ public static async Task LoadFromModuleDirectoryAsync( gameDataLoadOptions: CreateGameDataLoadOptions(effectiveOptions) ) ); - var audioAssetsTask = stageRecorder.MeasureAsync( - "AudioLoad.Total", - () => - EditorAudioAssetLoader.LoadFromModuleDirectoryAsync( - moduleDirectory, - cancellationToken, - progressTracker.CreateAssetProgress(BaseAudioSegment) - ) - ); - var moduleContent = await moduleContentTask.ConfigureAwait(false); var assets = stageRecorder.Measure( "ModuleContent.BuildAssetCatalog", () => EditorAssetCatalogBuilder.CreateForInstall(moduleContent.GameData, moduleContent.AssetSources) ); var loadReport = moduleContent.LoadReport; - var audioAssets = await audioAssetsTask.ConfigureAwait(false); - + var audioAssets = stageRecorder.Measure( + "AudioLoad.Total", + () => + EditorAudioAssetLoader.CreateFromAssetSources( + moduleContent.AssetSources, + progressTracker.CreateAssetProgress(BaseAudioSegment) + ) + ); cancellationToken.ThrowIfCancellationRequested(); LoadedSave? save = null; @@ -406,31 +404,34 @@ public static async Task LoadFromModuleDirectoryAsync( .ConfigureAwait(false); } - return BuildWorkspaceWithProgress( - moduleDirectory, - effectiveOptions, - moduleContent.GameData, - assets, - audioAssets, - loadReport, - save, - progressTracker, - stageRecorder, - moduleContent.ModuleContext - ?? throw new InvalidOperationException("Module loads must return a shared module context.") - ); + return await BuildWorkspaceWithProgress( + moduleDirectory, + effectiveOptions, + moduleContent.GameData, + assets, + Task.FromResult(audioAssets), + loadReport, + save, + progressTracker, + stageRecorder, + cancellationToken, + moduleContent.ModuleContext + ?? throw new InvalidOperationException("Module loads must return a shared module context.") + ) + .ConfigureAwait(false); } - private static EditorWorkspace BuildWorkspaceWithProgress( + private static async Task BuildWorkspaceWithProgress( string contentDirectory, EditorWorkspaceLoadOptions options, GameDataStore gameData, EditorAssetCatalog assets, - EditorAudioAssetLoader.EditorAudioAssetLoadResult audioAssets, + Task audioAssetsTask, WorkspaceLoadReport loadReport, LoadedSave? save, WorkspaceLoadProgressTracker progressTracker, WorkspaceLoadStageRecorder stageRecorder, + CancellationToken cancellationToken, WorkspaceModuleContext? module = null ) { @@ -443,17 +444,30 @@ private static EditorWorkspace BuildWorkspaceWithProgress( "assets", force: true ); - var workspace = BuildWorkspace( + var coreTask = Task.Run( + () => + BuildWorkspaceCore( + options, + gameData, + assets, + save, + stageRecorder, + progressTracker.CreateIndexBuildProgress(IndexSegment) + ), + cancellationToken + ); + var audioAssets = await audioAssetsTask.ConfigureAwait(false); + var core = await coreTask.ConfigureAwait(false); + var workspace = CreateWorkspace( contentDirectory, options, - gameData, assets, audioAssets, loadReport, save, module, - stageRecorder, - progressTracker.CreateIndexBuildProgress(IndexSegment) + core, + stageRecorder ); progressTracker.ReportStageTimings(stageRecorder.Capture()); progressTracker.ReportManual( @@ -468,15 +482,11 @@ private static EditorWorkspace BuildWorkspaceWithProgress( return workspace; } - private static EditorWorkspace BuildWorkspace( - string contentDirectory, + private static WorkspaceBuildCore BuildWorkspaceCore( EditorWorkspaceLoadOptions options, GameDataStore gameData, EditorAssetCatalog assets, - EditorAudioAssetLoader.EditorAudioAssetLoadResult audioAssets, - WorkspaceLoadReport loadReport, LoadedSave? save, - WorkspaceModuleContext? module = null, WorkspaceLoadStageRecorder? stageRecorder = null, IProgress? indexProgress = null ) @@ -497,6 +507,21 @@ private static EditorWorkspace BuildWorkspace( () => EditorAssetIndexBuilder.Create(effectiveGameData, assets, installationType, indexProgress) ); + return new WorkspaceBuildCore(installationType, effectiveGameData, index, validation); + } + + private static EditorWorkspace CreateWorkspace( + string contentDirectory, + EditorWorkspaceLoadOptions options, + EditorAssetCatalog assets, + EditorAudioAssetLoader.EditorAudioAssetLoadResult audioAssets, + WorkspaceLoadReport loadReport, + LoadedSave? save, + WorkspaceModuleContext? module, + WorkspaceBuildCore core, + WorkspaceLoadStageRecorder? stageRecorder = null + ) + { return Measure( stageRecorder, "BuildWorkspace.CreateWorkspace", @@ -506,13 +531,13 @@ private static EditorWorkspace BuildWorkspace( ContentDirectory = contentDirectory, GameDirectory = options.GameDirectory, Module = module, - InstallationType = installationType, - GameData = effectiveGameData, + InstallationType = core.InstallationType, + GameData = core.GameData, Assets = assets, AudioAssets = audioAssets.Catalog, - Index = index, + Index = core.Index, LoadReport = loadReport, - Validation = validation, + Validation = core.Validation, Save = save, SaveFolder = options.SaveFolder, SaveSlotName = options.SaveSlotName, @@ -520,6 +545,13 @@ private static EditorWorkspace BuildWorkspace( ); } + private sealed record WorkspaceBuildCore( + ArcanumInstallationType? InstallationType, + GameDataStore GameData, + EditorAssetIndex Index, + EditorWorkspaceValidationReport Validation + ); + private static async Task<( GameDataStore GameData, EditorAssetCatalog Assets, @@ -551,23 +583,16 @@ private static EditorWorkspace BuildWorkspace( gameDataLoadOptions: CreateGameDataLoadOptions(options) ) ); - var audioAssetsTask = stageRecorder.MeasureAsync( - "AudioLoad.Total", - () => - EditorAudioAssetLoader.LoadFromModuleDirectoryAsync( - moduleDirectory, - cancellationToken, - audioProgress - ) - ); - var moduleContent = await moduleContentTask.ConfigureAwait(false); var assets = stageRecorder.Measure( "ModuleContent.BuildAssetCatalog", () => EditorAssetCatalogBuilder.CreateForInstall(moduleContent.GameData, moduleContent.AssetSources) ); var loadReport = moduleContent.LoadReport; - var audioAssets = await audioAssetsTask.ConfigureAwait(false); + var audioAssets = stageRecorder.Measure( + "AudioLoad.Total", + () => EditorAudioAssetLoader.CreateFromAssetSources(moduleContent.AssetSources, audioProgress) + ); return ( moduleContent.GameData, @@ -591,23 +616,16 @@ private static EditorWorkspace BuildWorkspace( gameDataLoadOptions: CreateGameDataLoadOptions(options) ) ); - var installAudioAssetsTask = stageRecorder.MeasureAsync( - "AudioLoad.Total", - () => - EditorAudioAssetLoader.LoadFromGameInstallAsync( - options.GameDirectory!, - cancellationToken, - audioProgress - ) - ); - var installContent = await installContentTask.ConfigureAwait(false); var installAssets = stageRecorder.Measure( "InstallContent.BuildAssetCatalog", () => EditorAssetCatalogBuilder.CreateForInstall(installContent.GameData, installContent.AssetSources) ); var installLoadReport = installContent.LoadReport; - var installAudioAssets = await installAudioAssetsTask.ConfigureAwait(false); + var installAudioAssets = stageRecorder.Measure( + "AudioLoad.Total", + () => EditorAudioAssetLoader.CreateFromAssetSources(installContent.AssetSources, audioProgress) + ); return (installContent.GameData, installAssets, installLoadReport, installAudioAssets, null); } diff --git a/src/Editor/ArcNET.Editor/EditorWorkspaceSession.cs b/src/Editor/ArcNET.Editor/EditorWorkspaceSession.cs index 2fb660c..2670c7b 100644 --- a/src/Editor/ArcNET.Editor/EditorWorkspaceSession.cs +++ b/src/Editor/ArcNET.Editor/EditorWorkspaceSession.cs @@ -29,6 +29,7 @@ public sealed class EditorWorkspaceSession private readonly Dictionary _pendingProtoAssets = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _pendingMobAssets = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _pendingSectorAssets = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pendingJumpAssets = new(StringComparer.OrdinalIgnoreCase); private readonly Stack _undoSnapshots = new(); private readonly Stack _redoSnapshots = new(); private readonly Stack _undoStagedHistoryScopes = new(); @@ -115,6 +116,7 @@ private set || _pendingProtoAssets.Count > 0 || _pendingMobAssets.Count > 0 || _pendingSectorAssets.Count > 0 + || _pendingJumpAssets.Count > 0 || _saveEditor?.HasPendingChanges == true; /// @@ -3304,7 +3306,10 @@ private async Task CreateMapWorldEditSceneCoreAsync( ) { cancellationToken.ThrowIfCancellationRequested(); - var normalizedMapViewState = NormalizeProjectMapViewState(mapViewState); + var normalizedMapViewState = ApplySpecialTileOverlayPreviewPreference( + NormalizeProjectMapViewState(mapViewState), + request?.IncludeSpecialTileOverlays + ); cancellationToken.ThrowIfCancellationRequested(); var focusedTerrainSectorAssetPaths = request?.FocusedTerrainSectorRadius @@ -3421,12 +3426,17 @@ await spriteSource } reportProgress?.Invoke("Creating paintable scene", 0.56f); + var existingSpriteCoverage = + request?.IncludeSpriteCoverage == false + ? EditorMapRenderSpriteCoverage.Empty + : request?.ExistingSpriteCoverage; var paintableScene = EditorMapPaintableSceneBuilder.Build( sceneRender, placementPreview, spriteSource, - request?.ExistingSpriteCoverage, - cancellationToken + existingSpriteCoverage, + cancellationToken, + request?.IncludeVirtualTerrainSpriteCoverage ?? true ); reportProgress?.Invoke("World-edit scene completed", 0.59f); @@ -3507,6 +3517,37 @@ int sectorRadius return result; } + private static EditorProjectMapViewState ApplySpecialTileOverlayPreviewPreference( + EditorProjectMapViewState mapViewState, + bool? includeSpecialTileOverlays + ) + { + if (includeSpecialTileOverlays is not { } enabled) + return mapViewState; + + var preview = mapViewState.Preview; + return new EditorProjectMapViewState + { + Id = mapViewState.Id, + MapName = mapViewState.MapName, + ViewId = mapViewState.ViewId, + Camera = mapViewState.Camera, + Selection = mapViewState.Selection, + Preview = new EditorProjectMapPreviewState + { + UseScenePreview = preview.UseScenePreview, + OutlineMode = preview.OutlineMode, + ShowObjects = preview.ShowObjects, + ShowRoofs = preview.ShowRoofs, + ShowLights = preview.ShowLights, + ShowBlockedTiles = enabled, + ShowScripts = enabled, + ShowJumpPoints = enabled, + }, + WorldEdit = mapViewState.WorldEdit, + }; + } + /// /// Builds one bundled host-facing world-edit scene from one tracked typed map-view state identifier asynchronously. /// @@ -3685,10 +3726,13 @@ public Task CreateTrackedMapWorldEditShellAsync( IncludeEmptyTiles = renderPreset.IncludeEmptyTiles, IncludeObjects = renderPreset.IncludeObjects, IncludeRoofs = renderPreset.IncludeRoofs, - IncludeBlockedTileOverlays = renderPreset.IncludeBlockedTileOverlays, + IncludeBlockedTileOverlays = + renderPreset.IncludeBlockedTileOverlays && effectiveRequest.IncludeSpecialTileOverlays, IncludeLightOverlays = renderPreset.IncludeLightOverlays, - IncludeScriptOverlays = renderPreset.IncludeScriptOverlays, - IncludeJumpPointOverlays = renderPreset.IncludeJumpPointOverlays, + IncludeScriptOverlays = + renderPreset.IncludeScriptOverlays && effectiveRequest.IncludeSpecialTileOverlays, + IncludeJumpPointOverlays = + renderPreset.IncludeJumpPointOverlays && effectiveRequest.IncludeSpecialTileOverlays, IncludeEditorObjectStateTint = effectiveRequest.IncludeEditorObjectStateTint, IncludeFloorLightTint = effectiveRequest.IncludeFloorLightTint, AmbientLighting = effectiveRequest.AmbientLighting ?? ResolveCurrentAmbientLighting(), @@ -3719,8 +3763,11 @@ public Task CreateTrackedMapWorldEditShellAsync( ArtResolver = effectiveArtResolver, SpriteSource = effectiveSpriteSource, PreloadSceneSprites = effectiveRequest.PreloadSceneSprites, + IncludeSpriteCoverage = effectiveRequest.IncludeSpriteCoverage, + IncludeVirtualTerrainSpriteCoverage = effectiveRequest.IncludeVirtualTerrainSpriteCoverage, FocusedTerrainSectorRadius = effectiveRequest.FocusedTerrainSectorRadius, FocusedObjectSectorRadius = effectiveRequest.FocusedObjectSectorRadius, + IncludeSpecialTileOverlays = effectiveRequest.IncludeSpecialTileOverlays, }, cancellationToken, progressReporter.Report @@ -4477,6 +4524,7 @@ private EditorWorkspace SavePendingChanges( var pendingProtos = CollectProtoChanges(selectedScopeKeys); var pendingMobs = CollectMobChanges(selectedScopeKeys); var pendingSectors = CollectSectorChanges(selectedScopeKeys); + var pendingJumpFiles = CollectJumpChanges(selectedScopeKeys); var saveBackedMobs = CollectSaveBackedMobChanges(pendingMobs); var saveBackedSectors = CollectSaveBackedSectorChanges(pendingSectors); var contentMobs = ExcludeSaveBackedMobChanges(pendingMobs, saveBackedMobs); @@ -4493,6 +4541,7 @@ private EditorWorkspace SavePendingChanges( && pendingProtos.Count == 0 && contentMobs.Count == 0 && contentSectors.Count == 0 + && pendingJumpFiles.Count == 0 && !persistSave ) { @@ -4505,6 +4554,7 @@ private EditorWorkspace SavePendingChanges( PersistProtoChanges(pendingProtos); PersistMobChanges(contentMobs); PersistSectorChanges(contentSectors); + PersistJumpChanges(pendingJumpFiles); if (persistSave) PersistSaveChanges(saveSnapshotToPersist!); @@ -4529,6 +4579,7 @@ public EditorWorkspaceSession DiscardPendingChanges() _pendingProtoAssets.Clear(); _pendingMobAssets.Clear(); _pendingSectorAssets.Clear(); + _pendingJumpAssets.Clear(); ClearDirectAssetDraftHistory(); _saveEditor?.DiscardPendingChanges(); return this; @@ -5470,6 +5521,126 @@ public EditorSessionChange RemoveSectorTileScript(string assetPath, int index) )!; } + /// + /// Stages one tile-script replacement for each unique grouped scene hit. + /// Existing tile scripts on the same sector-local tile are replaced. + /// + public IReadOnlyList SetSectorTileScripts( + IReadOnlyList sectorHitGroups, + int scriptId, + uint nodeFlags = 0, + uint scriptFlags = 0, + uint scriptCounters = 0 + ) + { + ArgumentNullException.ThrowIfNull(sectorHitGroups); + ValidateScriptId(scriptId); + + var changes = new List(); + foreach (var sectorHitGroup in sectorHitGroups) + { + ArgumentNullException.ThrowIfNull(sectorHitGroup); + if (sectorHitGroup.Hits.Count == 0) + continue; + + var normalizedPath = NormalizeAssetPath(sectorHitGroup.SectorAssetPath); + var localTiles = CollectUniqueLocalTiles(sectorHitGroup); + if (localTiles.Count == 0) + continue; + + var change = StageSectorChange( + normalizedPath, + sector => SetSectorTileScriptsCore(sector, localTiles, scriptId, nodeFlags, scriptFlags, scriptCounters) + ); + if (change is not null) + changes.Add(change); + } + + return changes; + } + + /// + /// Stages removal of every tile script on each unique grouped scene-hit tile. + /// + public IReadOnlyList ClearSectorTileScripts( + IReadOnlyList sectorHitGroups + ) + { + ArgumentNullException.ThrowIfNull(sectorHitGroups); + + var changes = new List(); + foreach (var sectorHitGroup in sectorHitGroups) + { + ArgumentNullException.ThrowIfNull(sectorHitGroup); + if (sectorHitGroup.Hits.Count == 0) + continue; + + var normalizedPath = NormalizeAssetPath(sectorHitGroup.SectorAssetPath); + var localTiles = CollectUniqueLocalTiles(sectorHitGroup); + if (localTiles.Count == 0) + continue; + + var change = StageSectorChange(normalizedPath, sector => ClearSectorTileScriptsCore(sector, localTiles)); + if (change is not null) + changes.Add(change); + } + + return changes; + } + + /// + /// Stages one map jump-point replacement for each unique grouped scene hit. + /// Existing jump points with the same source tile are replaced. + /// + public IReadOnlyList SetMapJumpPoints( + string mapName, + IReadOnlyList sectorHitGroups, + int destinationMapId, + int destinationTileX, + int destinationTileY, + uint flags = 0 + ) + { + ArgumentNullException.ThrowIfNull(sectorHitGroups); + ValidateMapName(mapName); + ValidateJumpMapId(destinationMapId); + ValidateJumpTileCoordinate(nameof(destinationTileX), destinationTileX); + ValidateJumpTileCoordinate(nameof(destinationTileY), destinationTileY); + + var mapTiles = CollectUniqueMapTiles(sectorHitGroups); + if (mapTiles.Count == 0) + return []; + + var normalizedPath = ResolveMapJumpAssetPath(mapName); + var change = StageJumpChange( + normalizedPath, + jumpFile => + SetMapJumpPointsCore(jumpFile, mapTiles, destinationMapId, destinationTileX, destinationTileY, flags) + ); + + return change is null ? [] : [change]; + } + + /// + /// Stages removal of every map jump point on each unique grouped scene-hit source tile. + /// + public IReadOnlyList ClearMapJumpPoints( + string mapName, + IReadOnlyList sectorHitGroups + ) + { + ArgumentNullException.ThrowIfNull(sectorHitGroups); + ValidateMapName(mapName); + + var mapTiles = CollectUniqueMapTiles(sectorHitGroups); + if (mapTiles.Count == 0) + return []; + + var normalizedPath = ResolveMapJumpAssetPath(mapName); + var change = StageJumpChange(normalizedPath, jumpFile => ClearMapJumpPointsCore(jumpFile, mapTiles)); + return change is null ? [] : [change]; + } + /// /// Instantiates one placed object from a loaded proto definition and stages it on a sector asset. /// Returns the created object so hosts can capture the generated object ID for later move/remove operations. @@ -6110,6 +6281,7 @@ private static EditorMapWorldEditShellRequest CreateWorldEditShellRequest( IncludeTrackedPlacementPreview = normalizedShellState.IncludeTrackedPlacementPreview, IncludeEditorObjectStateTint = normalizedShellState.IncludeEditorObjectStateTint, IncludeFloorLightTint = normalizedShellState.IncludeFloorLightTint, + IncludeSpecialTileOverlays = normalizedShellState.IncludeSpecialTileOverlays, }; } @@ -6129,6 +6301,7 @@ EditorMapWorldEditShellRequest request IncludeTrackedPlacementPreview = request.IncludeTrackedPlacementPreview, IncludeEditorObjectStateTint = request.IncludeEditorObjectStateTint, IncludeFloorLightTint = request.IncludeFloorLightTint, + IncludeSpecialTileOverlays = request.IncludeSpecialTileOverlays, }; } @@ -8645,6 +8818,7 @@ private PendingWorkspaceState BuildPendingWorkspaceState( var pendingProtos = CollectProtoChanges(selectedScopeKeys); var pendingMobs = CollectMobChanges(selectedScopeKeys); var pendingSectors = CollectSectorChanges(selectedScopeKeys); + var pendingJumpFiles = CollectJumpChanges(selectedScopeKeys); var saveBackedMobs = CollectSaveBackedMobChanges(pendingMobs); var saveBackedSectors = CollectSaveBackedSectorChanges(pendingSectors); var pendingSave = CreatePendingSaveSnapshot(selectedScopeKeys, saveBackedMobs, saveBackedSectors); @@ -8656,6 +8830,7 @@ private PendingWorkspaceState BuildPendingWorkspaceState( && pendingProtos.Count == 0 && pendingMobs.Count == 0 && pendingSectors.Count == 0 + && pendingJumpFiles.Count == 0 ? Workspace.GameData : GameDataStoreSnapshotBuilder.CloneWithAssetReplacements( Workspace.GameData, @@ -8664,7 +8839,8 @@ private PendingWorkspaceState BuildPendingWorkspaceState( updatedDialogs: pendingDialogs, updatedSectors: pendingSectors, updatedProtos: pendingProtos, - updatedMobs: pendingMobs + updatedMobs: pendingMobs, + updatedJumpFiles: pendingJumpFiles ); updatedGameData = EditorWorkspaceSaveComposition.OverlayWorldAssets( updatedGameData, @@ -9003,6 +9179,11 @@ var assetPath in _pendingSectorAssets.Keys.OrderBy( ) ) changes.Add(new EditorSessionChange { Kind = EditorSessionChangeKind.Sector, Target = assetPath }); + + foreach ( + var assetPath in _pendingJumpAssets.Keys.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + ) + changes.Add(new EditorSessionChange { Kind = EditorSessionChangeKind.Jump, Target = assetPath }); } if ( @@ -9749,6 +9930,7 @@ private static EditorProjectMapWorldEditShellState NormalizeProjectMapWorldEditS IncludeTrackedPlacementPreview = shellState?.IncludeTrackedPlacementPreview ?? true, IncludeEditorObjectStateTint = shellState?.IncludeEditorObjectStateTint ?? false, IncludeFloorLightTint = shellState?.IncludeFloorLightTint ?? false, + IncludeSpecialTileOverlays = shellState?.IncludeSpecialTileOverlays ?? true, }; private static EditorProjectMapTerrainToolState NormalizeProjectMapTerrainToolState( @@ -9944,6 +10126,7 @@ is EditorSessionChangeKind.Message or EditorSessionChangeKind.Proto or EditorSessionChangeKind.Mob or EditorSessionChangeKind.Sector + or EditorSessionChangeKind.Jump ) .ToArray(), _ => throw new InvalidOperationException($"Unsupported staged transaction scope {scope.Kind}."), @@ -10239,7 +10422,8 @@ private EditorWorkspaceSessionDirectAssetSnapshot CaptureDirectAssetSnapshot() = new Dictionary(_pendingMessageAssets, StringComparer.OrdinalIgnoreCase), new Dictionary(_pendingProtoAssets, StringComparer.OrdinalIgnoreCase), new Dictionary(_pendingMobAssets, StringComparer.OrdinalIgnoreCase), - new Dictionary(_pendingSectorAssets, StringComparer.OrdinalIgnoreCase) + new Dictionary(_pendingSectorAssets, StringComparer.OrdinalIgnoreCase), + new Dictionary(_pendingJumpAssets, StringComparer.OrdinalIgnoreCase) ); private EditorWorkspaceSessionSnapshot CaptureHistorySnapshot() => new(Workspace, CreateProject()); @@ -10403,6 +10587,7 @@ private void RestoreHistorySnapshot(EditorWorkspaceSessionSnapshot snapshot) _pendingProtoAssets.Clear(); _pendingMobAssets.Clear(); _pendingSectorAssets.Clear(); + _pendingJumpAssets.Clear(); ClearDirectAssetDraftHistory(); _ = RestoreProject(snapshot.Project); RestoreDialogEditorBaselines(); @@ -10416,6 +10601,7 @@ private void RestoreDirectAssetSnapshot(EditorWorkspaceSessionDirectAssetSnapsho RestorePendingAssetDictionary(_pendingProtoAssets, snapshot.Protos); RestorePendingAssetDictionary(_pendingMobAssets, snapshot.Mobs); RestorePendingAssetDictionary(_pendingSectorAssets, snapshot.Sectors); + RestorePendingAssetDictionary(_pendingJumpAssets, snapshot.JumpFiles); } private static void RestorePendingAssetDictionary( @@ -12928,6 +13114,13 @@ private Dictionary CollectSectorChanges( ? new(_pendingSectorAssets, StringComparer.OrdinalIgnoreCase) : new(StringComparer.OrdinalIgnoreCase); + private Dictionary CollectJumpChanges( + IReadOnlySet? selectedScopeKeys = null + ) => + IncludesScope(selectedScopeKeys, EditorSessionStagedHistoryScopeKind.DirectAssets, null) + ? new(_pendingJumpAssets, StringComparer.OrdinalIgnoreCase) + : new(StringComparer.OrdinalIgnoreCase); + private EditorSessionChange? StageProtoChange(string normalizedPath, Func update) => TrackDirectAssetEdit( () => @@ -12973,6 +13166,21 @@ private Dictionary CollectSectorChanges( static change => change is not null ); + private EditorSessionChange? StageJumpChange(string normalizedPath, Func update) => + TrackDirectAssetEdit( + () => + { + var currentJumpFile = GetCurrentJumpAsset(normalizedPath); + var updatedJumpFile = update(currentJumpFile); + if (updatedJumpFile is null) + return null; + + _pendingJumpAssets[normalizedPath] = updatedJumpFile; + return CreateDirectAssetChange(normalizedPath, FileFormat.Jmp); + }, + static change => change is not null + ); + private void PersistDialogChanges(IEnumerable assetPaths) { foreach (var assetPath in assetPaths) @@ -13033,6 +13241,16 @@ private void PersistSectorChanges(IReadOnlyDictionary sectors) } } + private void PersistJumpChanges(IReadOnlyDictionary jumpFiles) + { + foreach (var (assetPath, jumpFile) in jumpFiles) + { + var outputPath = ResolveWorkspaceContentPath(assetPath); + EnsureParentDirectory(outputPath); + JmpFormat.WriteToFile(in jumpFile, outputPath); + } + } + private void PersistSaveChanges(LoadedSave saveSnapshot) { ArgumentNullException.ThrowIfNull(saveSnapshot); @@ -13095,6 +13313,7 @@ private void CommitDirectAssetChanges(IReadOnlySet CollectUniqueLocalTiles(EditorMapSceneSectorHitGroup sectorHitGroup) + { + var tileIndices = new HashSet(); + foreach (var hit in sectorHitGroup.Hits) + { + ArgumentNullException.ThrowIfNull(hit); + ValidateTileCoordinate(nameof(hit.Tile.X), hit.Tile.X); + ValidateTileCoordinate(nameof(hit.Tile.Y), hit.Tile.Y); + tileIndices.Add((hit.Tile.Y * SectorTileAxisLength) + hit.Tile.X); + } + + return [.. tileIndices.Order()]; + } + + private static IReadOnlyList CollectUniqueMapTiles( + IReadOnlyList sectorHitGroups + ) + { + var tileLocations = new HashSet(); + foreach (var sectorHitGroup in sectorHitGroups) + { + ArgumentNullException.ThrowIfNull(sectorHitGroup); + foreach (var hit in sectorHitGroup.Hits) + { + ArgumentNullException.ThrowIfNull(hit); + ValidateJumpTileCoordinate(nameof(hit.MapTileX), hit.MapTileX); + ValidateJumpTileCoordinate(nameof(hit.MapTileY), hit.MapTileY); + tileLocations.Add(PackJumpTileLocation(hit.MapTileX, hit.MapTileY)); + } + } + + return [.. tileLocations.Order()]; + } + + private static Sector? SetSectorTileScriptsCore( + Sector sector, + IReadOnlyList tileIndices, + int scriptId, + uint nodeFlags, + uint scriptFlags, + uint scriptCounters + ) + { + var updatedScripts = sector.TileScripts.ToList(); + var changed = false; + + foreach (var tileIndex in tileIndices) + { + var replacement = CreateTileScript(tileIndex, scriptId, nodeFlags, scriptFlags, scriptCounters); + var existingCount = 0; + var alreadyEqual = false; + foreach (var tileScript in updatedScripts) + { + if (tileScript.TileId != replacement.TileId) + continue; + + existingCount++; + alreadyEqual = tileScript == replacement; + } + + if (existingCount == 1 && alreadyEqual) + continue; + + updatedScripts.RemoveAll(tileScript => tileScript.TileId == replacement.TileId); + updatedScripts.Add(replacement); + changed = true; + } + + return changed ? RebuildSectorTileScripts(sector, updatedScripts) : null; + } + + private static Sector? ClearSectorTileScriptsCore(Sector sector, IReadOnlyList tileIndices) + { + var tileIndexSet = tileIndices.Select(static tileIndex => (uint)tileIndex).ToHashSet(); + if (!sector.TileScripts.Any(tileScript => tileIndexSet.Contains(tileScript.TileId))) + return null; + + var updatedScripts = sector.TileScripts.Where(tileScript => !tileIndexSet.Contains(tileScript.TileId)).ToList(); + return RebuildSectorTileScripts(sector, updatedScripts); + } + + private static Sector RebuildSectorTileScripts(Sector sector, IReadOnlyList updatedScripts) + { + var builder = new SectorBuilder(sector); + for (var index = sector.TileScripts.Count - 1; index >= 0; index--) + builder.RemoveTileScript(index); + + foreach (var tileScript in updatedScripts) + builder.AddTileScript(tileScript); + + return builder.Build(); + } + + private static JmpFile? SetMapJumpPointsCore( + JmpFile jumpFile, + IReadOnlyList sourceLocations, + int destinationMapId, + int destinationTileX, + int destinationTileY, + uint flags + ) + { + var updatedJumps = jumpFile.Jumps.ToList(); + var destinationLoc = PackJumpTileLocation(destinationTileX, destinationTileY); + var changed = false; + + foreach (var sourceLoc in sourceLocations) + { + var replacement = new JumpEntry + { + Flags = flags, + SourceLoc = sourceLoc, + DestinationMapId = destinationMapId, + DestinationLoc = destinationLoc, + }; + var existing = updatedJumps.Where(jump => jump.SourceLoc == sourceLoc).ToArray(); + if (existing.Length == 1 && JumpEntriesEqual(existing[0], replacement)) + continue; + + updatedJumps.RemoveAll(jump => jump.SourceLoc == sourceLoc); + updatedJumps.Add(replacement); + changed = true; + } + + return changed ? new JmpFile { Jumps = [.. updatedJumps] } : null; + } + + private static JmpFile? ClearMapJumpPointsCore(JmpFile jumpFile, IReadOnlyList sourceLocations) + { + var sourceLocationSet = sourceLocations.ToHashSet(); + if (!jumpFile.Jumps.Any(jump => sourceLocationSet.Contains(jump.SourceLoc))) + return null; + + return new JmpFile { Jumps = [.. jumpFile.Jumps.Where(jump => !sourceLocationSet.Contains(jump.SourceLoc))] }; + } + + private string ResolveMapJumpAssetPath(string mapName) + { + ValidateMapName(mapName); + + var defaultPath = NormalizeAssetPath($"maps/{mapName.Trim()}/map.jmp"); + if (_pendingJumpAssets.ContainsKey(defaultPath) || Workspace.FindJumpFile(defaultPath) is not null) + return defaultPath; + + foreach (var asset in Workspace.Index.FindMapAssets(mapName.Trim())) + { + if (asset.Format == FileFormat.Jmp) + return NormalizeAssetPath(asset.AssetPath); + } + + return defaultPath; + } + + private static TileScript CreateTileScript( + int tileIndex, + int scriptId, + uint nodeFlags, + uint scriptFlags, + uint scriptCounters + ) => + new() + { + NodeFlags = nodeFlags, + TileId = (uint)tileIndex, + ScriptFlags = scriptFlags, + ScriptCounters = scriptCounters, + ScriptNum = scriptId, + }; + + private static bool JumpEntriesEqual(JumpEntry left, JumpEntry right) => + left.Flags == right.Flags + && left.SourceLoc == right.SourceLoc + && left.DestinationMapId == right.DestinationMapId + && left.DestinationLoc == right.DestinationLoc; + + private static long PackJumpTileLocation(int tileX, int tileY) => (long)(uint)tileX | ((long)(uint)tileY << 32); + private static void ValidateSectorLight(SectorLight light) { ValidateTileCoordinate("lightTileX", light.TileX); @@ -13882,6 +14278,26 @@ private static void ValidateTileScript(TileScript tileScript) } } + private static void ValidateScriptId(int scriptId) + { + if (scriptId <= 0) + throw new ArgumentOutOfRangeException(nameof(scriptId), scriptId, "Script identifiers must be positive."); + } + + private static void ValidateMapName(string mapName) => ArgumentException.ThrowIfNullOrWhiteSpace(mapName); + + private static void ValidateJumpMapId(int mapId) + { + if (mapId < 0) + throw new ArgumentOutOfRangeException(nameof(mapId), mapId, "Map identifiers cannot be negative."); + } + + private static void ValidateJumpTileCoordinate(string paramName, int value) + { + if (value < 0) + throw new ArgumentOutOfRangeException(paramName, value, "Jump tile coordinates cannot be negative."); + } + private static void ValidateSectorItemIndex( string paramName, int index, @@ -15098,6 +15514,15 @@ private Sector GetCurrentSectorAsset(string assetPath) ?? throw new InvalidOperationException($"No loaded sector asset matched '{normalizedPath}'."); } + private JmpFile GetCurrentJumpAsset(string assetPath) + { + var normalizedPath = NormalizeAssetPath(assetPath); + if (_pendingJumpAssets.TryGetValue(normalizedPath, out var pendingJumpFile)) + return pendingJumpFile; + + return Workspace.FindJumpFile(normalizedPath) ?? new JmpFile { Jumps = [] }; + } + private static MobData? FindWorkspaceMobByObjectId(EditorWorkspace workspace, GameObjectGuid objectId) { ArgumentNullException.ThrowIfNull(workspace); @@ -15155,6 +15580,7 @@ private static EditorSessionChange CreateDirectAssetChange(string assetPath, Fil FileFormat.Proto => EditorSessionChangeKind.Proto, FileFormat.Mob => EditorSessionChangeKind.Mob, FileFormat.Sector => EditorSessionChangeKind.Sector, + FileFormat.Jmp => EditorSessionChangeKind.Jump, _ => throw new InvalidOperationException( $"Direct asset changes do not support assets of format {format}." ), @@ -15168,7 +15594,8 @@ private readonly record struct EditorWorkspaceSessionDirectAssetSnapshot( IReadOnlyDictionary Messages, IReadOnlyDictionary Protos, IReadOnlyDictionary Mobs, - IReadOnlyDictionary Sectors + IReadOnlyDictionary Sectors, + IReadOnlyDictionary JumpFiles ); private readonly record struct EditorWorkspaceSessionHistoryFrame( diff --git a/src/Editor/ArcNET.Editor/EditorWorldAreaCatalogBuilder.cs b/src/Editor/ArcNET.Editor/EditorWorldAreaCatalogBuilder.cs index fa573bc..1abee91 100644 --- a/src/Editor/ArcNET.Editor/EditorWorldAreaCatalogBuilder.cs +++ b/src/Editor/ArcNET.Editor/EditorWorldAreaCatalogBuilder.cs @@ -34,6 +34,7 @@ private static EditorWorldAreaEntry CreateArea(WorkspaceWorldAreaEntry area) => private static EditorWorldAreaMapEntry CreateMapEntry(WorkspaceWorldAreaMapEntry entry) => new() { + MapId = entry.MapId, MapName = entry.MapName, EntryTileX = entry.EntryTileX, EntryTileY = entry.EntryTileY, diff --git a/src/Editor/ArcNET.Editor/EditorWorldAreaMapEntry.cs b/src/Editor/ArcNET.Editor/EditorWorldAreaMapEntry.cs index d5a395a..e901f56 100644 --- a/src/Editor/ArcNET.Editor/EditorWorldAreaMapEntry.cs +++ b/src/Editor/ArcNET.Editor/EditorWorldAreaMapEntry.cs @@ -5,6 +5,11 @@ /// public sealed class EditorWorldAreaMapEntry { + /// + /// One-based map id assigned by Rules/MapList.mes load order. + /// + public int MapId { get; init; } + /// /// Logical map name from Rules/MapList.mes. /// diff --git a/src/Editor/ArcNET.Editor/GameDataStoreSnapshotBuilder.cs b/src/Editor/ArcNET.Editor/GameDataStoreSnapshotBuilder.cs index 6a314d5..2485ab2 100644 --- a/src/Editor/ArcNET.Editor/GameDataStoreSnapshotBuilder.cs +++ b/src/Editor/ArcNET.Editor/GameDataStoreSnapshotBuilder.cs @@ -12,7 +12,8 @@ public static GameDataStore CloneWithAssetReplacements( IReadOnlyDictionary? updatedDialogs = null, IReadOnlyDictionary? updatedSectors = null, IReadOnlyDictionary? updatedProtos = null, - IReadOnlyDictionary? updatedMobs = null + IReadOnlyDictionary? updatedMobs = null, + IReadOnlyDictionary? updatedJumpFiles = null ) { ArgumentNullException.ThrowIfNull(source); @@ -47,6 +48,12 @@ public static GameDataStore CloneWithAssetReplacements( null, static (store, asset, assetPath) => store.AddArt(asset, assetPath) ); + CopyTrackedAssets( + source.JumpFilesBySource, + snapshot, + updatedJumpFiles, + static (store, asset, assetPath) => store.AddJumpFile(asset, assetPath) + ); CopyTrackedScripts(source.ScriptsBySource, snapshot, updatedScripts); CopyTrackedDialogs(source.DialogsBySource, snapshot, updatedDialogs); diff --git a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceJumpPointCatalogBuilderTests.cs b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceJumpPointCatalogBuilderTests.cs new file mode 100644 index 0000000..93401b6 --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceJumpPointCatalogBuilderTests.cs @@ -0,0 +1,51 @@ +using ArcNET.Formats; + +namespace ArcNET.GameData.Workspace.Tests; + +public sealed class WorkspaceJumpPointCatalogBuilderTests +{ + [Test] + public async Task Build_ProjectsLoadedMapJumpPoints(CancellationToken cancellationToken) + { + using var sandbox = TemporaryDirectory.Create(); + var gameDirectory = sandbox.CreateDirectory("Arcanum"); + Directory.CreateDirectory(Path.Combine(gameDirectory, "data", "maps", "map01")); + + var jumpFile = new JmpFile + { + Jumps = + [ + new JumpEntry + { + Flags = 3, + SourceLoc = PackLocation(12, 34), + DestinationMapId = 5022, + DestinationLoc = PackLocation(56, 78), + }, + ], + }; + JmpFormat.WriteToFile(in jumpFile, Path.Combine(gameDirectory, "data", "maps", "map01", "map.jmp")); + + var loadResult = await WorkspaceContentLoader.LoadGameInstallAsync( + gameDirectory, + cancellationToken: cancellationToken + ); + + var entries = WorkspaceJumpPointCatalogBuilder.Build(loadResult.GameData); + + await Assert.That(entries.Count).IsEqualTo(1); + var entry = entries[0]; + await Assert.That(entry.SourceAssetPath).IsEqualTo("maps/map01/map.jmp"); + await Assert.That(entry.SourceMapName).IsEqualTo("map01"); + await Assert.That(entry.Flags).IsEqualTo(3u); + await Assert.That(entry.SourcePackedLocation).IsEqualTo(PackLocation(12, 34)); + await Assert.That(entry.SourceTileX).IsEqualTo(12); + await Assert.That(entry.SourceTileY).IsEqualTo(34); + await Assert.That(entry.DestinationMapId).IsEqualTo(5022); + await Assert.That(entry.DestinationPackedLocation).IsEqualTo(PackLocation(56, 78)); + await Assert.That(entry.DestinationTileX).IsEqualTo(56); + await Assert.That(entry.DestinationTileY).IsEqualTo(78); + } + + private static long PackLocation(int x, int y) => (long)(uint)x | ((long)(uint)y << 32); +} diff --git a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspacePrototypeCatalogBuilderTests.cs b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspacePrototypeCatalogBuilderTests.cs index 8e79bde..1aff539 100644 --- a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspacePrototypeCatalogBuilderTests.cs +++ b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspacePrototypeCatalogBuilderTests.cs @@ -20,7 +20,8 @@ public async Task Build_UsesInstallationAdjustedNameAndMessageBackedDescription( ObjectType.Npc, 21, ObjectPropertyFactory.ForInt32(ObjectField.Description, 10), - ObjectPropertyFactory.ForInt32(ObjectField.CurrentAid, unchecked((int)0x00000123u)) + ObjectPropertyFactory.ForInt32(ObjectField.CurrentAid, unchecked((int)0x00000123u)), + ObjectPropertyFactory.ForInt32(ObjectField.DestroyedAid, unchecked((int)0x00000456u)) ); ProtoFormat.WriteToFile( in prototype, @@ -53,6 +54,45 @@ public async Task Build_UsesInstallationAdjustedNameAndMessageBackedDescription( await Assert.That(entries[0].Description).IsEqualTo("Patrols the bridge."); await Assert.That(entries[0].PaletteGroup).IsEqualTo("critters"); await Assert.That(entries[0].CurrentArtId).IsEqualTo(new ArtId(0x00000123u)); + await Assert.That(entries[0].DestroyedArtId).IsEqualTo(new ArtId(0x00000456u)); await Assert.That(entries[0].ArtAssetPath).IsNull(); + await Assert.That(entries[0].PortalFlags).IsNull(); + await Assert.That(entries[0].ContainerFlags).IsNull(); + await Assert.That(entries[0].SceneryFlags).IsNull(); + } + + [Test] + public async Task Build_ProjectsTypeSpecificPrototypeFlags(CancellationToken cancellationToken) + { + using var sandbox = TemporaryDirectory.Create(); + var gameDirectory = sandbox.CreateDirectory("Arcanum"); + Directory.CreateDirectory(Path.Combine(gameDirectory, "data", "proto", "portal")); + + var prototype = WorkspaceCatalogTestData.MakePrototype( + ObjectType.Portal, + 1001, + ObjectPropertyFactory.ForInt32(ObjectField.PortalFlags, unchecked((int)PortalFlags.Locked)), + ObjectPropertyFactory.ForInt32(ObjectField.PortalLockDifficulty, 45), + ObjectPropertyFactory.ForInt32(ObjectField.PortalKeyId, 17) + ); + ProtoFormat.WriteToFile( + in prototype, + Path.Combine(gameDirectory, "data", "proto", "portal", "001001 - Door.pro") + ); + + var loadResult = await WorkspaceContentLoader.LoadGameInstallAsync( + gameDirectory, + cancellationToken: cancellationToken + ); + + var entries = WorkspacePrototypeCatalogBuilder.Build(loadResult.GameData); + + await Assert.That(entries.Count).IsEqualTo(1); + await Assert.That(entries[0].ObjectType).IsEqualTo(ObjectType.Portal); + await Assert.That(entries[0].PortalFlags).IsEqualTo(PortalFlags.Locked); + await Assert.That(entries[0].PortalLockDifficulty).IsEqualTo(45); + await Assert.That(entries[0].PortalKeyId).IsEqualTo(17); + await Assert.That(entries[0].ContainerFlags).IsNull(); + await Assert.That(entries[0].SceneryFlags).IsNull(); } } diff --git a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceSpellCatalogBuilderTests.cs b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceSpellCatalogBuilderTests.cs new file mode 100644 index 0000000..a35b321 --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceSpellCatalogBuilderTests.cs @@ -0,0 +1,29 @@ +namespace ArcNET.GameData.Workspace.Tests; + +public sealed class WorkspaceSpellCatalogBuilderTests +{ + [Test] + public async Task Build_UsesOriginalSpellEnumOrder(CancellationToken cancellationToken) + { + using var sandbox = TemporaryDirectory.Create(); + var gameDirectory = sandbox.CreateDirectory("Arcanum"); + Directory.CreateDirectory(Path.Combine(gameDirectory, "data")); + + var loadResult = await WorkspaceContentLoader.LoadGameInstallAsync( + gameDirectory, + cancellationToken: cancellationToken + ); + + var entries = WorkspaceSpellCatalogBuilder.Build(loadResult.GameData); + + await Assert.That(entries.Count).IsEqualTo(80); + await Assert.That(entries[4].Name).IsEqualTo("Teleportation"); + await Assert.That(entries[16].Name).IsEqualTo("Stone Throw"); + await Assert.That(entries[21].Name).IsEqualTo("Wall Of Fire"); + await Assert.That(entries[30].Name).IsEqualTo("Shield Of Protection"); + await Assert.That(entries[55].Name).IsEqualTo("Harm"); + await Assert.That(entries[72].Name).IsEqualTo("Guardian Ogre"); + await Assert.That(entries[72].CollegeName).IsEqualTo("Summoning"); + await Assert.That(entries[72].Level).IsEqualTo(3); + } +} diff --git a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceStaticObjectCatalogBuilderTests.cs b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceStaticObjectCatalogBuilderTests.cs index 3203f89..b08b569 100644 --- a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceStaticObjectCatalogBuilderTests.cs +++ b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceStaticObjectCatalogBuilderTests.cs @@ -1,3 +1,4 @@ +using ArcNET.Core.Primitives; using ArcNET.Formats; using ArcNET.GameObjects; @@ -67,4 +68,60 @@ await Assert await Assert.That(entries.Any(static entry => entry.SourceAssetPath == "maps/map01/mobile/lamp.mob")).IsTrue(); await Assert.That(entries.Any(static entry => entry.SourceAssetPath == "maps/map01/0.sec")).IsTrue(); } + + [Test] + public async Task Build_ProjectsPlacedObjectFlagsWithPrototypeFallback(CancellationToken cancellationToken) + { + using var sandbox = TemporaryDirectory.Create(); + var gameDirectory = sandbox.CreateDirectory("Arcanum"); + Directory.CreateDirectory(Path.Combine(gameDirectory, "data", "proto", "portal")); + Directory.CreateDirectory(Path.Combine(gameDirectory, "data", "maps", "map01", "mobile")); + + var prototypeArtId = new ArtId(0x30000000u); + var placedArtId = prototypeArtId.WithFrameIndex(3); + var prototype = WorkspaceCatalogTestData.MakePrototype( + ObjectType.Portal, + 3001, + ObjectPropertyFactory.ForInt32(ObjectField.CurrentAid, unchecked((int)prototypeArtId.Value)), + ObjectPropertyFactory.ForInt32(ObjectField.PortalFlags, unchecked((int)PortalFlags.Locked)), + ObjectPropertyFactory.ForInt32(ObjectField.PortalLockDifficulty, 35), + ObjectPropertyFactory.ForInt32(ObjectField.PortalKeyId, 11) + ); + ProtoFormat.WriteToFile( + in prototype, + Path.Combine(gameDirectory, "data", "proto", "portal", "003001 - Door.pro") + ); + + var mobileObject = WorkspaceCatalogTestData.MakeMob( + ObjectType.Portal, + 3001, + Guid.Parse("11111111-1111-1111-1111-111111111111"), + tileX: 5, + tileY: 6, + ObjectPropertyFactory.ForInt32(ObjectField.CurrentAid, unchecked((int)placedArtId.Value)), + ObjectPropertyFactory.ForInt32(ObjectField.PortalFlags, unchecked((int)PortalFlags.Jammed)) + ); + MobFormat.WriteToFile( + in mobileObject, + Path.Combine(gameDirectory, "data", "maps", "map01", "mobile", "door.mob") + ); + + var loadResult = await WorkspaceContentLoader.LoadGameInstallAsync( + gameDirectory, + cancellationToken: cancellationToken + ); + var prototypeEntries = WorkspacePrototypeCatalogBuilder.Build(loadResult.GameData); + + var entries = WorkspaceStaticObjectCatalogBuilder.Build(loadResult.GameData, prototypeEntries); + + await Assert.That(entries.Count).IsEqualTo(1); + await Assert.That(entries[0].ObjectType).IsEqualTo(ObjectType.Portal); + await Assert.That(entries[0].CurrentArtId).IsEqualTo(placedArtId); + await Assert.That(entries[0].CurrentArtId?.FrameIndex).IsEqualTo(3); + await Assert.That(entries[0].PortalFlags).IsEqualTo(PortalFlags.Jammed); + await Assert.That(entries[0].PortalLockDifficulty).IsEqualTo(35); + await Assert.That(entries[0].PortalKeyId).IsEqualTo(11); + await Assert.That(entries[0].ContainerFlags).IsNull(); + await Assert.That(entries[0].SceneryFlags).IsNull(); + } } diff --git a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceWorldAreaCatalogBuilderTests.cs b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceWorldAreaCatalogBuilderTests.cs index 104cc3b..3babb13 100644 --- a/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceWorldAreaCatalogBuilderTests.cs +++ b/src/GameData/ArcNET.GameData.Workspace.Tests/WorkspaceWorldAreaCatalogBuilderTests.cs @@ -60,7 +60,9 @@ public async Task Build_JoinsGameAreaTownMapAndMapListMetadata() await Assert.That(tarant.IsWorldMapVisible).IsTrue(); await Assert.That(tarant.MapEntries.Count).IsEqualTo(2); await Assert.That(tarant.MapEntries[0].MapName).IsEqualTo("Tarant Sewers-01"); + await Assert.That(tarant.MapEntries[0].MapId).IsEqualTo(2); await Assert.That(tarant.MapEntries[1].MapName).IsEqualTo("Tarant-City Hall Downstairs"); + await Assert.That(tarant.MapEntries[1].MapId).IsEqualTo(3); var vendigroth = catalog.FindArea(22); await Assert.That(vendigroth).IsNotNull(); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceAssetSource.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceAssetSource.cs index 3eef8e0..43beb39 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceAssetSource.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceAssetSource.cs @@ -10,4 +10,6 @@ public sealed class WorkspaceAssetSource public required string SourcePath { get; init; } public string? SourceEntryPath { get; init; } + + public int? ByteLength { get; init; } } diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceContentLoader.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceContentLoader.cs index 2029181..43f2a75 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceContentLoader.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceContentLoader.cs @@ -354,15 +354,24 @@ CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); var format = ResolveSupportedFormat(entry.Path); + var assetPath = NormalizeVirtualPath(entry.Path); if (!IsSupportedFormat(format)) + { + AddAudioAssetSource( + overlay, + assetPath, + WorkspaceAssetSourceKind.DatArchive, + archivePath, + assetPath, + entry.UncompressedSize + ); continue; + } - var assetPath = NormalizeVirtualPath(entry.Path); - var archiveEntryPath = entry.Path; overlay.LoadEntries[assetPath] = new GameDataLoadEntry( format, assetPath, - ct => Task.FromResult(LoadArchiveEntry(archive, archiveEntryPath, ct)), + ct => Task.FromResult(LoadArchiveEntry(archive, entry, ct)), entry.UncompressedSize ); overlay.AssetSources[assetPath] = new WorkspaceAssetSource @@ -370,6 +379,7 @@ CancellationToken cancellationToken SourceKind = WorkspaceAssetSourceKind.DatArchive, SourcePath = archivePath, SourceEntryPath = assetPath, + ByteLength = entry.UncompressedSize, }; } @@ -407,11 +417,24 @@ CancellationToken cancellationToken continue; var format = ResolveSupportedFormat(filePath); + var relativePath = Path.GetRelativePath(rootDirectory, filePath); + var assetPath = NormalizeVirtualPath(relativePath); if (!IsSupportedFormat(format)) + { + if (IsAudioAsset(assetPath)) + { + AddAudioAssetSource( + overlay, + assetPath, + WorkspaceAssetSourceKind.LooseFile, + filePath, + sourceEntryPath: null, + checked((int)new FileInfo(filePath).Length) + ); + } continue; + } - var relativePath = Path.GetRelativePath(rootDirectory, filePath); - var assetPath = NormalizeVirtualPath(relativePath); overlay.LoadEntries[assetPath] = GameDataLoadEntry.FromFile(format, assetPath, filePath); overlay.AssetSources[assetPath] = new WorkspaceAssetSource { @@ -424,6 +447,27 @@ CancellationToken cancellationToken return overlay; } + private static void AddAudioAssetSource( + InstallOverlaySource overlay, + string assetPath, + WorkspaceAssetSourceKind sourceKind, + string sourcePath, + string? sourceEntryPath, + int byteLength + ) + { + if (!IsAudioAsset(assetPath)) + return; + + overlay.AssetSources[assetPath] = new WorkspaceAssetSource + { + SourceKind = sourceKind, + SourcePath = sourcePath, + SourceEntryPath = sourceEntryPath, + ByteLength = byteLength, + }; + } + private static bool IsSaveFilePath(string filePath) => filePath.Contains( $"{Path.DirectorySeparatorChar}Save{Path.DirectorySeparatorChar}", @@ -441,6 +485,8 @@ private static bool IsSupportedFormat(FileFormat format) return false; } + private static bool IsAudioAsset(string path) => path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase); + private static FileFormat ResolveSupportedFormat(string path) { var format = FileFormatExtensions.FromPath(path); @@ -524,12 +570,12 @@ private static void ApplyOverlay(InstallFileSet files, InstallOverlaySource over private static ReadOnlyMemory LoadArchiveEntry( DatArchive archive, - string archiveEntryPath, + ArchiveEntry archiveEntry, CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); - return archive.GetEntryData(archiveEntryPath); + return archive.GetEntryData(archiveEntry); } private static string NormalizeVirtualPath(string path) => ArcNET.Core.VirtualPath.Normalize(path); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogBuilder.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogBuilder.cs new file mode 100644 index 0000000..41297fa --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogBuilder.cs @@ -0,0 +1,101 @@ +using System.Globalization; + +namespace ArcNET.GameData.Workspace; + +/// +/// Projects loaded map jump points into workspace catalog entries. +/// +public static class WorkspaceJumpPointCatalogBuilder +{ + public static IReadOnlyList Build(GameDataStore gameData) + { + ArgumentNullException.ThrowIfNull(gameData); + + List entries = []; + + foreach (var (assetPath, jumpFiles) in gameData.JumpFilesBySource) + { + if (!TryResolveSourceMapName(assetPath, out var sourceMapName)) + { + continue; + } + + foreach (var jumpFile in jumpFiles) + { + foreach (var jump in jumpFile.Jumps) + { + entries.Add( + new WorkspaceJumpPointCatalogEntry( + assetPath, + sourceMapName, + jump.Flags, + jump.SourceLoc, + jump.SourceX, + jump.SourceY, + jump.DestinationMapId, + jump.DestinationLoc, + jump.DestX, + jump.DestY, + FormatSummary( + sourceMapName, + jump.SourceX, + jump.SourceY, + jump.DestinationMapId, + jump.DestX, + jump.DestY + ) + ) + ); + } + } + } + + return + [ + .. entries + .OrderBy(static entry => entry.SourceMapName, StringComparer.OrdinalIgnoreCase) + .ThenBy(static entry => entry.SourceTileY) + .ThenBy(static entry => entry.SourceTileX) + .ThenBy(static entry => entry.DestinationMapId) + .ThenBy(static entry => entry.DestinationTileY) + .ThenBy(static entry => entry.DestinationTileX), + ]; + } + + private static bool TryResolveSourceMapName(string sourceAssetPath, out string sourceMapName) + { + sourceMapName = string.Empty; + if (string.IsNullOrWhiteSpace(sourceAssetPath)) + { + return false; + } + + var segments = sourceAssetPath + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var index = 0; index < segments.Length - 1; index++) + { + if (!string.Equals(segments[index], "maps", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + sourceMapName = segments[index + 1]; + return sourceMapName.Length > 0; + } + + return false; + } + + private static string FormatSummary( + string sourceMapName, + int sourceX, + int sourceY, + int destinationMapId, + int destinationX, + int destinationY + ) => + FormattableString.Invariant( + $"{sourceMapName} ({sourceX}, {sourceY}) -> world map {destinationMapId} ({destinationX}, {destinationY})" + ); +} diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogEntry.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogEntry.cs new file mode 100644 index 0000000..b3c0b7e --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceJumpPointCatalogEntry.cs @@ -0,0 +1,18 @@ +namespace ArcNET.GameData.Workspace; + +/// +/// One map jump point projected from a loaded map.jmp file. +/// +public sealed record class WorkspaceJumpPointCatalogEntry( + string SourceAssetPath, + string SourceMapName, + uint Flags, + long SourcePackedLocation, + int SourceTileX, + int SourceTileY, + int DestinationMapId, + long DestinationPackedLocation, + int DestinationTileX, + int DestinationTileY, + string SummaryText +); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogBuilder.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogBuilder.cs index c967845..56b7a74 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogBuilder.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogBuilder.cs @@ -45,7 +45,15 @@ public static IReadOnlyList Build( ResolveMessageText(TryGetInt32Property(proto, ObjectField.Description)), GetObjectPaletteGroup(assetPath), ResolveCurrentArtId(proto), - null + ResolveDestroyedArtId(proto), + null, + ResolvePortalFlags(proto), + ResolveContainerFlags(proto), + ResolveSceneryFlags(proto), + ResolvePortalInt32(proto, ObjectField.PortalLockDifficulty), + ResolvePortalInt32(proto, ObjectField.PortalKeyId), + ResolveContainerInt32(proto, ObjectField.ContainerLockDifficulty), + ResolveContainerInt32(proto, ObjectField.ContainerKeyId) ) ); } @@ -171,25 +179,66 @@ private static IEnumerable EnumerateProtoDisplayNameKeys( private static ArtId? ResolveCurrentArtId(ProtoData proto) { var currentArtId = TryGetArtId(proto, ObjectField.CurrentAid); - if (currentArtId is { Value: 0u }) + if (!IsValidArtId(currentArtId)) currentArtId = null; if (!currentArtId.HasValue) { currentArtId = TryGetArtId(proto, ObjectField.Aid); - if (currentArtId is { Value: 0u }) + if (!IsValidArtId(currentArtId)) currentArtId = null; } return currentArtId; } + private static ArtId? ResolveDestroyedArtId(ProtoData proto) + { + var destroyedArtId = TryGetArtId(proto, ObjectField.DestroyedAid); + return IsValidArtId(destroyedArtId) ? destroyedArtId : null; + } + private static ArtId? TryGetArtId(ProtoData proto, ObjectField field) { var value = TryGetInt32Property(proto, field); return value.HasValue ? new ArtId(unchecked((uint)value.Value)) : null; } + private static PortalFlags? ResolvePortalFlags(ProtoData proto) + { + if (proto.Header.GameObjectType is not ObjectType.Portal) + return null; + + var value = TryGetInt32Property(proto, ObjectField.PortalFlags); + return value.HasValue ? unchecked((PortalFlags)(uint)value.Value) : null; + } + + private static ContainerFlags? ResolveContainerFlags(ProtoData proto) + { + if (proto.Header.GameObjectType is not ObjectType.Container) + return null; + + var value = TryGetInt32Property(proto, ObjectField.ContainerFlags); + return value.HasValue ? unchecked((ContainerFlags)(uint)value.Value) : null; + } + + private static SceneryFlags? ResolveSceneryFlags(ProtoData proto) + { + if (proto.Header.GameObjectType is not ObjectType.Scenery) + return null; + + var value = TryGetInt32Property(proto, ObjectField.SceneryFlags); + return value.HasValue ? unchecked((SceneryFlags)(uint)value.Value) : null; + } + + private static int? ResolvePortalInt32(ProtoData proto, ObjectField field) => + proto.Header.GameObjectType is ObjectType.Portal ? TryGetInt32Property(proto, field) : null; + + private static int? ResolveContainerInt32(ProtoData proto, ObjectField field) => + proto.Header.GameObjectType is ObjectType.Container ? TryGetInt32Property(proto, field) : null; + + private static bool IsValidArtId(ArtId? artId) => artId is { Value: not 0u and not uint.MaxValue }; + private static string? GetObjectPaletteGroup(string assetPath) { var normalizedPath = WorkspaceMessageLookup.NormalizeAssetPath(assetPath); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogEntry.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogEntry.cs index 8e122ba..7f07695 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogEntry.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspacePrototypeCatalogEntry.cs @@ -14,5 +14,13 @@ public sealed record class WorkspacePrototypeCatalogEntry( string? Description, string? PaletteGroup, ArtId? CurrentArtId, - string? ArtAssetPath + ArtId? DestroyedArtId, + string? ArtAssetPath, + PortalFlags? PortalFlags = null, + ContainerFlags? ContainerFlags = null, + SceneryFlags? SceneryFlags = null, + int? PortalLockDifficulty = null, + int? PortalKeyId = null, + int? ContainerLockDifficulty = null, + int? ContainerKeyId = null ); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogBuilder.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogBuilder.cs new file mode 100644 index 0000000..bd4e649 --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogBuilder.cs @@ -0,0 +1,136 @@ +using System.Globalization; + +namespace ArcNET.GameData.Workspace; + +/// +/// Projects canonical Arcanum spell metadata in the same numeric order used by CE and the original executable. +/// +public static class WorkspaceSpellCatalogBuilder +{ + private const int SpellMaxLevel = 5; + + public static IReadOnlyList Build(GameDataStore gameData) + { + ArgumentNullException.ThrowIfNull(gameData); + + var textInfo = CultureInfo.InvariantCulture.TextInfo; + var entries = new WorkspaceSpellCatalogEntry[s_spellTokens.Length]; + for (var spellId = 0; spellId < s_spellTokens.Length; spellId++) + { + var collegeId = spellId / SpellMaxLevel; + entries[spellId] = new WorkspaceSpellCatalogEntry( + spellId, + textInfo.ToTitleCase(s_spellTokens[spellId].Replace('_', ' ').ToLowerInvariant()), + collegeId, + s_spellCollegeNames[collegeId], + (spellId % SpellMaxLevel) + 1 + ); + } + + return entries; + } + + private static readonly string[] s_spellTokens = + [ + "DISARM", + "UNLOCKING_CANTRIP", + "UNSEEN_FORCE", + "SPATIAL_DISTORTION", + "TELEPORTATION", + "SENSE_ALIGNMENT", + "SEE_CONTENTS", + "READ_AURA", + "SENSE_HIDDEN", + "DIVINE_MAGICK", + "VITALITY_OF_AIR", + "POISON_VAPOURS", + "CALL_WINDS", + "BODY_OF_AIR", + "CALL_AIR_ELEMENTAL", + "STRENGTH_OF_EARTH", + "STONE_THROW", + "WALL_OF_STONE", + "BODY_OF_STONE", + "CALL_EARTH_ELEMENTAL", + "AGILITY_OF_FIRE", + "WALL_OF_FIRE", + "FIREFLASH", + "BODY_OF_FIRE", + "CALL_FIRE_ELEMENTAL", + "PURITY_OF_WATER", + "CALL_FOG", + "SQUALL_OF_ICE", + "BODY_OF_WATER", + "CALL_WATER_ELEMENTAL", + "SHIELD_OF_PROTECTION", + "JOLT", + "WALL_OF_FORCE", + "BOLT_OF_LIGHTNING", + "DISINTEGRATE", + "CHARM", + "STUN", + "DRAIN_WILL", + "NIGHTMARE", + "DOMINATE_WILL", + "RESIST_MAGICK", + "DISPERSE_MAGICK", + "DWEOMER_SHIELD", + "BONDS_OF_MAGICK", + "REFLECTION_SHIELD", + "HARDENED_HANDS", + "WEAKEN", + "SHRINK", + "FLESH_TO_STONE", + "POLYMORPH", + "CHARM_BEAST", + "ENTANGLE", + "CONTROL_BEAST", + "SUCCOUR_BEAST", + "REGENERATE", + "HARM", + "CONJURE_SPIRIT", + "SUMMON_UNDEAD", + "CREATE_UNDEAD", + "QUENCH_LIFE", + "MINOR_HEALING", + "HALT_POISON", + "MAJOR_HEALING", + "SANCTUARY", + "RESURRECT", + "ILLUMINATE", + "FLASH", + "BLUR_SIGHT", + "PHANTASMAL_FIEND", + "INVISIBILITY", + "PLAGUE_OF_INSECTS", + "ORCISH_CHAMPION", + "GUARDIAN_OGRE", + "HELLGATE", + "FAMILIAR", + "MAGELOCK", + "CONGEAL_TIME", + "HASTEN", + "STASIS", + "TEMPUS_FUGIT", + ]; + + private static readonly string[] s_spellCollegeNames = + [ + "Conveyance", + "Divination", + "Air", + "Earth", + "Fire", + "Water", + "Force", + "Mental", + "Meta", + "Morph", + "Nature", + "Necromantic Black", + "Necromantic White", + "Phantasm", + "Summoning", + "Temporal", + ]; +} diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogEntry.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogEntry.cs new file mode 100644 index 0000000..4e2f901 --- /dev/null +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceSpellCatalogEntry.cs @@ -0,0 +1,6 @@ +namespace ArcNET.GameData.Workspace; + +/// +/// Canonical spell metadata projected from the original Arcanum spell enum. +/// +public sealed record WorkspaceSpellCatalogEntry(int SpellId, string Name, int CollegeId, string CollegeName, int Level); diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogBuilder.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogBuilder.cs index df71d9b..80a9b9e 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogBuilder.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogBuilder.cs @@ -86,9 +86,26 @@ IReadOnlyDictionary prototypesByNumber objectGuidText, protoNumber, prototypeText, + ResolvePlacedOrPrototypeArtId(mob, ObjectField.CurrentAid, prototypeEntry?.CurrentArtId), + ResolvePlacedOrPrototypeArtId(mob, ObjectField.DestroyedAid, prototypeEntry?.DestroyedArtId), sourceAssetPath, TryFormatObjectLocation(mob), - $"{sourceKindText} - {sourceAssetPath}" + $"{sourceKindText} - {sourceAssetPath}", + ResolvePlacedOrPrototypePortalFlags(mob, prototypeEntry), + ResolvePlacedOrPrototypeContainerFlags(mob, prototypeEntry), + ResolvePlacedOrPrototypeSceneryFlags(mob, prototypeEntry), + ResolvePlacedOrPrototypePortalInt32( + mob, + ObjectField.PortalLockDifficulty, + prototypeEntry?.PortalLockDifficulty + ), + ResolvePlacedOrPrototypePortalInt32(mob, ObjectField.PortalKeyId, prototypeEntry?.PortalKeyId), + ResolvePlacedOrPrototypeContainerInt32( + mob, + ObjectField.ContainerLockDifficulty, + prototypeEntry?.ContainerLockDifficulty + ), + ResolvePlacedOrPrototypeContainerInt32(mob, ObjectField.ContainerKeyId, prototypeEntry?.ContainerKeyId) ); } @@ -102,4 +119,77 @@ private static string TryFormatObjectLocation(MobData mob) ? $"Tile ({tile.X.ToString(CultureInfo.InvariantCulture)}, {tile.Y.ToString(CultureInfo.InvariantCulture)})" : "Tile unavailable"; } + + private static ArtId? ResolvePlacedOrPrototypeArtId(MobData mob, ObjectField field, ArtId? prototypeArtId) + { + var property = mob.GetProperty(field); + if (property is not null) + { + var artId = new ArtId(unchecked((uint)property.GetInt32())); + if (IsValidArtId(artId)) + return artId; + } + + return IsValidArtId(prototypeArtId) ? prototypeArtId : null; + } + + private static PortalFlags? ResolvePlacedOrPrototypePortalFlags( + MobData mob, + WorkspacePrototypeCatalogEntry? prototypeEntry + ) + { + if (mob.Header.GameObjectType is not ObjectType.Portal) + return null; + + var value = TryGetInt32Property(mob, ObjectField.PortalFlags); + return value.HasValue ? unchecked((PortalFlags)(uint)value.Value) : prototypeEntry?.PortalFlags; + } + + private static ContainerFlags? ResolvePlacedOrPrototypeContainerFlags( + MobData mob, + WorkspacePrototypeCatalogEntry? prototypeEntry + ) + { + if (mob.Header.GameObjectType is not ObjectType.Container) + return null; + + var value = TryGetInt32Property(mob, ObjectField.ContainerFlags); + return value.HasValue ? unchecked((ContainerFlags)(uint)value.Value) : prototypeEntry?.ContainerFlags; + } + + private static SceneryFlags? ResolvePlacedOrPrototypeSceneryFlags( + MobData mob, + WorkspacePrototypeCatalogEntry? prototypeEntry + ) + { + if (mob.Header.GameObjectType is not ObjectType.Scenery) + return null; + + var value = TryGetInt32Property(mob, ObjectField.SceneryFlags); + return value.HasValue ? unchecked((SceneryFlags)(uint)value.Value) : prototypeEntry?.SceneryFlags; + } + + private static int? ResolvePlacedOrPrototypePortalInt32(MobData mob, ObjectField field, int? prototypeValue) + { + if (mob.Header.GameObjectType is not ObjectType.Portal) + return null; + + return TryGetInt32Property(mob, field) ?? prototypeValue; + } + + private static int? ResolvePlacedOrPrototypeContainerInt32(MobData mob, ObjectField field, int? prototypeValue) + { + if (mob.Header.GameObjectType is not ObjectType.Container) + return null; + + return TryGetInt32Property(mob, field) ?? prototypeValue; + } + + private static int? TryGetInt32Property(MobData mob, ObjectField field) + { + var property = mob.GetProperty(field); + return property is null ? null : property.GetInt32(); + } + + private static bool IsValidArtId(ArtId? artId) => artId is { Value: not 0u and not uint.MaxValue }; } diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogEntry.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogEntry.cs index ed06f2d..32844a8 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogEntry.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceStaticObjectCatalogEntry.cs @@ -1,3 +1,4 @@ +using ArcNET.Core.Primitives; using ArcNET.GameObjects; namespace ArcNET.GameData.Workspace; @@ -13,9 +14,18 @@ public sealed record class WorkspaceStaticObjectCatalogEntry( string ObjectGuidText, int? ProtoNumber, string PrototypeText, + ArtId? CurrentArtId, + ArtId? DestroyedArtId, string SourceAssetPath, string LocationText, - string SummaryText + string SummaryText, + PortalFlags? PortalFlags = null, + ContainerFlags? ContainerFlags = null, + SceneryFlags? SceneryFlags = null, + int? PortalLockDifficulty = null, + int? PortalKeyId = null, + int? ContainerLockDifficulty = null, + int? ContainerKeyId = null ) { public bool HasPrototype => ProtoNumber is > 0; diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaCatalogBuilder.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaCatalogBuilder.cs index 95868bf..1fedffb 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaCatalogBuilder.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaCatalogBuilder.cs @@ -145,7 +145,8 @@ private static IReadOnlyList ParseMapList(MesFile? file) List entries = []; foreach (var entry in file.Entries) { - if (!TryParseMapListEntry(entry, out var parsed)) + var mapId = entries.Count + 1; + if (!TryParseMapListEntry(entry, mapId, out var parsed)) continue; entries.Add(parsed); @@ -222,7 +223,7 @@ private static bool TryParseTownMapEntry(MessageEntry entry, out TownMapEntry pa return parsed.DisplayName.Length > 0; } - private static bool TryParseMapListEntry(MessageEntry entry, out MapListEntry parsed) + private static bool TryParseMapListEntry(MessageEntry entry, int mapId, out MapListEntry parsed) { parsed = null!; @@ -279,6 +280,7 @@ out var parsedWorldMapId type, new WorkspaceWorldAreaMapEntry { + MapId = mapId, MapName = mapName, EntryTileX = entryTileX, EntryTileY = entryTileY, diff --git a/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaMapEntry.cs b/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaMapEntry.cs index f87c7a5..468cf07 100644 --- a/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaMapEntry.cs +++ b/src/GameData/ArcNET.GameData.Workspace/WorkspaceWorldAreaMapEntry.cs @@ -5,6 +5,12 @@ namespace ArcNET.GameData.Workspace; /// public sealed class WorkspaceWorldAreaMapEntry { + /// + /// One-based map id assigned by Rules/MapList.mes load order. + /// This is the value used by jump points and teleport data. + /// + public int MapId { get; init; } + /// /// Logical map name from Rules/MapList.mes. /// diff --git a/src/GameData/ArcNET.GameData/GameDataLoader.cs b/src/GameData/ArcNET.GameData/GameDataLoader.cs index f34f88e..6b70399 100644 --- a/src/GameData/ArcNET.GameData/GameDataLoader.cs +++ b/src/GameData/ArcNET.GameData/GameDataLoader.cs @@ -1,5 +1,5 @@ using System.Diagnostics; -using System.Threading.Channels; +using System.Threading.Tasks.Dataflow; using ArcNET.Formats; using ArcNET.GameObjects; @@ -17,6 +17,7 @@ public static class GameDataLoader private const int MaxReadParallelism = 256; private const int MinReadParallelism = 8; private const int MaxLoadedEntryBufferSize = 128; + private const int ParseProgressReportStride = 128; private const long MinLoadedEntryRetainedByteBudget = 32L * 1024L * 1024L; private const long MaxLoadedEntryRetainedByteBudget = 256L * 1024L * 1024L; private const long UnknownEntryEstimatedLength = 64L * 1024L; @@ -367,79 +368,106 @@ private static async Task ParseEntriesAsync( var parseParallelism = GetParseParallelism(); var readParallelism = GetReadParallelism(parseParallelism); + var loadedEntryBufferSize = GetLoadedEntryBufferSize(parseParallelism); var loadedEntryByteBudget = GetLoadedEntryByteBudget(parseParallelism); var timingAccumulator = stageProgress is null ? null : new GameDataLoadTimingAccumulator(); Debug.WriteLine( - $"[GameDataLoader] CPU={Environment.ProcessorCount}, ParseParallelism={parseParallelism}, ReadParallelism={readParallelism}, BufferedEntries={GetLoadedEntryBufferSize(parseParallelism)}, RetainedByteBudget={loadedEntryByteBudget}, TotalAssets={entries.Count}" + $"[GameDataLoader] CPU={Environment.ProcessorCount}, ParseParallelism={parseParallelism}, ReadParallelism={readParallelism}, BufferedEntries={loadedEntryBufferSize}, RetainedByteBudget={loadedEntryByteBudget}, TotalAssets={entries.Count}" ); - // Use a bounded channel to prevent unbounded memory growth if reading is faster than parsing. - // AllowSynchronousContinuations MUST be false to ensure that the CPU-intensive parse continuations - // do not hijack the I/O threads. Setting it to true destroys the parallelism pipeline. - var loadedEntryChannel = Channel.CreateBounded( - new BoundedChannelOptions(GetLoadedEntryBufferSize(parseParallelism)) + + var inFlightByteBudget = new InFlightByteBudget(loadedEntryByteBudget); + var readBlock = new TransformBlock( + async index => { - FullMode = BoundedChannelFullMode.Wait, - SingleWriter = false, - SingleReader = false, // Multiple parse workers read from the channel - AllowSynchronousContinuations = false, // Critical: Decouple I/O and CPU work + var entry = entries[index]; + var byteLease = await inFlightByteBudget + .AcquireAsync(GetEffectiveEstimatedContentLength(entry), ct) + .ConfigureAwait(false); + + try + { + var memory = timingAccumulator is null + ? await entry.LoadContentAsync(ct).ConfigureAwait(false) + : await timingAccumulator + .MeasureLoadAsync(entry, entry.LoadContentAsync, ct) + .ConfigureAwait(false); + return new LoadedEntry(index, entry, memory, byteLease); + } + catch + { + byteLease.Dispose(); + throw; + } + }, + new ExecutionDataflowBlockOptions + { + BoundedCapacity = loadedEntryBufferSize, + CancellationToken = ct, + EnsureOrdered = false, + MaxDegreeOfParallelism = readParallelism, + SingleProducerConstrained = true, } ); - var inFlightByteBudget = new InFlightByteBudget(loadedEntryByteBudget); - var parseWorkers = Enumerable - .Range(0, parseParallelism) - .Select(_ => - Task.Run( - () => ConsumeLoadedEntriesAsync(loadedEntryChannel.Reader, parsedEntries, timingAccumulator, ct), - ct - ) - ) - .ToArray(); + var parseBlock = new TransformBlock( + loadedEntry => + { + try + { + var parsedEntry = timingAccumulator is null + ? ParseLoadedEntry(loadedEntry.Entry, loadedEntry.Memory) + : timingAccumulator.MeasureParse(loadedEntry.Entry, loadedEntry.Memory); + return new ParsedIndexedLoadEntry(loadedEntry.Index, parsedEntry); + } + finally + { + loadedEntry.ByteLease.Dispose(); + } + }, + new ExecutionDataflowBlockOptions + { + BoundedCapacity = loadedEntryBufferSize, + CancellationToken = ct, + EnsureOrdered = false, + MaxDegreeOfParallelism = parseParallelism, + } + ); - Exception? loadFailure = null; - try - { - await Parallel - .ForEachAsync( - Enumerable.Range(0, entries.Count), - new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = readParallelism }, - async (index, cancellationToken) => - { - var entry = entries[index]; - var byteLease = await inFlightByteBudget - .AcquireAsync(GetEffectiveEstimatedContentLength(entry), cancellationToken) - .ConfigureAwait(false); + var completedCount = 0; + var recordBlock = new ActionBlock( + indexedEntry => + { + parsedEntries[indexedEntry.Index] = indexedEntry.Entry; + completedCount++; + ReportParseProgress(progress, loadProgress, completedCount, entries.Count); + }, + new ExecutionDataflowBlockOptions + { + BoundedCapacity = loadedEntryBufferSize, + CancellationToken = ct, + EnsureOrdered = false, + MaxDegreeOfParallelism = 1, + } + ); - try - { - var memory = timingAccumulator is null - ? await entry.LoadContentAsync(cancellationToken).ConfigureAwait(false) - : await timingAccumulator - .MeasureLoadAsync(entry, entry.LoadContentAsync, cancellationToken) - .ConfigureAwait(false); - await loadedEntryChannel - .Writer.WriteAsync(new LoadedEntry(index, entry, memory, byteLease), cancellationToken) - .ConfigureAwait(false); - } - catch - { - byteLease.Dispose(); - throw; - } - } - ) - .ConfigureAwait(false); - } - catch (Exception ex) - { - loadFailure = ex; - } - finally + using var readToParseLink = readBlock.LinkTo( + parseBlock, + new DataflowLinkOptions { PropagateCompletion = true } + ); + using var parseToRecordLink = parseBlock.LinkTo( + recordBlock, + new DataflowLinkOptions { PropagateCompletion = true } + ); + + for (var index = 0; index < entries.Count; index++) { - loadedEntryChannel.Writer.TryComplete(loadFailure); + ct.ThrowIfCancellationRequested(); + if (!await readBlock.SendAsync(index, ct).ConfigureAwait(false)) + throw new InvalidOperationException("The game data load pipeline declined an entry before completion."); } - await Task.WhenAll(parseWorkers).ConfigureAwait(false); + readBlock.Complete(); + await recordBlock.Completion.ConfigureAwait(false); timingAccumulator?.Report(stageProgress); // Final progress report @@ -449,26 +477,21 @@ await loadedEntryChannel return parsedEntries; } - private static async Task ConsumeLoadedEntriesAsync( - ChannelReader reader, - ParsedLoadEntry[] parsedEntries, - GameDataLoadTimingAccumulator? timingAccumulator, - CancellationToken ct + private static void ReportParseProgress( + IProgress? progress, + IProgress? loadProgress, + int completedCount, + int totalCount ) { - await foreach (var loadedEntry in reader.ReadAllAsync(ct).ConfigureAwait(false)) - { - try - { - parsedEntries[loadedEntry.Index] = timingAccumulator is null - ? ParseLoadedEntry(loadedEntry.Entry, loadedEntry.Memory) - : timingAccumulator.MeasureParse(loadedEntry.Entry, loadedEntry.Memory); - } - finally - { - loadedEntry.ByteLease.Dispose(); - } - } + if (completedCount != totalCount && completedCount % ParseProgressReportStride != 0) + return; + + var fraction = completedCount / (float)totalCount; + progress?.Report(fraction); + loadProgress?.Report( + new GameDataLoadProgress("Parsing game data assets", fraction, completedCount, totalCount) + ); } private static ParsedLoadEntry ParseLoadedEntry(GameDataLoadEntry entry, ReadOnlyMemory memory) @@ -776,6 +799,8 @@ private readonly record struct LoadedEntry( InFlightByteBudget.Lease ByteLease ); + private readonly record struct ParsedIndexedLoadEntry(int Index, ParsedLoadEntry Entry); + private readonly record struct FileLoadEntry(FileFormat Format, string Path); private readonly record struct ParsedLoadEntry(