From 52a2d45c3996091d3653f6a92f133878148e233f Mon Sep 17 00:00:00 2001 From: JaThePlayer Date: Mon, 8 Jan 2024 16:32:48 +0100 Subject: [PATCH 1/2] Refactor the Autotiler --- Celeste.Mod.mm/Patches/Autotiler.cs | 282 ++++++++++++++++++++-------- 1 file changed, 205 insertions(+), 77 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Autotiler.cs b/Celeste.Mod.mm/Patches/Autotiler.cs index 44169fffe..2e9beb663 100644 --- a/Celeste.Mod.mm/Patches/Autotiler.cs +++ b/Celeste.Mod.mm/Patches/Autotiler.cs @@ -112,18 +112,18 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml foreach (char c in text) { switch (c) { case '0': - masked.Mask[i++] = 0; // No tile + masked.Mask[i++] = patch_Masked.TileEmptyMask; break; case '1': - masked.Mask[i++] = 1; // Tile + masked.Mask[i++] = patch_Masked.TilePresentMask; break; case 'x': case 'X': - masked.Mask[i++] = 2; // Any + masked.Mask[i++] = patch_Masked.AnyMask; break; case 'y': case 'Y': - masked.Mask[i++] = 3; // Not this tile + masked.Mask[i++] = patch_Masked.NotThisTileMask; break; case 'z': case 'Z': @@ -184,24 +184,24 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml int aAnys = 0; int bAnys = 0; for (int i = 0; i < data.ScanWidth * data.ScanHeight; i++) { - if (a.Mask[i] >= 10) { + if (a.Mask[i] >= patch_Masked.CustomFilterMaskStart) { aFilters++; } - if (b.Mask[i] >= 10) { + if (b.Mask[i] >= patch_Masked.CustomFilterMaskStart) { bFilters++; } - if (a.Mask[i] == 3) { + if (a.Mask[i] == patch_Masked.NotThisTileMask) { aNots++; } - if (b.Mask[i] == 3) { + if (b.Mask[i] == patch_Masked.NotThisTileMask) { bNots++; } - if (a.Mask[i] == 2) { + if (a.Mask[i] == patch_Masked.AnyMask) { aAnys++; } - if (b.Mask[i] == 2) { + if (b.Mask[i] == patch_Masked.AnyMask) { bAnys++; } } @@ -216,13 +216,18 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml } private byte GetByteLookup(char c) { - if (char.IsLower(c)) - // Take the letter, convert it into a number from 10 to 36 - return (byte) ((c - 'a') + 10); - else if (char.IsUpper(c)) - // Take the letter, convert it into a number from 37 to 63 - return (byte) ((c - 'A') + 37); - throw new ArgumentException("Custom tileset mask filter must be an uppercase or lowercase letter."); + // Because of how the below code converts chars to numbers, only ascii values are safe... + if (char.IsAscii(c)) { + if (char.IsLower(c)) + // Take the letter, convert it into a number from 10 to 36 + return (byte) ((c - 'a') + patch_Masked.CustomFilterMaskStart); + + if (char.IsUpper(c)) + // Take the letter, convert it into a number from 37 to 63 + return (byte) ((c - 'A') + patch_Masked.CustomFilterMaskCapitalLetterStart); + } + + throw new ArgumentException("Custom tileset mask filter must be an ASCII uppercase or lowercase letter."); } [MonoModIgnore] @@ -237,6 +242,10 @@ private byte GetByteLookup(char c) { [MonoModIgnore] private extern bool CheckForSameLevel(int x1, int y1, int x2, int y2); + // While this method is no longer used, we still need to keep it around for backwards compat. + // All hooks on orig_TileHandler would've needed to duplicate the behaviour for TileHandler anyway to stay compatible with custom masks, + // so this should be safe. + [Obsolete("Never called, all code paths use TileHandler now")] private extern patch_Tiles orig_TileHandler(VirtualMap mapData, int x, int y, Rectangle forceFill, char forceID, Behaviour behaviour); private patch_Tiles TileHandler(VirtualMap mapData, int x, int y, Rectangle forceFill, char forceID, Behaviour behaviour) { char tile = GetTile(mapData, x, y, forceFill, forceID, behaviour); @@ -254,22 +263,30 @@ private patch_Tiles TileHandler(VirtualMap mapData, int x, int y, Rectangl int width = terrainType.ScanWidth; int height = terrainType.ScanHeight; - if (terrainType.CustomFills == null && width == 3 && height == 3 && terrainType.whitelists.Count == 0 && terrainType.blacklists.Count == 0) { - return orig_TileHandler(mapData, x, y, forceFill, forceID, behaviour); // Default tileset, default handler. - } - bool fillTile = true; - char[] adjacent = new char[width * height]; + // Stores information about adjacent tiles, flattened. + // This needs to be an array instead of stackalloc'd span, as we'll use it to construct a EquatableCharArray later + char[] adjacent = terrainType.GetSharedAdjacentBuffer(); + // Stores information whether adjacent tiles are present or not, taking into consideration the 'ignores' field. + // Used for '1' and '0' masks. + Span adjacentPresent = stackalloc bool[adjacent.Length]; + + // Calculate the level that contains this tile, so that we can quickly check if the neighbouring tiles are in the same level. + Rectangle levelBounds = behaviour.EdgesIgnoreOutOfLevel ? GetContainingLevelBounds(x, y) : default; int idx = 0; for (int yOffset = 0; yOffset < height; yOffset++) { for (int xOffset = 0; xOffset < width; xOffset++) { // Integer division will effectively truncate the "middle" (this) tile bool tilePresent = TryGetTile(terrainType, mapData, x + (xOffset - width / 2), y + (yOffset - height / 2), forceFill, forceID, behaviour, out char adjTile); - if (!tilePresent && behaviour.EdgesIgnoreOutOfLevel && !CheckForSameLevel(x, y, x + xOffset, y + yOffset)) { + + if (!tilePresent && behaviour.EdgesIgnoreOutOfLevel && !levelBounds.Contains(x + (xOffset - width / 2), y + (yOffset - height / 2))) { tilePresent = true; } + + adjacentPresent[idx] = tilePresent; adjacent[idx++] = adjTile; + if (!tilePresent) fillTile = false; } @@ -278,60 +295,93 @@ private patch_Tiles TileHandler(VirtualMap mapData, int x, int y, Rectangl if (fillTile) { if (terrainType.CustomFills != null) { // Start at depth of 1 since first layer has already been checked by masks. - int depth = GetDepth(terrainType, mapData, x, y, forceFill, behaviour, 1); + int depth = GetDepth(terrainType, mapData, x, y, forceFill, behaviour, 1, levelBounds); + return terrainType.CustomFills[depth - 1]; - } else { - if (CheckCross(terrainType, mapData, x, y, forceFill, behaviour, 1 + width / 2, 1 + height / 2)) - return terrainType.Center; - - return terrainType.Padded; } + + if (CheckCross(terrainType, mapData, x, y, forceFill, behaviour, 1 + width / 2, 1 + height / 2, levelBounds)) + return terrainType.Center; + + return terrainType.Padded; + } + + // If we already checked the same set of adjacent tiles, the exact same mask will match again. + // This means we can easily cache this. + if (terrainType.GetCachedMaskOrNull(adjacent) is { } cachedMask) { + return cachedMask.Tiles; } foreach (patch_Masked item in terrainType.Masked) { bool matched = true; - for (int i = 0; i < width * height; i++) { - if (item.Mask[i] == 2) // Matches Any - continue; - - if (item.Mask[i] == 1 && IsEmpty(adjacent[i])) { - matched = false; - break; - } - - if (item.Mask[i] == 0 && !IsEmpty(adjacent[i])) { + byte[] mask = item.Mask; + + // mask.Length as well as adjacent.Length should always be equal to width * height + // To get rid of JIT-generated bounds checks: + // - We'll do a normal for loop through the `mask`, to get rid of bounds checks for `mask` + // - And this additional check here, to get rid of bounds checks for `adjacent` + if (adjacent.Length < mask.Length) + continue; + + for (int i = 0; i < mask.Length; i++) { + bool thisTileMatched = mask[i] switch { + patch_Masked.AnyMask => true, + patch_Masked.TilePresentMask => adjacentPresent[i], + patch_Masked.TileEmptyMask => !adjacentPresent[i], + patch_Masked.NotThisTileMask => adjacent[i] != tile, + var customMask => IsCustomMaskMatch(terrainType, customMask, adjacent[i]) + }; + + if (!thisTileMatched) { matched = false; break; } + } - if (item.Mask[i] == 3 && adjacent[i] == tile) { - matched = false; - break; - } + if (matched) { + terrainType.CacheMask(adjacent, item); + return item.Tiles; + } + } - if (terrainType.blacklists.Count > 0) { - if (terrainType.blacklists.ContainsKey(item.Mask[i]) && terrainType.blacklists[item.Mask[i]].Contains(adjacent[i].ToString())) { - matched = false; - break; - } + return null; + } + + /// + /// Checks whether the given matches the custom . + /// + private static bool IsCustomMaskMatch(patch_TerrainType terrainType, byte mask, char tile) { + if (terrainType.blacklists.Count > 0) { + if (terrainType.blacklists.TryGetValue(mask, out string value) && value.Contains(tile, StringComparison.Ordinal)) { + return false; + } - } + } - if (terrainType.whitelists.Count > 0) { - if (terrainType.whitelists.ContainsKey(item.Mask[i]) && !terrainType.whitelists[item.Mask[i]].Contains(adjacent[i].ToString())) { - matched = false; - break; - } + if (terrainType.whitelists.Count > 0) { + if (terrainType.whitelists.TryGetValue(mask, out string value) && !value.Contains(tile, StringComparison.Ordinal)) { + return false; + } - } + } + return true; + } + + /// + /// Gets the bounds of the level that contains the given point. + /// This loops through all levels (rooms) in the map. + /// + private Rectangle GetContainingLevelBounds(int x, int y) { + foreach (Rectangle rectangle in LevelBounds) + { + if (rectangle.Contains(x, y)) + { + return rectangle; } - - if (matched) - return item.Tiles; } - return null; + return new(x, y, 1, 1); } // Replaces "CheckTile" in modded TileHandler method. @@ -359,34 +409,34 @@ private bool TryGetTile(patch_TerrainType set, VirtualMap mapData, int x, return !IsEmpty(tile) && !set.Ignore(tile); } - private int GetDepth(patch_TerrainType terrainType, VirtualMap mapData, int x, int y, Rectangle forceFill, Behaviour behaviour, int depth) { + private int GetDepth(patch_TerrainType terrainType, VirtualMap mapData, int x, int y, Rectangle forceFill, Behaviour behaviour, int depth, Rectangle levelBounds) { int searchX = depth + terrainType.ScanWidth / 2; int searchY = depth + terrainType.ScanHeight / 2; - if (CheckCross(terrainType, mapData, x, y, forceFill, behaviour, searchX, searchY) && depth < terrainType.CustomFills.Count) - return GetDepth(terrainType, mapData, x, y, forceFill, behaviour, ++depth); + if (CheckCross(terrainType, mapData, x, y, forceFill, behaviour, searchX, searchY, levelBounds) && depth < terrainType.CustomFills.Count) + return GetDepth(terrainType, mapData, x, y, forceFill, behaviour, ++depth, levelBounds); return depth; } + + private bool CheckCross(patch_TerrainType terrainType, VirtualMap mapData, int x, int y, Rectangle forceFill, Behaviour behaviour, int width, int height, Rectangle levelBounds) { + if (behaviour.PaddingIgnoreOutOfLevel) { + return (CheckTile(terrainType, mapData, x - width, y, forceFill, behaviour) || !levelBounds.Contains(x - width, y)) && + (CheckTile(terrainType, mapData, x + width, y, forceFill, behaviour) || !levelBounds.Contains(x + width, y)) && + (CheckTile(terrainType, mapData, x, y - height, forceFill, behaviour) || !levelBounds.Contains(x, y - height)) && + (CheckTile(terrainType, mapData, x, y + height, forceFill, behaviour) || !levelBounds.Contains(x, y + height)); + } - private bool CheckCross(patch_TerrainType terrainType, VirtualMap mapData, int x, int y, Rectangle forceFill, Behaviour behaviour, int width, int height) { - if (behaviour.PaddingIgnoreOutOfLevel) - return (CheckTile(terrainType, mapData, x - width, y, forceFill, behaviour) || !CheckForSameLevel(x, y, x - width, y)) && - (CheckTile(terrainType, mapData, x + width, y, forceFill, behaviour) || !CheckForSameLevel(x, y, x + width, y)) && - (CheckTile(terrainType, mapData, x, y - height, forceFill, behaviour) || !CheckForSameLevel(x, y, x, y - height)) && - (CheckTile(terrainType, mapData, x, y + height, forceFill, behaviour) || !CheckForSameLevel(x, y, x, y + height)); - else - return CheckTile(terrainType, mapData, x - width, y, forceFill, behaviour) && - CheckTile(terrainType, mapData, x + width, y, forceFill, behaviour) && - CheckTile(terrainType, mapData, x, y - height, forceFill, behaviour) && - CheckTile(terrainType, mapData, x, y + height, forceFill, behaviour); + return CheckTile(terrainType, mapData, x - width, y, forceFill, behaviour) && + CheckTile(terrainType, mapData, x + width, y, forceFill, behaviour) && + CheckTile(terrainType, mapData, x, y - height, forceFill, behaviour) && + CheckTile(terrainType, mapData, x, y + height, forceFill, behaviour); } public bool TryGetCustomDebris(out string path, char tiletype) { return !string.IsNullOrEmpty(path = lookup.TryGetValue(tiletype, out patch_TerrainType t) ? t.Debris : ""); } - // Required because TerrainType is private. private class patch_TerrainType { public char ID; public List Masked; @@ -402,6 +452,39 @@ private class patch_TerrainType { public Dictionary whitelists; public Dictionary blacklists; + // Cached shared buffer for GetSharedAdjacentBuffer + private char[] _adjacentBuffer; + // Cache for GetCachedMaskOrNull + private Dictionary _maskCache; + + /// + /// Returns a shared char[] that can be used by the Autotiler to hold all adjacent tiles. + /// + internal char[] GetSharedAdjacentBuffer() { + int size = ScanWidth * ScanHeight; + + // if ScanWidth/ScanHeight got changed, we need to create a new buffer + if (_adjacentBuffer is { } b && b.Length != size) { + _adjacentBuffer = null; + } + + return _adjacentBuffer ??= new char[size]; + } + + /// + /// Returns the that is known to match the given adjacent tiles, + /// or null if this combination of tiles hasn't been checked yet. + /// + internal patch_Masked GetCachedMaskOrNull(char[] adjacent) { + return _maskCache.GetValueOrDefault(new(adjacent), null); + } + + internal void CacheMask(char[] adjacent, patch_Masked mask) { + char[] adjacentCopy = (char[])adjacent.Clone(); + + _maskCache[new(adjacentCopy)] = mask; + } + [MonoModIgnore] public extern bool Ignore(char c); @@ -412,8 +495,37 @@ public void ctor(char id) { whitelists = new Dictionary(); blacklists = new Dictionary(); + _maskCache = new(); } + /// + /// A wrapper over a char[], which allows it to be used as a dictionary key to perform a SequenceEqual comparison. + /// This allows us to use a shared char[] to index the dict, without having to allocate a temporary string instance. + /// + private readonly struct EquatableCharArray : IEquatable { + private readonly char[] Data; + + public EquatableCharArray(char[] data) { + Data = data; + } + + public bool Equals(EquatableCharArray other) { + return Data.AsSpan().SequenceEqual(other.Data); + } + + public override int GetHashCode() => string.GetHashCode(Data); + + public override bool Equals(object obj) + => obj is EquatableCharArray other && Equals(other); + + public static bool operator ==(EquatableCharArray left, EquatableCharArray right) { + return left.Equals(right); + } + + public static bool operator !=(EquatableCharArray left, EquatableCharArray right) { + return !(left == right); + } + } } // Required because Tiles is private. @@ -424,13 +536,29 @@ private class patch_Tiles { public bool HasOverlays; } - - // Required because Masked is private. - [MonoModIgnore] + + // Add additional constants to clean up code private class patch_Masked { + [MonoModIgnore] public byte[] Mask; + + [MonoModIgnore] public patch_Tiles Tiles; + public const byte TileEmptyMask = 0; + public const byte TilePresentMask = 1; + public const byte AnyMask = 2; + public const byte NotThisTileMask = 3; + + /// + /// The first mask type used for custom filters. + /// + internal const byte CustomFilterMaskStart = 10; + + /// + /// The first mask type used for custom filters with capital letters. + /// + internal const byte CustomFilterMaskCapitalLetterStart = CustomFilterMaskStart + 27; } } From bab71e08c85d346520bcadde79d979bcf600d463 Mon Sep 17 00:00:00 2001 From: JaThePlayer Date: Mon, 8 Jan 2024 18:14:17 +0100 Subject: [PATCH 2/2] Implement `sortMode="none"` support for tilesets to disable automatic mask sorting --- Celeste.Mod.mm/Patches/Autotiler.cs | 118 +++++++++++++++++----------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Autotiler.cs b/Celeste.Mod.mm/Patches/Autotiler.cs index 2e9beb663..9a6e114f3 100644 --- a/Celeste.Mod.mm/Patches/Autotiler.cs +++ b/Celeste.Mod.mm/Patches/Autotiler.cs @@ -62,12 +62,18 @@ private void ReadInto(patch_TerrainType data, Tileset tileset, XmlElement xml) { for (int i = 0; i < fills.Count; i++) data.CustomFills.Add(new patch_Tiles()); } + + var sortMode = xml.Attr("sortMode", "default") switch { + "none" => MaskSortMode.None, + "default" => MaskSortMode.Default, + var unknownSortMode => throw new Exception($"Unknown sortMode for element: {unknownSortMode}.") + }; - if (data.CustomFills == null && data.ScanWidth == 3 && data.ScanHeight == 3 && !xml.HasChild("define")) // ReadIntoCustomTemplate can handle vanilla templates but meh + if (data.CustomFills == null && data.ScanWidth == 3 && data.ScanHeight == 3 && !xml.HasChild("define") && sortMode is MaskSortMode.Default) // ReadIntoCustomTemplate can handle vanilla templates but meh orig_ReadInto(data, tileset, xml); else { Logger.Log(LogLevel.Debug, "Autotiler", $"Reading template for tileset with id '{data.ID}', scan height {data.ScanHeight}, and scan width {data.ScanWidth}."); - ReadIntoCustomTemplate(data, tileset, xml); + ReadIntoCustomTemplate(data, tileset, xml, sortMode); } if (xml.HasAttr("soundPath") && xml.HasAttr("sound")) { // Could accommodate for no sound attr, but requiring it should improve clarity on user's end @@ -83,24 +89,32 @@ private void ReadInto(patch_TerrainType data, Tileset tileset, XmlElement xml) { data.Debris = xml.Attr("debris"); } - private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, XmlElement xml) { + private enum MaskSortMode { + Default, + None, + } + + private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, XmlElement xml, MaskSortMode sortMode) { foreach (XmlNode child in xml) { - if (child is XmlElement node) { - if (node.Name == "set") { // Potential somewhat breaking change, although there is no reason for another name to have been used. - string text = node.Attr("mask"); + if (child is not XmlElement node) + continue; + + switch (node.Name) { + case "set": { + string mask = node.Attr("mask"); patch_Tiles tiles; - if (text == "center") { + if (mask == "center") { if (data.CustomFills != null) Logger.Log(LogLevel.Warn, "Autotiler", $"\"Center\" tiles for tileset with id '{data.ID}' will not be used if custom fills are present."); tiles = data.Center; - } else if (text == "padding") { + } else if (mask == "padding") { if (data.CustomFills != null) Logger.Log(LogLevel.Warn, "Autotiler", $"\"Padding\" tiles for tileset with id '{data.ID}' will not be used if custom fills are present."); tiles = data.Padded; - } else if (text.StartsWith("fill")) { - tiles = data.CustomFills[int.Parse(text.Substring(4))]; + } else if (mask.StartsWith("fill", StringComparison.Ordinal)) { + tiles = data.CustomFills[int.Parse(mask.Substring(4))]; } else { patch_Masked masked = new patch_Masked(); masked.Mask = new byte[data.ScanWidth * data.ScanHeight]; @@ -109,7 +123,7 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml try { // Allows for spacer characters like '-' in the xml int i = 0; - foreach (char c in text) { + foreach (char c in mask) { switch (c) { case '0': masked.Mask[i++] = patch_Masked.TileEmptyMask; @@ -162,7 +176,9 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml tiles.HasOverlays = true; } - } else if (node.Name == "define") { + break; + } + case "define": { byte id = GetByteLookup(node.AttrChar("id")); string filter = node.Attr("filter"); @@ -170,49 +186,55 @@ private void ReadIntoCustomTemplate(patch_TerrainType data, Tileset tileset, Xml data.blacklists[id] = filter; else data.whitelists[id] = filter; + break; } } } - data.Masked.Sort((patch_Masked a, patch_Masked b) => { - // Sorts the masks to give preference to more specific masks. - // Order is Custom Filters -> "Not This" -> "Any" -> Everything else - int aFilters = 0; - int bFilters = 0; - int aNots = 0; - int bNots = 0; - int aAnys = 0; - int bAnys = 0; - for (int i = 0; i < data.ScanWidth * data.ScanHeight; i++) { - if (a.Mask[i] >= patch_Masked.CustomFilterMaskStart) { - aFilters++; - } - if (b.Mask[i] >= patch_Masked.CustomFilterMaskStart) { - bFilters++; - } - - if (a.Mask[i] == patch_Masked.NotThisTileMask) { - aNots++; - } - if (b.Mask[i] == patch_Masked.NotThisTileMask) { - bNots++; - } - - if (a.Mask[i] == patch_Masked.AnyMask) { - aAnys++; - } - if (b.Mask[i] == patch_Masked.AnyMask) { - bAnys++; + if (sortMode == MaskSortMode.Default) { + data.Masked.Sort((patch_Masked a, patch_Masked b) => { + // Sorts the masks to give preference to more specific masks. + // Order is Custom Filters -> "Not This" -> "Any" -> Everything else + int aFilters = 0; + int bFilters = 0; + int aNots = 0; + int bNots = 0; + int aAnys = 0; + int bAnys = 0; + for (int i = 0; i < a.Mask.Length; i++) { + switch (a.Mask[i]) { + case patch_Masked.NotThisTileMask: + aNots++; + break; + case patch_Masked.AnyMask: + aAnys++; + break; + case >= patch_Masked.CustomFilterMaskStart: + aFilters++; + break; + } + switch (b.Mask[i]) { + case patch_Masked.NotThisTileMask: + bNots++; + break; + case patch_Masked.AnyMask: + bAnys++; + break; + case >= patch_Masked.CustomFilterMaskStart: + bFilters++; + break; + } } - } - if (aFilters > 0 || bFilters > 0) - return aFilters - bFilters; + + if (aFilters > 0 || bFilters > 0) + return aFilters - bFilters; - if (aNots > 0 || bNots > 0) - return aNots - bNots; + if (aNots > 0 || bNots > 0) + return aNots - bNots; - return aAnys - bAnys; - }); + return aAnys - bAnys; + }); + } } private byte GetByteLookup(char c) {