1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-11-21 20:43:29 +01:00

Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ell 2024-11-09 18:02:52 +01:00
commit 2e1d52944a
4 changed files with 185 additions and 159 deletions

View file

@ -2,7 +2,7 @@
MLEM tries to adhere to [semantic versioning](https://semver.org/). Potentially breaking changes are written in **bold**.
Jump to version:
- [7.2.0](#720-in-development)
- [8.0.0](#800-in-development)
- [7.1.1](#711)
- [7.1.0](#710)
- [7.0.0](#700)
@ -16,7 +16,7 @@ Jump to version:
- [5.1.0](#510)
- [5.0.0](#500)
## 7.2.0 (In Development)
## 8.0.0 (In Development)
### MLEM
Fixes
@ -36,6 +36,7 @@ Fixes
### MLEM.Data
Improvements
- **Use a binary tree algorithm for RuntimeTexturePacker to vastly increase packing speed**
- Made fields and methods in StaticJsonConverter protected to allow extending it
## 7.1.1

View file

@ -4,7 +4,6 @@ using System.Diagnostics;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Maths;
using MLEM.Textures;
using static MLEM.Textures.TextureExtensions;
@ -14,6 +13,9 @@ namespace MLEM.Data {
/// Packing textures in this manner allows for faster rendering, as fewer texture swaps are required.
/// The resulting texture segments are returned as <see cref="TextureRegion"/> instances.
/// </summary>
/// <remarks>
/// The algorithm used by this implementation is based on the blog post "Binary Tree Bin Packing Algorithm", which can be found at https://codeincomplete.com/articles/bin-packing/.
/// </remarks>
public class RuntimeTexturePacker : IDisposable {
/// <summary>
@ -36,31 +38,21 @@ namespace MLEM.Data {
/// <summary>
/// The amount of currently packed texture regions.
/// </summary>
public int PackedTextures => this.packedTextures.Count;
public int PackedTextures => this.PackedTexture != null ? this.requests.Count : 0;
private readonly List<Request> texturesToPack = new List<Request>();
private readonly List<Request> packedTextures = new List<Request>();
private readonly Dictionary<Point, Request> occupiedPositions = new Dictionary<Point, Request>();
private readonly Dictionary<Point, Point> initialPositions = new Dictionary<Point, Point>();
private readonly List<Request> requests = new List<Request>();
private readonly Dictionary<Texture2D, TextureData> dataCache = new Dictionary<Texture2D, TextureData>();
private readonly bool autoIncreaseMaxWidth;
private readonly bool forcePowerOfTwo;
private readonly bool forceSquare;
private readonly bool disposeTextures;
private int maxWidth;
/// <summary>
/// Creates a new runtime texture packer with the given settings.
/// </summary>
/// <param name="maxWidth">The maximum width that the packed texture can have. Defaults to 2048.</param>
/// <param name="autoIncreaseMaxWidth">Whether the maximum width should be increased if there is a texture to be packed that is wider than the maximum width specified in the constructor. Defaults to false.</param>
/// <param name="forcePowerOfTwo">Whether the resulting <see cref="PackedTexture"/> should have a width and height that is a power of two.</param>
/// <param name="forceSquare">Whether the resulting <see cref="PackedTexture"/> should be square regardless of required size.</param>
/// <param name="disposeTextures">Whether the original textures submitted to this texture packer should be disposed after packing.</param>
public RuntimeTexturePacker(int maxWidth = 2048, bool autoIncreaseMaxWidth = false, bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) {
this.maxWidth = maxWidth;
this.autoIncreaseMaxWidth = autoIncreaseMaxWidth;
public RuntimeTexturePacker(bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) {
this.forcePowerOfTwo = forcePowerOfTwo;
this.forceSquare = forceSquare;
this.disposeTextures = disposeTextures;
@ -145,15 +137,7 @@ namespace MLEM.Data {
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(TextureRegion texture, Action<TextureRegion> result, int padding = 0, bool padWithPixels = false) {
var paddedWidth = texture.Width + 2 * padding;
if (paddedWidth > this.maxWidth) {
if (this.autoIncreaseMaxWidth) {
this.maxWidth = paddedWidth;
} else {
throw new InvalidOperationException($"Cannot add texture with width {texture.Width} to a texture packer with max width {this.maxWidth}");
}
}
this.texturesToPack.Add(new Request(texture, result, padding, padWithPixels));
this.requests.Add(new Request(texture, result, padding, padWithPixels));
}
/// <summary>
@ -163,19 +147,32 @@ namespace MLEM.Data {
/// </summary>
/// <param name="device">The graphics device to use for texture generation</param>
public void Pack(GraphicsDevice device) {
// set pack areas for each request
// we pack larger textures first, so that smaller textures can fit in the gaps that larger ones leave
// set pack areas for each request based on the algo in https://codeincomplete.com/articles/bin-packing/
var stopwatch = Stopwatch.StartNew();
foreach (var request in this.texturesToPack.OrderByDescending(t => t.Texture.Width * t.Texture.Height)) {
request.PackedArea = this.OccupyFreeArea(request);
this.packedTextures.Add(request);
RequestNode root = null;
foreach (var request in this.requests.OrderByDescending(t => Math.Max(t.Texture.Width, t.Texture.Height) + t.Padding * 2)) {
var size = new Point(request.Texture.Width, request.Texture.Height);
size.X += request.Padding * 2;
size.Y += request.Padding * 2;
if (root == null)
root = new RequestNode(0, 0, size.X, size.Y);
var node = RuntimeTexturePacker.FindNode(size, root);
if (node == null) {
root = RuntimeTexturePacker.GrowNode(size, root);
node = RuntimeTexturePacker.FindNode(size, root);
}
request.Node = node;
node.Split(size);
}
stopwatch.Stop();
this.LastCalculationTime = stopwatch.Elapsed;
// figure out texture size and regenerate texture if necessary
var width = this.packedTextures.Max(t => t.PackedArea.Right);
var height = this.packedTextures.Max(t => t.PackedArea.Bottom);
var width = root.Area.Width;
var height = root.Area.Height;
if (this.forcePowerOfTwo) {
width = RuntimeTexturePacker.ToPowerOfTwo(width);
height = RuntimeTexturePacker.ToPowerOfTwo(height);
@ -184,26 +181,24 @@ namespace MLEM.Data {
width = height = Math.Max(width, height);
// if we don't need to regenerate, we only need to add newly added regions
IEnumerable<Request> texturesToCopy = this.texturesToPack;
if (this.PackedTexture == null || this.PackedTexture.Width != width || this.PackedTexture.Height != height) {
this.PackedTexture?.Dispose();
this.PackedTexture = new Texture2D(device, width, height);
// if we need to regenerate, we need to copy all regions since the old ones were deleted
texturesToCopy = this.packedTextures;
}
// copy texture data onto the packed texture
stopwatch.Restart();
using (var data = this.PackedTexture.GetTextureData()) {
foreach (var request in texturesToCopy)
foreach (var request in this.requests)
this.CopyRegion(data, request);
}
stopwatch.Stop();
this.LastPackTime = stopwatch.Elapsed;
// invoke callbacks for textures we copied
foreach (var request in texturesToCopy) {
var packedArea = request.PackedArea.Shrink(new Point(request.Padding, request.Padding));
foreach (var request in this.requests) {
var packedLoc = request.Node.Area.Location + new Point(request.Padding, request.Padding);
var packedArea = new Rectangle(packedLoc.X, packedLoc.Y, request.Texture.Width, request.Texture.Height);
request.Result.Invoke(new TextureRegion(this.PackedTexture, packedArea) {
Pivot = request.Texture.Pivot,
Name = request.Texture.Name,
@ -213,7 +208,6 @@ namespace MLEM.Data {
request.Texture.Texture.Dispose();
}
this.texturesToPack.Clear();
this.dataCache.Clear();
}
@ -225,10 +219,7 @@ namespace MLEM.Data {
this.PackedTexture = null;
this.LastCalculationTime = TimeSpan.Zero;
this.LastPackTime = TimeSpan.Zero;
this.texturesToPack.Clear();
this.packedTextures.Clear();
this.initialPositions.Clear();
this.occupiedPositions.Clear();
this.requests.Clear();
this.dataCache.Clear();
}
@ -237,53 +228,9 @@ namespace MLEM.Data {
this.Reset();
}
private Rectangle OccupyFreeArea(Request request) {
var size = new Point(request.Texture.Width, request.Texture.Height);
size.X += request.Padding * 2;
size.Y += request.Padding * 2;
// exit early if the texture doesn't need to find a free location
if (size.X <= 0 || size.Y <= 0)
return Rectangle.Empty;
var pos = this.initialPositions.TryGetValue(size, out var first) ? first : Point.Zero;
var area = new Rectangle(pos.X, pos.Y, size.X, size.Y);
var lowestY = int.MaxValue;
while (true) {
// check if the current area is already occupied
if (!this.occupiedPositions.TryGetValue(area.Location, out var existing)) {
existing = this.packedTextures.FirstOrDefault(t => t.PackedArea.Intersects(area));
// if no texture is occupying this space, we have found a free area
if (existing == null) {
// if this is the first position that this request fit in, no other requests of the same size will find a position before it
this.initialPositions[new Point(area.Width, area.Height)] = area.Location;
this.occupiedPositions.Add(area.Location, request);
return area;
}
// also cache the existing texture for this position, in case we check it again in the future
this.occupiedPositions.Add(area.Location, existing);
}
// move to the right by the existing texture's width
area.X = existing.PackedArea.Right;
// remember the smallest intersecting texture's height for when we move down
if (lowestY > existing.PackedArea.Bottom)
lowestY = existing.PackedArea.Bottom;
// move down a row if we exceed our maximum width
if (area.Right > this.maxWidth) {
area.X = 0;
area.Y = lowestY;
lowestY = int.MaxValue;
}
}
}
private void CopyRegion(TextureData destination, Request request) {
var data = this.GetCachedTextureData(request.Texture.Texture);
var location = request.PackedArea.Location + new Point(request.Padding, request.Padding);
var location = request.Node.Area.Location + new Point(request.Padding, request.Padding);
for (var x = -request.Padding; x < request.Texture.Width + request.Padding; x++) {
for (var y = -request.Padding; y < request.Texture.Height + request.Padding; y++) {
Color srcColor;
@ -328,13 +275,57 @@ namespace MLEM.Data {
return ret;
}
private static RequestNode FindNode(Point requestSize, RequestNode node) {
if (node.Down != null && node.Right != null) {
return RuntimeTexturePacker.FindNode(requestSize, node.Right) ?? RuntimeTexturePacker.FindNode(requestSize, node.Down);
} else if (requestSize.X <= node.Area.Width && requestSize.Y <= node.Area.Height) {
return node;
} else {
return null;
}
}
private static RequestNode GrowNode(Point requestSize, RequestNode node) {
var canGrowDown = requestSize.X <= node.Area.Width;
var canGrowRight = requestSize.Y <= node.Area.Height;
var shouldGrowRight = canGrowRight && node.Area.Height >= node.Area.Width + requestSize.X;
var shouldGrowDown = canGrowDown && node.Area.Width >= node.Area.Height + requestSize.Y;
if (shouldGrowRight) {
return RuntimeTexturePacker.GrowNodeRight(requestSize, node);
} else if (shouldGrowDown) {
return RuntimeTexturePacker.GrowNodeDown(requestSize, node);
} else if (canGrowRight) {
return RuntimeTexturePacker.GrowNodeRight(requestSize, node);
} else if (canGrowDown) {
return RuntimeTexturePacker.GrowNodeDown(requestSize, node);
} else {
return null;
}
}
private static RequestNode GrowNodeRight(Point requestSize, RequestNode node) {
return new RequestNode(0, 0, node.Area.Width + requestSize.X, node.Area.Height) {
Right = new RequestNode(node.Area.Width, 0, requestSize.X, node.Area.Height),
Down = node
};
}
private static RequestNode GrowNodeDown(Point requestSize, RequestNode node) {
return new RequestNode(0, 0, node.Area.Width, node.Area.Height + requestSize.Y) {
Right = node,
Down = new RequestNode(0, node.Area.Height, node.Area.Width, requestSize.Y)
};
}
private class Request {
public readonly TextureRegion Texture;
public readonly Action<TextureRegion> Result;
public readonly int Padding;
public readonly bool PadWithPixels;
public Rectangle PackedArea;
public RequestNode Node;
public Request(TextureRegion texture, Action<TextureRegion> result, int padding, bool padWithPixels) {
this.Texture = texture;
@ -345,5 +336,22 @@ namespace MLEM.Data {
}
private class RequestNode {
public readonly Rectangle Area;
public RequestNode Down;
public RequestNode Right;
public RequestNode(int x, int y, int width, int height) {
this.Area = new Rectangle(x, y, width, height);
}
public void Split(Point requestSize) {
this.Down = new RequestNode(this.Area.X, this.Area.Y + requestSize.Y, this.Area.Width, this.Area.Height - requestSize.Y);
this.Right = new RequestNode(this.Area.X + requestSize.X, this.Area.Y, this.Area.Width - requestSize.X, requestSize.Y);
}
}
}
}

View file

@ -1,27 +1,25 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Data;
using MLEM.Graphics;
using MLEM.Maths;
using MLEM.Textures;
using NUnit.Framework;
using Color = Microsoft.Xna.Framework.Color;
namespace Tests;
public class TexturePackerTests : GameTestFixture {
private Texture2D testTexture;
private Texture2D disposedTestTexture;
[SetUp]
public void SetUp() {
this.testTexture = new Texture2D(this.Game.GraphicsDevice, 2048, 2048);
this.disposedTestTexture = new Texture2D(this.Game.GraphicsDevice, 16, 16);
}
private readonly List<TextureRegion> generatedTextures = [];
[TearDown]
public void TearDown() {
this.testTexture?.Dispose();
this.disposedTestTexture?.Dispose();
foreach (var tex in this.generatedTextures)
tex.Texture.Dispose();
this.generatedTextures.Clear();
}
[Test]
@ -29,69 +27,67 @@ public class TexturePackerTests : GameTestFixture {
using var packer = new RuntimeTexturePacker();
for (var i = 0; i < 5; i++) {
var width = 16 * (i + 1);
packer.Add(new TextureRegion(this.testTexture, 0, 0, width, 64), r => {
packer.Add(this.MakeTextureRegion(width, 64), r => {
Assert.AreEqual(r.Width, width);
Assert.AreEqual(r.Height, 64);
});
}
packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(packer.PackedTexture.Width, 16 + 32 + 48 + 64 + 80);
Assert.AreEqual(packer.PackedTexture.Height, 64);
TexturePackerTests.SaveTexture(packer);
Assert.AreEqual(packer.PackedTexture.Width, 128);
Assert.AreEqual(packer.PackedTexture.Height, 128);
}
[Test]
public void TestOverlap() {
public void TestOverlap([Values(0, 1, 5, 10)] int padding) {
var packed = new List<TextureRegion>();
using (var packer = new RuntimeTexturePacker(8192)) {
using var packer = new RuntimeTexturePacker();
for (var i = 1; i <= 1000; i++)
packer.Add(new TextureRegion(this.testTexture, 0, 0, i % 239, i % 673), packed.Add);
packer.Add(this.MakeTextureRegion(i % 239, i % 673), packed.Add, padding);
packer.Pack(this.Game.GraphicsDevice);
}
TexturePackerTests.SaveTexture(packer, padding.ToString());
foreach (var r1 in packed) {
var r1Padded = r1.Area;
r1Padded.Inflate(padding, padding);
foreach (var r2 in packed) {
if (r1 == r2)
continue;
Assert.False(r1.Area.Intersects(r2.Area));
Assert.False(r1.Area.Intersects(r2.Area), $"Regions {r1.Area} and {r2.Area} intersect");
var r2Padded = r2.Area;
r2Padded.Inflate(padding, padding);
Assert.False(r1Padded.Intersects(r2Padded), $"Padded regions {r1Padded} and {r2Padded} intersect");
}
}
}
[Test]
public void TestDisposal() {
using var packer = new RuntimeTexturePacker(128, disposeTextures: true);
packer.Add(new TextureRegion(this.disposedTestTexture), TexturePackerTests.StubResult);
packer.Add(new TextureRegion(this.disposedTestTexture, 0, 0, 8, 8), TexturePackerTests.StubResult);
using var packer = new RuntimeTexturePacker(disposeTextures: true);
var disposeLater = this.MakeTextureRegion(16, 16);
packer.Add(disposeLater, TexturePackerTests.StubResult);
packer.Add(new TextureRegion(disposeLater, 0, 0, 8, 8), TexturePackerTests.StubResult);
packer.Pack(this.Game.GraphicsDevice);
Assert.True(this.disposedTestTexture.IsDisposed);
Assert.True(disposeLater.Texture.IsDisposed);
Assert.False(packer.PackedTexture.IsDisposed);
}
[Test]
public void TestBounds() {
// test forced max width
using var packer = new RuntimeTexturePacker(128);
Assert.Throws<InvalidOperationException>(() => {
packer.Add(new TextureRegion(this.testTexture, 0, 0, 256, 128), TexturePackerTests.StubResult);
});
// test auto-expanding width
using var packer2 = new RuntimeTexturePacker(128, true);
Assert.DoesNotThrow(() => {
packer2.Add(new TextureRegion(this.testTexture, 0, 0, 256, 128), TexturePackerTests.StubResult);
});
packer2.Pack(this.Game.GraphicsDevice);
// test power of two forcing
using var packer3 = new RuntimeTexturePacker(128, forcePowerOfTwo: true);
packer3.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult);
using var packer3 = new RuntimeTexturePacker(true);
packer3.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult);
packer3.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(64, packer3.PackedTexture.Width);
Assert.AreEqual(256, packer3.PackedTexture.Height);
// test square forcing
using var packer4 = new RuntimeTexturePacker(128, forceSquare: true);
packer4.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult);
using var packer4 = new RuntimeTexturePacker(forceSquare: true);
packer4.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult);
packer4.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(170, packer4.PackedTexture.Width);
Assert.AreEqual(170, packer4.PackedTexture.Height);
@ -99,45 +95,66 @@ public class TexturePackerTests : GameTestFixture {
[Test]
public void TestPackMultipleTimes() {
using var packer = new RuntimeTexturePacker(1024);
using var packer = new RuntimeTexturePacker();
// pack the first time
var results = 0;
for (var i = 0; i < 10; i++)
packer.Add(new TextureRegion(this.testTexture, 0, 0, 64, 64), _ => results++);
packer.Add(this.MakeTextureRegion(64, 64), _ => results++);
packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(10, results);
// pack without resizing
packer.Add(new TextureRegion(this.testTexture, 0, 0, 0, 0), _ => results++);
// pack again
packer.Add(this.MakeTextureRegion(64, 64), _ => results++);
packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(11, results);
// pack and force a resize
packer.Add(new TextureRegion(this.testTexture, 0, 0, 64, 64), _ => results++);
packer.Pack(this.Game.GraphicsDevice);
// all callbacks are called again, so we add 11 again, as well as the callback we just added
Assert.AreEqual(2 * 11 + 1, results);
// all callbacks are called again, so we add 10 again, as well as the callback we just added
Assert.AreEqual(10 + 10 + 1, results);
}
[Test]
public void TestPackTimes() {
for (var total = 1; total <= 10001; total += 1000) {
public void TestPackTimes([Values(1, 100, 1000, 5000, 10000)] int total) {
var random = new Random(1238492384);
using var sameSizePacker = new RuntimeTexturePacker();
using var diffSizePacker = new RuntimeTexturePacker();
for (var i = 0; i < total; i++) {
sameSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10, 10), TexturePackerTests.StubResult);
diffSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10 + i % 129, 10 * (i % 5 + 1)), TexturePackerTests.StubResult);
sameSizePacker.Add(this.MakeTextureRegion(10, 10), TexturePackerTests.StubResult);
diffSizePacker.Add(this.MakeTextureRegion(random.Next(10, 200), random.Next(10, 200)), TexturePackerTests.StubResult);
}
sameSizePacker.Pack(this.Game.GraphicsDevice);
diffSizePacker.Pack(this.Game.GraphicsDevice);
TexturePackerTests.SaveTexture(sameSizePacker, $"SameSize{total}");
TexturePackerTests.SaveTexture(diffSizePacker, $"DiffSize{total}");
TestContext.WriteLine($"""
{total} regions,
same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds} calc, {sameSizePacker.LastPackTime.TotalMilliseconds} pack, {sameSizePacker.LastTotalTime.TotalMilliseconds} total,
diff-size {diffSizePacker.LastCalculationTime.TotalMilliseconds} calc, {diffSizePacker.LastPackTime.TotalMilliseconds} pack, {diffSizePacker.LastTotalTime.TotalMilliseconds} total
same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds}ms calc, {sameSizePacker.LastPackTime.TotalMilliseconds}ms pack, {sameSizePacker.LastTotalTime.TotalMilliseconds}ms total,
diff-size {diffSizePacker.LastCalculationTime.TotalMilliseconds}ms calc, {diffSizePacker.LastPackTime.TotalMilliseconds}ms pack, {diffSizePacker.LastTotalTime.TotalMilliseconds}ms total
""");
}
private TextureRegion MakeTextureRegion(int width, int height) {
var color = ColorHelper.FromHexRgb(SingleRandom.Int(this.generatedTextures.Count));
var texture = new Texture2D(this.Game.GraphicsDevice, Math.Max(width, 1), Math.Max(height, 1));
using (var data = texture.GetTextureData()) {
for (var x = 0; x < texture.Width; x++) {
for (var y = 0; y < texture.Height; y++)
data[x, y] = color;
}
}
var region = new TextureRegion(texture, 0, 0, width, height);
this.generatedTextures.Add(region);
return region;
}
private static void SaveTexture(RuntimeTexturePacker packer, string append = "") {
var caller = new System.Diagnostics.StackTrace(1).GetFrame(0).GetMethod().Name + append;
var file = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.WorkDirectory, "PackedTextures", caller + ".png"));
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
using (var stream = File.Create(file))
packer.PackedTexture.SaveAsPng(stream, packer.PackedTexture.Width, packer.PackedTexture.Height);
TestContext.WriteLine($"Saving texture generated by {caller} to {file}");
TestContext.AddTestAttachment(file);
}
private static void StubResult(TextureRegion region) {}

View file

@ -2,7 +2,7 @@
#tool dotnet:?package=docfx&version=2.75.3
// this is the upcoming version, for prereleases
var version = Argument("version", "7.2.0");
var version = Argument("version", "8.0.0");
var target = Argument("target", "Default");
var gitRef = Argument("ref", "refs/heads/main");
var buildNum = Argument("buildNum", "");
@ -40,7 +40,7 @@ Task("Test").IsDependentOn("Prepare").Does(() => {
var settings = new DotNetTestSettings {
Configuration = config,
Collectors = {"XPlat Code Coverage"},
Loggers = {"console;verbosity=normal"}
Loggers = {"console;verbosity=normal", "nunit"}
};
DotNetTest("MLEM.sln", settings);
DotNetTest("MLEM.FNA.sln", settings);