MapRenderer code fixes (#38357)

* Fix MapRenderer integration test usage to properly show output.

Added an ITestContextLike interface that can be used to properly run the integration test infrastructure OUTSIDE A TEST.

* Use System.Test.Json instead of Newtonsoft.Json for MapRenderer

* Fix map renderer JSON output being broken

I love not testing or even reading the surrounding code.

* Fix un-reusable integration instances getting leaked.

The pair state was always getting set to Ready even if the instance was killed, meaning it was getting put back into the pool even if killed.

* Mark map renderer integration instances as destructive to avoid memory leak.

* Fix file specification handling.

Map file specification is now backwards compatible again (loose filename match to search prototypes). It also supports proper direct OS filename arguments. The former is the fallback scenario is extremely important for the map server still.

Cleaned up the way that target map files are passed through the application, so mixed file/prototype specifications are now handled properly (which can be caused by the fallback behavior).

Fixes JSON data export to use the proper user-facing map name. This only works if a prototype ID is specified *or* the legacy file behavior is used.

Restructured MapPainter into an instance that has multiple functions called on it, so not all data has to be passed through a single Paint() call.

Clean up the godawful map/grid detection code. Now we just load both in a single call, because yes you can do that. This relies on LogOrphanedGrids = false in the map loader options, which I think is fine for our purposes.

Improved error handling in much of the program.

* Fix duplicate map names in map renderer output

I'm not sure *what* this output is used for, but I'm sure having it duplicated per grid isn't intentional.

* Make maprenderer command line parsing bail on unknown - options

* Fix incorrect docs for --viewer maprenderer argument

It doesn't change directory layout

* Fix parallax layer specification to not use imgur as a fucking CDN

Files are now copied to a separate folder _parallax, and these files are referenced by the parallax configuration.

Parallax data is only output when instructed to via --parallax.

This will break parallax on current map server builds, but it should be graceful. Also, that's fucking good considering we shouldn't be using imgur links. Purge it.

* Fix incorrect assert in test pair clean return

* Restore other map viewer parallax layers, fix attribution.

* This isn't a valid copyright statement but the validator forces me to enter something here.
This commit is contained in:
Pieter-Jan Briers
2025-06-26 14:47:39 +02:00
committed by GitHub
parent 966122f7a5
commit bebc077fcc
17 changed files with 414 additions and 170 deletions

View File

@@ -0,0 +1,12 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Generic implementation of <see cref="ITestContextLike"/> for usage outside of actual tests.
/// </summary>
public sealed class ExternalTestContext(string name, TextWriter writer) : ITestContextLike
{
public string FullName => name;
public TextWriter Out => writer;
}

View File

@@ -0,0 +1,13 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Something that looks like a <see cref="TestContext"/>, for passing to integration tests.
/// </summary>
public interface ITestContextLike
{
string FullName { get; }
TextWriter Out { get; }
}

View File

@@ -0,0 +1,12 @@
using System.IO;
namespace Content.IntegrationTests;
/// <summary>
/// Canonical implementation of <see cref="ITestContextLike"/> for usage in actual NUnit tests.
/// </summary>
public sealed class NUnitTestContextWrap(TestContext context, TextWriter writer) : ITestContextLike
{
public string FullName => context.Test.FullName;
public TextWriter Out => writer;
}

View File

@@ -13,6 +13,7 @@ using Robust.Server.Player;
using Robust.Shared.Exceptions; using Robust.Shared.Exceptions;
using Robust.Shared.GameObjects; using Robust.Shared.GameObjects;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Utility;
namespace Content.IntegrationTests.Pair; namespace Content.IntegrationTests.Pair;
@@ -84,6 +85,7 @@ public sealed partial class TestPair : IAsyncDisposable
var returnTime = Watch.Elapsed; var returnTime = Watch.Elapsed;
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool"); await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool");
State = PairState.Ready;
} }
private async Task ResetModifiedPreferences() private async Task ResetModifiedPreferences()
@@ -104,7 +106,7 @@ public sealed partial class TestPair : IAsyncDisposable
await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started"); await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started");
State = PairState.CleanDisposed; State = PairState.CleanDisposed;
await OnCleanDispose(); await OnCleanDispose();
State = PairState.Ready; DebugTools.Assert(State is PairState.Dead or PairState.Ready);
PoolManager.NoCheckReturn(this); PoolManager.NoCheckReturn(this);
ClearContext(); ClearContext();
} }

View File

@@ -182,24 +182,29 @@ public static partial class PoolManager
/// </summary> /// </summary>
/// <param name="poolSettings">See <see cref="PoolSettings"/></param> /// <param name="poolSettings">See <see cref="PoolSettings"/></param>
/// <returns></returns> /// <returns></returns>
public static async Task<TestPair> GetServerClient(PoolSettings? poolSettings = null) public static async Task<TestPair> GetServerClient(
PoolSettings? poolSettings = null,
ITestContextLike? testContext = null)
{ {
return await GetServerClientPair(poolSettings ?? new PoolSettings()); return await GetServerClientPair(
poolSettings ?? new PoolSettings(),
testContext ?? new NUnitTestContextWrap(TestContext.CurrentContext, TestContext.Out));
} }
private static string GetDefaultTestName(TestContext testContext) private static string GetDefaultTestName(ITestContextLike testContext)
{ {
return testContext.Test.FullName.Replace("Content.IntegrationTests.Tests.", ""); return testContext.FullName.Replace("Content.IntegrationTests.Tests.", "");
} }
private static async Task<TestPair> GetServerClientPair(PoolSettings poolSettings) private static async Task<TestPair> GetServerClientPair(
PoolSettings poolSettings,
ITestContextLike testContext)
{ {
if (!_initialized) if (!_initialized)
throw new InvalidOperationException($"Pool manager has not been initialized"); throw new InvalidOperationException($"Pool manager has not been initialized");
// Trust issues with the AsyncLocal that backs this. // Trust issues with the AsyncLocal that backs this.
var testContext = TestContext.CurrentContext; var testOut = testContext.Out;
var testOut = TestContext.Out;
DieIfPoolFailure(); DieIfPoolFailure();
var currentTestName = poolSettings.TestName ?? GetDefaultTestName(testContext); var currentTestName = poolSettings.TestName ?? GetDefaultTestName(testContext);

View File

@@ -13,6 +13,7 @@ public sealed class CommandLineArguments
public string OutputPath { get; set; } = DirectoryExtensions.MapImages().FullName; public string OutputPath { get; set; } = DirectoryExtensions.MapImages().FullName;
public bool ArgumentsAreFileNames { get; set; } = false; public bool ArgumentsAreFileNames { get; set; } = false;
public bool ShowMarkers { get; set; } = false; public bool ShowMarkers { get; set; } = false;
public bool OutputParallax { get; set; } = false;
public static bool TryParse(IReadOnlyList<string> args, [NotNullWhen(true)] out CommandLineArguments? parsed) public static bool TryParse(IReadOnlyList<string> args, [NotNullWhen(true)] out CommandLineArguments? parsed)
{ {
@@ -70,7 +71,17 @@ public sealed class CommandLineArguments
PrintHelp(); PrintHelp();
return false; return false;
case "--parallax":
parsed.OutputParallax = true;
break;
default: default:
if (argument.StartsWith('-'))
{
Console.WriteLine($"Unknown argument: {argument}");
return false;
}
parsed.Maps.Add(argument); parsed.Maps.Add(argument);
break; break;
} }
@@ -95,7 +106,6 @@ Options:
Defaults to: png Defaults to: png
--viewer --viewer
Causes the map renderer to create the map.json files required for use with the map viewer. Causes the map renderer to create the map.json files required for use with the map viewer.
Also puts the maps in the required directory structure.
-o / --output <output path> -o / --output <output path>
Changes the path the rendered maps will get saved to. Changes the path the rendered maps will get saved to.
Defaults to Resources/MapImages Defaults to Resources/MapImages
@@ -104,6 +114,8 @@ Options:
Example: Content.MapRenderer -f /Maps/box.yml /Maps/bagel.yml Example: Content.MapRenderer -f /Maps/box.yml /Maps/bagel.yml
-m / --markers -m / --markers
Show hidden markers on map render. Defaults to false. Show hidden markers on map render. Defaults to false.
--parallax
Output images and data used for map viewer parallax.
-h / --help -h / --help
Displays this help text"); Displays this help text");
} }

View File

@@ -1,6 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using Robust.Shared.Maths; using System.Text.Json.Serialization;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
namespace Content.MapRenderer; namespace Content.MapRenderer;
@@ -43,31 +45,31 @@ public sealed class LayerGroup
public GroupSource Source { get; set; } = new(); public GroupSource Source { get; set; } = new();
public List<Layer> Layers { get; set; } = new(); public List<Layer> Layers { get; set; } = new();
public static LayerGroup DefaultParallax() public static LayerGroup DefaultParallax(IResourceManager resourceManager, ParallaxOutput output)
{ {
return new LayerGroup return new LayerGroup
{ {
Scale = new Position(0.1f, 0.1f), Scale = new Position(0.1f, 0.1f),
Source = new GroupSource Source = new GroupSource
{ {
Url = "https://i.imgur.com/3YO8KRd.png", Url = output.ReferenceResourceFile(resourceManager, new ResPath("/Textures/Parallaxes/layer1.png")),
Extent = new Extent(6000, 4000) Extent = new Extent(6000, 4000),
}, },
Layers = new List<Layer> Layers = new List<Layer>
{ {
new() new()
{ {
Url = "https://i.imgur.com/IannmmK.png" Url = output.ReferenceResourceFile(resourceManager, new ResPath("/Textures/Parallaxes/layer1.png")),
}, },
new() new()
{ {
Url = "https://i.imgur.com/T3W6JsE.png", Url = output.ReferenceResourceFile(resourceManager, new ResPath("/Textures/Parallaxes/layer2.png")),
Composition = "lighter", Composition = "lighter",
ParallaxScale = new Position(0.2f, 0.2f) ParallaxScale = new Position(0.2f, 0.2f)
}, },
new() new()
{ {
Url = "https://i.imgur.com/T3W6JsE.png", Url = output.ReferenceResourceFile(resourceManager, new ResPath("/Textures/Parallaxes/layer3.png")),
Composition = "lighter", Composition = "lighter",
ParallaxScale = new Position(0.3f, 0.3f) ParallaxScale = new Position(0.3f, 0.3f)
} }
@@ -91,9 +93,13 @@ public sealed class Layer
public readonly struct Extent public readonly struct Extent
{ {
[JsonInclude]
public readonly float X1; public readonly float X1;
[JsonInclude]
public readonly float Y1; public readonly float Y1;
[JsonInclude]
public readonly float X2; public readonly float X2;
[JsonInclude]
public readonly float Y2; public readonly float Y2;
public Extent() public Extent()
@@ -123,7 +129,9 @@ public readonly struct Extent
public readonly struct Position public readonly struct Position
{ {
[JsonInclude]
public readonly float X; public readonly float X;
[JsonInclude]
public readonly float Y; public readonly float Y;
public Position(float x, float y) public Position(float x, float y)

View File

@@ -1,149 +1,158 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Content.Client.Markers;
using Content.IntegrationTests; using Content.IntegrationTests;
using Content.IntegrationTests.Pair;
using Content.Server.GameTicking; using Content.Server.GameTicking;
using Robust.Client.GameObjects; using Robust.Client.GameObjects;
using Robust.Server.GameObjects; using Robust.Server.GameObjects;
using Robust.Server.Player; using Robust.Server.Player;
using Robust.Shared.ContentPack;
using Robust.Shared.EntitySerialization; using Robust.Shared.EntitySerialization;
using Robust.Shared.EntitySerialization.Systems; using Robust.Shared.EntitySerialization.Systems;
using Robust.Shared.GameObjects; using Robust.Shared.GameObjects;
using Robust.Shared.Map; using Robust.Shared.Map;
using Robust.Shared.Map.Components; using Robust.Shared.Map.Components;
using Robust.Shared.Map.Events;
using Robust.Shared.Maths; using Robust.Shared.Maths;
using Robust.Shared.Timing; using Robust.Shared.Timing;
using Robust.Shared.Utility;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
namespace Content.MapRenderer.Painters namespace Content.MapRenderer.Painters
{ {
public sealed class MapPainter public sealed class MapPainter : IAsyncDisposable
{ {
public static async IAsyncEnumerable<RenderedGridImage<Rgba32>> Paint(string map, private readonly RenderMap _map;
bool mapIsFilename = false, private readonly ITestContextLike _testContextLike;
bool showMarkers = false)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
await using var pair = await PoolManager.GetServerClient(new PoolSettings private TestPair? _pair;
private Entity<MapGridComponent>[] _grids = [];
public MapPainter(RenderMap map, ITestContextLike testContextLike)
{
_map = map;
_testContextLike = testContextLike;
}
public async Task Initialize()
{
var stopwatch = RStopwatch.StartNew();
var poolSettings = new PoolSettings
{ {
DummyTicker = false, DummyTicker = false,
Connected = true, Connected = true,
Destructive = true,
Fresh = true, Fresh = true,
// Seriously whoever made MapPainter use GameMapPrototype I wish you step on a lego one time. // Seriously whoever made MapPainter use GameMapPrototype I wish you step on a lego one time.
Map = mapIsFilename ? "Empty" : map, Map = _map is RenderMapPrototype prototype ? prototype.Prototype : PoolManager.TestMap,
}); };
_pair = await PoolManager.GetServerClient(poolSettings, _testContextLike);
var server = pair.Server;
var client = pair.Client;
Console.WriteLine($"Loaded client and server in {(int)stopwatch.Elapsed.TotalMilliseconds} ms"); Console.WriteLine($"Loaded client and server in {(int)stopwatch.Elapsed.TotalMilliseconds} ms");
stopwatch.Restart(); if (_map is RenderMapFile mapFile)
var cEntityManager = client.ResolveDependency<IClientEntityManager>();
var cPlayerManager = client.ResolveDependency<Robust.Client.Player.IPlayerManager>();
await client.WaitPost(() =>
{ {
if (cEntityManager.TryGetComponent(cPlayerManager.LocalEntity, out SpriteComponent? sprite)) using var stream = File.OpenRead(mapFile.FileName);
await _pair.Server.WaitPost(() =>
{ {
cEntityManager.System<SpriteSystem>().SetVisible((cPlayerManager.LocalEntity.Value, sprite), false); var loadOptions = new MapLoadOptions
{
// Accept loading both maps and grids without caring about what the input file truly is.
DeserializationOptions =
{
LogOrphanedGrids = false,
},
};
if (!_pair.Server.System<MapLoaderSystem>().TryLoadGeneric(stream, mapFile.FileName, out var loadResult, loadOptions))
throw new IOException($"File {mapFile.FileName} could not be read");
_grids = loadResult.Grids.ToArray();
});
}
}
public async Task SetupView(bool showMarkers)
{
if (_pair == null)
throw new InvalidOperationException("Instance not initialized!");
await _pair.Client.WaitPost(() =>
{
if (_pair.Client.EntMan.TryGetComponent(_pair.Client.PlayerMan.LocalEntity, out SpriteComponent? sprite))
{
_pair.Client.System<SpriteSystem>()
.SetVisible((_pair.Client.PlayerMan.LocalEntity.Value, sprite), false);
} }
}); });
if (showMarkers) if (showMarkers)
await pair.WaitClientCommand("showmarkers"); {
await _pair.Client.WaitPost(() =>
{
_pair.Client.System<MarkerSystem>().MarkersVisible = true;
});
}
}
public async Task<MapViewerData> GenerateMapViewerData(ParallaxOutput? parallaxOutput)
{
if (_pair == null)
throw new InvalidOperationException("Instance not initialized!");
var mapShort = _map.ShortName;
string fullName;
if (_map is RenderMapPrototype prototype)
{
fullName = _pair.Server.ProtoMan.Index(prototype.Prototype).MapName;
}
else
{
fullName = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(mapShort);
}
var mapViewerData = new MapViewerData
{
Id = mapShort,
Name = fullName,
};
if (parallaxOutput != null)
{
await _pair.Client.WaitPost(() =>
{
var res = _pair.Client.InstanceDependencyCollection.Resolve<IResourceManager>();
mapViewerData.ParallaxLayers.Add(LayerGroup.DefaultParallax(res, parallaxOutput));
});
}
return mapViewerData;
}
public async IAsyncEnumerable<RenderedGridImage<Rgba32>> Paint()
{
if (_pair == null)
throw new InvalidOperationException("Instance not initialized!");
var client = _pair.Client;
var server = _pair.Server;
var sEntityManager = server.ResolveDependency<IServerEntityManager>(); var sEntityManager = server.ResolveDependency<IServerEntityManager>();
var sPlayerManager = server.ResolveDependency<IPlayerManager>(); var sPlayerManager = server.ResolveDependency<IPlayerManager>();
var entityManager = server.ResolveDependency<IEntityManager>(); var entityManager = server.ResolveDependency<IEntityManager>();
var mapLoader = entityManager.System<MapLoaderSystem>();
var mapSys = entityManager.System<SharedMapSystem>(); var mapSys = entityManager.System<SharedMapSystem>();
var deps = server.ResolveDependency<IEntitySystemManager>().DependencyCollection;
Entity<MapGridComponent>[] grids = []; await _pair.RunTicksSync(10);
if (mapIsFilename)
{
var resPath = new ResPath(map);
if (!mapLoader.TryReadFile(resPath, out var data))
throw new IOException($"File {map} could not be read");
var ev = new BeforeEntityReadEvent();
server.EntMan.EventBus.RaiseEvent(EventSource.Local, ev);
var deserializer = new EntityDeserializer(deps,
data,
DeserializationOptions.Default,
ev.RenamedPrototypes,
ev.DeletedPrototypes);
if (!deserializer.TryProcessData())
{
throw new IOException($"Failed to process entity data in {map}");
}
if (deserializer.Result.Category == FileCategory.Unknown && deserializer.Result.Version < 7)
{
var mapCount = 0;
var gridCount = 0;
foreach (var (entId, ent) in deserializer.YamlEntities)
{
if (ent.Components != null && ent.Components.ContainsKey("MapGrid"))
{
gridCount++;
}
if (ent.Components != null && ent.Components.ContainsKey("Map"))
{
mapCount++;
}
}
if (mapCount == 1)
deserializer.Result.Category = FileCategory.Map;
else if (mapCount == 0 && gridCount == 1)
deserializer.Result.Category = FileCategory.Grid;
}
switch (deserializer.Result.Category)
{
case FileCategory.Map:
await server.WaitPost(() =>
{
if (mapLoader.TryLoadMap(resPath, out _, out var loadedGrids))
{
grids = loadedGrids.ToArray();
}
});
break;
case FileCategory.Grid:
await server.WaitPost(() =>
{
if (mapLoader.TryLoadGrid(resPath, out _, out var loadedGrids))
{
grids = [(Entity<MapGridComponent>)loadedGrids];
}
});
break;
default:
throw new IOException($"Unknown category {deserializer.Result.Category}");
}
}
await pair.RunTicksSync(10);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
var sMapManager = server.ResolveDependency<IMapManager>(); var sMapManager = server.ResolveDependency<IMapManager>();
@@ -162,23 +171,23 @@ namespace Content.MapRenderer.Painters
sEntityManager.DeleteEntity(playerEntity.Value); sEntityManager.DeleteEntity(playerEntity.Value);
} }
if (!mapIsFilename) if (_map is RenderMapPrototype)
{ {
var mapId = sEntityManager.System<GameTicker>().DefaultMap; var mapId = sEntityManager.System<GameTicker>().DefaultMap;
grids = sMapManager.GetAllGrids(mapId).ToArray(); _grids = sMapManager.GetAllGrids(mapId).ToArray();
} }
foreach (var (uid, _) in grids) foreach (var (uid, _) in _grids)
{ {
var gridXform = xformQuery.GetComponent(uid); var gridXform = xformQuery.GetComponent(uid);
xformSystem.SetWorldRotation(gridXform, Angle.Zero); xformSystem.SetWorldRotation(gridXform, Angle.Zero);
} }
}); });
await pair.RunTicksSync(10); await _pair.RunTicksSync(10);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
foreach (var (uid, grid) in grids) foreach (var (uid, grid) in _grids)
{ {
var tiles = mapSys.GetAllTiles(uid, grid).ToList(); var tiles = mapSys.GetAllTiles(uid, grid).ToList();
if (tiles.Count == 0) if (tiles.Count == 0)
@@ -219,16 +228,20 @@ namespace Content.MapRenderer.Painters
yield return renderedImage; yield return renderedImage;
} }
}
// We don't care if it fails as we have already saved the images. public async Task CleanReturnAsync()
try {
{ if (_pair == null)
await pair.CleanReturnAsync(); throw new InvalidOperationException("Instance not initialized!");
}
catch await _pair.CleanReturnAsync();
{ }
// ignored
} public async ValueTask DisposeAsync()
{
if (_pair != null)
await _pair.DisposeAsync();
} }
} }
} }

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.IO;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
namespace Content.MapRenderer;
/// <summary>
/// Helper class for collecting the files used for parallax output
/// </summary>
public sealed class ParallaxOutput
{
public const string OutputDirectory = "_parallax";
public readonly HashSet<ResPath> FilesToCopy = [];
private readonly string _outputPath;
/// <summary>
/// Helper class for collecting the files used for parallax output
/// </summary>
public ParallaxOutput(string outputPath)
{
_outputPath = outputPath;
Directory.CreateDirectory(Path.Combine(_outputPath, OutputDirectory));
}
public string ReferenceResourceFile(IResourceManager resourceManager, ResPath path)
{
var fileName = Path.Combine(OutputDirectory, path.Filename);
if (FilesToCopy.Add(path))
{
using var file = resourceManager.ContentFileRead(path);
using var target = File.Create(Path.Combine(_outputPath, fileName));
file.CopyTo(target);
}
return fileName;
}
}

View File

@@ -3,12 +3,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Content.IntegrationTests; using Content.IntegrationTests;
using Content.MapRenderer.Painters; using Content.MapRenderer.Painters;
using Content.Server.Maps; using Content.Server.Maps;
using Newtonsoft.Json;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Formats.Webp;
@@ -21,20 +20,19 @@ namespace Content.MapRenderer
private static readonly Func<string, string> ChosenMapIdNotIntMessage = id => $"The chosen id is not a valid integer: {id}"; private static readonly Func<string, string> ChosenMapIdNotIntMessage = id => $"The chosen id is not a valid integer: {id}";
private static readonly Func<int, string> NoMapFoundWithIdMessage = id => $"No map found with chosen id: {id}"; private static readonly Func<int, string> NoMapFoundWithIdMessage = id => $"No map found with chosen id: {id}";
private static readonly MapPainter MapPainter = new();
internal static async Task Main(string[] args) internal static async Task Main(string[] args)
{ {
if (!CommandLineArguments.TryParse(args, out var arguments)) if (!CommandLineArguments.TryParse(args, out var arguments))
return; return;
var testContext = new ExternalTestContext("Content.MapRenderer", Console.Out);
PoolManager.Startup(); PoolManager.Startup();
if (arguments.Maps.Count == 0) if (arguments.Maps.Count == 0)
{ {
Console.WriteLine("Didn't specify any maps to paint! Loading the map list..."); Console.WriteLine("Didn't specify any maps to paint! Loading the map list...");
await using var pair = await PoolManager.GetServerClient(); await using var pair = await PoolManager.GetServerClient(testContext: testContext);
var mapIds = pair.Server var mapIds = pair.Server
.ResolveDependency<IPrototypeManager>() .ResolveDependency<IPrototypeManager>()
.EnumeratePrototypes<GameMapPrototype>() .EnumeratePrototypes<GameMapPrototype>()
@@ -104,45 +102,118 @@ namespace Content.MapRenderer
Console.WriteLine($"Selected maps: {string.Join(", ", selectedMapPrototypes)}"); Console.WriteLine($"Selected maps: {string.Join(", ", selectedMapPrototypes)}");
} }
var maps = new List<RenderMap>();
if (arguments.ArgumentsAreFileNames) if (arguments.ArgumentsAreFileNames)
{ {
Console.WriteLine("Retrieving maps by file names..."); Console.WriteLine("Retrieving maps by file names...");
//
// Handle legacy command line processing:
// Ideally, people pass file names that are relative to the process working directory.
// i.e. regular command-line behavior.
//
// However, the map renderer was originally written to only handle gameMap prototypes,
// so it would actually go through the list of prototypes and match file name arguments
// via a *very* coarse check.
//
// So if we have any input filenames that don't exist... we run the old behavior.
// Yes by the way this means a typo means spinning up an entire integration pool pair
// before the map renderer can report a proper failure.
//
// Note that this legacy processing is very important! The map server currently relies on it,
// because it wants to work with file names, but we *need* to resolve the input to a prototype
// to properly export viewer JSON data.
//
var lookupPrototypeFiles = new List<string>();
foreach (var map in arguments.Maps)
{
if (File.Exists(map))
{
maps.Add(new RenderMapFile { FileName = map });
}
else
{
lookupPrototypeFiles.Add(map);
}
}
if (lookupPrototypeFiles.Count > 0)
{
Console.Write($"Following map files did not exist on disk directly, searching through prototypes: {string.Join(", ", lookupPrototypeFiles)}");
await using var pair = await PoolManager.GetServerClient();
var mapPrototypes = pair.Server
.ResolveDependency<IPrototypeManager>()
.EnumeratePrototypes<GameMapPrototype>()
.ToArray();
foreach (var toFind in lookupPrototypeFiles)
{
foreach (var mapPrototype in mapPrototypes)
{
if (mapPrototype.MapPath.Filename == toFind)
{
maps.Add(new RenderMapPrototype { Prototype = mapPrototype, });
Console.WriteLine($"Found matching map prototype: {mapPrototype.MapName}");
goto found;
}
}
await Console.Error.WriteLineAsync($"Found no map prototype for file '{toFind}'!");
found: ;
}
}
}
else
{
foreach (var map in arguments.Maps)
{
maps.Add(new RenderMapPrototype { Prototype = map });
}
} }
await Run(arguments); await Run(arguments, maps, testContext);
PoolManager.Shutdown(); PoolManager.Shutdown();
} }
private static async Task Run(CommandLineArguments arguments) private static async Task Run(
CommandLineArguments arguments,
List<RenderMap> toRender,
ExternalTestContext testContext)
{ {
Console.WriteLine($"Creating images for {arguments.Maps.Count} maps"); Console.WriteLine($"Creating images for {toRender.Count} maps");
var parallaxOutput = arguments.OutputParallax ? new ParallaxOutput(arguments.OutputPath) : null;
var mapNames = new List<string>(); var mapNames = new List<string>();
foreach (var map in arguments.Maps) foreach (var map in toRender)
{ {
Console.WriteLine($"Painting map {map}"); Console.WriteLine($"Painting map {map}");
var mapViewerData = new MapViewerData await using var painter = new MapPainter(map, testContext);
{ await painter.Initialize();
Id = map, await painter.SetupView(showMarkers: arguments.ShowMarkers);
Name = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(map)
};
mapViewerData.ParallaxLayers.Add(LayerGroup.DefaultParallax()); var mapViewerData = await painter.GenerateMapViewerData(parallaxOutput);
var directory = Path.Combine(arguments.OutputPath, Path.GetFileNameWithoutExtension(map));
var mapShort = map.ShortName;
var directory = Path.Combine(arguments.OutputPath, mapShort);
mapNames.Add(mapShort);
var i = 0; var i = 0;
try try
{ {
await foreach (var renderedGrid in MapPainter.Paint(map, await foreach (var renderedGrid in painter.Paint())
arguments.ArgumentsAreFileNames,
arguments.ShowMarkers))
{ {
var grid = renderedGrid.Image; var grid = renderedGrid.Image;
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
var fileName = Path.GetFileNameWithoutExtension(map); var savePath = $"{directory}{Path.DirectorySeparatorChar}{mapShort}-{i}.{arguments.Format}";
var savePath = $"{directory}{Path.DirectorySeparatorChar}{fileName}-{i}.{arguments.Format}";
Console.WriteLine($"Writing grid of size {grid.Width}x{grid.Height} to {savePath}"); Console.WriteLine($"Writing grid of size {grid.Width}x{grid.Height} to {savePath}");
@@ -167,9 +238,7 @@ namespace Content.MapRenderer
grid.Dispose(); grid.Dispose();
mapViewerData.Grids.Add(new GridLayer(renderedGrid, Path.Combine(map, Path.GetFileName(savePath)))); mapViewerData.Grids.Add(new GridLayer(renderedGrid, Path.Combine(mapShort, Path.GetFileName(savePath))));
mapNames.Add(fileName);
i++; i++;
} }
} }
@@ -182,8 +251,17 @@ namespace Content.MapRenderer
if (arguments.ExportViewerJson) if (arguments.ExportViewerJson)
{ {
var json = JsonConvert.SerializeObject(mapViewerData); var json = JsonSerializer.Serialize(mapViewerData);
await File.WriteAllTextAsync(Path.Combine(arguments.OutputPath, map, "map.json"), json); await File.WriteAllTextAsync(Path.Combine(directory, "map.json"), json);
}
try
{
await painter.CleanReturnAsync();
}
catch (Exception e)
{
Console.WriteLine($"Exception while shutting down painter: {e}");
} }
} }

View File

@@ -0,0 +1,55 @@
using System.IO;
using Content.Server.Maps;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.MapRenderer;
/// <summary>
/// A single target map that the map renderer should render.
/// </summary>
/// <seealso cref="RenderMapPrototype"/>
/// <seealso cref="RenderMapFile"/>
public abstract class RenderMap
{
/// <summary>
/// Short identifier of the map that should be unique-ish. Used in file names and other important stuff.
/// </summary>
public abstract string ShortName { get; }
}
/// <summary>
/// Specifies a map prototype that the map renderer should render.
/// </summary>
public sealed class RenderMapPrototype : RenderMap
{
/// <summary>
/// The ID of the prototype to render.
/// </summary>
public required ProtoId<GameMapPrototype> Prototype;
public override string ShortName => Prototype;
public override string ToString()
{
return $"{nameof(RenderMapPrototype)}({Prototype})";
}
}
/// <summary>
/// Specifies a map file on disk that the map renderer should render.
/// </summary>
public sealed class RenderMapFile : RenderMap
{
/// <summary>
/// The path to the file that should be rendered. This is an OS disk path, *not* a <see cref="ResPath"/>.
/// </summary>
public required string FileName;
public override string ShortName => Path.GetFileNameWithoutExtension(FileName);
public override string ToString()
{
return $"{nameof(RenderMapFile)}({FileName})";
}
}

View File

@@ -32,3 +32,8 @@
license: "CC-BY-NC-SA-3.0" license: "CC-BY-NC-SA-3.0"
copyright: "Made by SlamBamActionman" copyright: "Made by SlamBamActionman"
source: "https://github.com/space-wizards/space-station-14/blob/master/Resources/Textures/Parallaxes/space_map3.png" source: "https://github.com/space-wizards/space-station-14/blob/master/Resources/Textures/Parallaxes/space_map3.png"
- files: [ "layer1.png", "layer2.png", "layer3.png" ]
license: "CC-BY-SA-3.0"
copyright: "Taken from TGstation13"
source: "https://github.com/tgstation/tgstation/blob/bce9afc2cf39c229f2799c6ad2124f4d69a2dbbf/icons/effects/parallax.dmi"

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1 @@
preload: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
preload: false

View File

@@ -1,14 +0,0 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "https://github.com/tgstation/tgstation/blob/master/icons/effects/parallax.dmi.",
"size": {
"x": 480,
"y": 480
},
"states": [
{
"name": "layer1"
}
]
}