Files
OpenRA/OpenRA.Game/Map/MapPreview.cs
Paul Chote 0bbb32e8ac Rework MapPreview custom rule handling.
The previous asynchronous approach did not work particularly well,
leading to large janks when switching to custom maps or opening the
mission browser.

This commit introduces two key changes:

 * Rule loading for WorldActorInfo and PlayerActorInfo is made
   synchronous, in preparation for the next commit which will
   significantly optimize this path.
 * The full ruleset loading, which is required for map validation,
   is moved to the server-side and managed by a new ServerMapStatusCache.
   The previous syntax check is expanded to include the ability to run
   lint tests.
2021-04-21 18:57:44 +02:00

523 lines
16 KiB
C#

#region Copyright & License Information
/*
* Copyright 2007-2020 The OpenRA Developers (see AUTHORS)
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using OpenRA.FileFormats;
using OpenRA.FileSystem;
using OpenRA.Graphics;
using OpenRA.Primitives;
using OpenRA.Support;
namespace OpenRA
{
public enum MapStatus { Available, Unavailable, Searching, DownloadAvailable, Downloading, DownloadError }
// Used for grouping maps in the UI
[Flags]
public enum MapClassification
{
Unknown = 0,
System = 1,
User = 2,
Remote = 4
}
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1307:AccessibleFieldsMustBeginWithUpperCaseLetter",
Justification = "Fields names must match the with the remote API.")]
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1304:NonPrivateReadonlyFieldsMustBeginWithUpperCaseLetter",
Justification = "Fields names must match the with the remote API.")]
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1310:FieldNamesMustNotContainUnderscore",
Justification = "Fields names must match the with the remote API.")]
public class RemoteMapData
{
public readonly string title;
public readonly string author;
public readonly string[] categories;
public readonly int players;
public readonly Rectangle bounds;
public readonly short[] spawnpoints = { };
public readonly MapGridType map_grid_type;
public readonly string minimap;
public readonly bool downloading;
public readonly string tileset;
public readonly string rules;
public readonly string players_block;
}
public class MapPreview : IDisposable, IReadOnlyFileSystem
{
/// <summary>Wrapper that enables map data to be replaced in an atomic fashion</summary>
class InnerData
{
public string Title;
public string[] Categories;
public string Author;
public string TileSet;
public MapPlayers Players;
public int PlayerCount;
public CPos[] SpawnPoints;
public MapGridType GridType;
public Rectangle Bounds;
public Png Preview;
public MapStatus Status;
public MapClassification Class;
public MapVisibility Visibility;
public MiniYaml RuleDefinitions;
public MiniYaml WeaponDefinitions;
public MiniYaml VoiceDefinitions;
public MiniYaml MusicDefinitions;
public MiniYaml NotificationDefinitions;
public MiniYaml SequenceDefinitions;
public MiniYaml ModelSequenceDefinitions;
public ActorInfo WorldActorInfo { get; private set; }
public ActorInfo PlayerActorInfo { get; private set; }
static MiniYaml LoadRuleSection(Dictionary<string, MiniYaml> yaml, string section)
{
if (!yaml.TryGetValue(section, out var node))
return null;
return node;
}
public void SetCustomRules(ModData modData, IReadOnlyFileSystem fileSystem, Dictionary<string, MiniYaml> yaml)
{
RuleDefinitions = LoadRuleSection(yaml, "Rules");
WeaponDefinitions = LoadRuleSection(yaml, "Weapons");
VoiceDefinitions = LoadRuleSection(yaml, "Voices");
MusicDefinitions = LoadRuleSection(yaml, "Music");
NotificationDefinitions = LoadRuleSection(yaml, "Notifications");
SequenceDefinitions = LoadRuleSection(yaml, "Sequences");
ModelSequenceDefinitions = LoadRuleSection(yaml, "ModelSequences");
try
{
var rules = Ruleset.Load(modData, fileSystem, TileSet, RuleDefinitions,
WeaponDefinitions, VoiceDefinitions, NotificationDefinitions,
MusicDefinitions, SequenceDefinitions, ModelSequenceDefinitions);
WorldActorInfo = rules.Actors[SystemActors.World];
PlayerActorInfo = rules.Actors[SystemActors.Player];
}
catch (Exception e)
{
Log.Write("debug", "Failed to load rules for `{0}` with error :{1}", Title, e.Message);
WorldActorInfo = modData.DefaultRules.Actors[SystemActors.World];
PlayerActorInfo = modData.DefaultRules.Actors[SystemActors.Player];
}
}
public InnerData Clone()
{
return (InnerData)MemberwiseClone();
}
}
static readonly CPos[] NoSpawns = { };
readonly MapCache cache;
readonly ModData modData;
public readonly string Uid;
public IReadOnlyPackage Package { get; private set; }
IReadOnlyPackage parentPackage;
volatile InnerData innerData;
public string Title => innerData.Title;
public string[] Categories => innerData.Categories;
public string Author => innerData.Author;
public string TileSet => innerData.TileSet;
public MapPlayers Players => innerData.Players;
public int PlayerCount => innerData.PlayerCount;
public CPos[] SpawnPoints => innerData.SpawnPoints;
public MapGridType GridType => innerData.GridType;
public Rectangle Bounds => innerData.Bounds;
public Png Preview => innerData.Preview;
public MapStatus Status => innerData.Status;
public MapClassification Class => innerData.Class;
public MapVisibility Visibility => innerData.Visibility;
public ActorInfo WorldActorInfo => innerData.WorldActorInfo;
public ActorInfo PlayerActorInfo => innerData.PlayerActorInfo;
public long DownloadBytes { get; private set; }
public int DownloadPercentage { get; private set; }
Sprite minimap;
bool generatingMinimap;
public Sprite GetMinimap()
{
if (minimap != null)
return minimap;
if (!generatingMinimap && Status == MapStatus.Available)
{
generatingMinimap = true;
cache.CacheMinimap(this);
}
return null;
}
internal void SetMinimap(Sprite minimap)
{
this.minimap = minimap;
generatingMinimap = false;
}
public bool DefinesUnsafeCustomRules()
{
return Ruleset.DefinesUnsafeCustomRules(modData, this, innerData.RuleDefinitions,
innerData.WeaponDefinitions, innerData.VoiceDefinitions,
innerData.NotificationDefinitions, innerData.SequenceDefinitions);
}
public Ruleset LoadRuleset()
{
return Ruleset.Load(modData, this, TileSet, innerData.RuleDefinitions,
innerData.WeaponDefinitions, innerData.VoiceDefinitions, innerData.NotificationDefinitions,
innerData.MusicDefinitions, innerData.SequenceDefinitions, innerData.ModelSequenceDefinitions);
}
public MapPreview(ModData modData, string uid, MapGridType gridType, MapCache cache)
{
this.cache = cache;
this.modData = modData;
Uid = uid;
innerData = new InnerData
{
Title = "Unknown Map",
Categories = new[] { "Unknown" },
Author = "Unknown Author",
TileSet = "unknown",
Players = null,
PlayerCount = 0,
SpawnPoints = NoSpawns,
GridType = gridType,
Bounds = Rectangle.Empty,
Preview = null,
Status = MapStatus.Unavailable,
Class = MapClassification.Unknown,
Visibility = MapVisibility.Lobby,
};
}
public void UpdateFromMap(IReadOnlyPackage p, IReadOnlyPackage parent, MapClassification classification, string[] mapCompatibility, MapGridType gridType)
{
Dictionary<string, MiniYaml> yaml;
using (var yamlStream = p.GetStream("map.yaml"))
{
if (yamlStream == null)
throw new FileNotFoundException("Required file map.yaml not present in this map");
yaml = new MiniYaml(null, MiniYaml.FromStream(yamlStream, "map.yaml", stringPool: cache.StringPool)).ToDictionary();
}
Package = p;
parentPackage = parent;
var newData = innerData.Clone();
newData.GridType = gridType;
newData.Class = classification;
if (yaml.TryGetValue("MapFormat", out var temp))
{
var format = FieldLoader.GetValue<int>("MapFormat", temp.Value);
if (format != Map.SupportedMapFormat)
throw new InvalidDataException("Map format {0} is not supported.".F(format));
}
if (yaml.TryGetValue("Title", out temp))
newData.Title = temp.Value;
if (yaml.TryGetValue("Categories", out temp))
newData.Categories = FieldLoader.GetValue<string[]>("Categories", temp.Value);
if (yaml.TryGetValue("Tileset", out temp))
newData.TileSet = temp.Value;
if (yaml.TryGetValue("Author", out temp))
newData.Author = temp.Value;
if (yaml.TryGetValue("Bounds", out temp))
newData.Bounds = FieldLoader.GetValue<Rectangle>("Bounds", temp.Value);
if (yaml.TryGetValue("Visibility", out temp))
newData.Visibility = FieldLoader.GetValue<MapVisibility>("Visibility", temp.Value);
string requiresMod = string.Empty;
if (yaml.TryGetValue("RequiresMod", out temp))
requiresMod = temp.Value;
newData.Status = mapCompatibility == null || mapCompatibility.Contains(requiresMod) ?
MapStatus.Available : MapStatus.Unavailable;
try
{
// Actor definitions may change if the map format changes
if (yaml.TryGetValue("Actors", out var actorDefinitions))
{
var spawns = new List<CPos>();
foreach (var kv in actorDefinitions.Nodes.Where(d => d.Value.Value == "mpspawn"))
{
var s = new ActorReference(kv.Value.Value, kv.Value.ToDictionary());
spawns.Add(s.Get<LocationInit>().Value);
}
newData.SpawnPoints = spawns.ToArray();
}
else
newData.SpawnPoints = new CPos[0];
}
catch (Exception)
{
newData.SpawnPoints = new CPos[0];
newData.Status = MapStatus.Unavailable;
}
try
{
// Player definitions may change if the map format changes
if (yaml.TryGetValue("Players", out var playerDefinitions))
{
newData.Players = new MapPlayers(playerDefinitions.Nodes);
newData.PlayerCount = newData.Players.Players.Count(x => x.Value.Playable);
}
}
catch (Exception)
{
newData.Status = MapStatus.Unavailable;
}
newData.SetCustomRules(modData, this, yaml);
if (p.Contains("map.png"))
using (var dataStream = p.GetStream("map.png"))
newData.Preview = new Png(dataStream);
// Assign the new data atomically
innerData = newData;
}
public void UpdateRemoteSearch(MapStatus status, MiniYaml yaml, Action<MapPreview> parseMetadata = null)
{
var newData = innerData.Clone();
newData.Status = status;
newData.Class = MapClassification.Remote;
if (status == MapStatus.DownloadAvailable)
{
try
{
var r = FieldLoader.Load<RemoteMapData>(yaml);
// Map download has been disabled server side
if (!r.downloading)
{
newData.Status = MapStatus.Unavailable;
return;
}
newData.Title = r.title;
newData.Categories = r.categories;
newData.Author = r.author;
newData.PlayerCount = r.players;
newData.Bounds = r.bounds;
newData.TileSet = r.tileset;
var spawns = new CPos[r.spawnpoints.Length / 2];
for (var j = 0; j < r.spawnpoints.Length; j += 2)
spawns[j / 2] = new CPos(r.spawnpoints[j], r.spawnpoints[j + 1]);
newData.SpawnPoints = spawns;
newData.GridType = r.map_grid_type;
try
{
newData.Preview = new Png(new MemoryStream(Convert.FromBase64String(r.minimap)));
}
catch (Exception e)
{
Log.Write("debug", "Failed parsing mapserver minimap response: {0}", e);
newData.Preview = null;
}
var playersString = Encoding.UTF8.GetString(Convert.FromBase64String(r.players_block));
newData.Players = new MapPlayers(MiniYaml.FromString(playersString));
var rulesString = Encoding.UTF8.GetString(Convert.FromBase64String(r.rules));
var rulesYaml = new MiniYaml("", MiniYaml.FromString(rulesString)).ToDictionary();
newData.SetCustomRules(modData, this, rulesYaml);
}
catch (Exception e)
{
Log.Write("debug", "Failed parsing mapserver response: {0}", e);
}
// Commit updated data before running the callbacks
innerData = newData;
if (innerData.Preview != null)
cache.CacheMinimap(this);
parseMetadata?.Invoke(this);
}
// Update the status and class unconditionally
innerData = newData;
}
public void Install(string mapRepositoryUrl, Action onSuccess)
{
if ((Status != MapStatus.DownloadError && Status != MapStatus.DownloadAvailable) || !Game.Settings.Game.AllowDownloading)
return;
innerData.Status = MapStatus.Downloading;
var installLocation = cache.MapLocations.FirstOrDefault(p => p.Value == MapClassification.User);
if (installLocation.Key == null || !(installLocation.Key is IReadWritePackage))
{
Log.Write("debug", "Map install directory not found");
innerData.Status = MapStatus.DownloadError;
return;
}
var mapInstallPackage = installLocation.Key as IReadWritePackage;
Task.Run(async () =>
{
// Request the filename from the server
// Run in a worker thread to avoid network delays
var mapUrl = mapRepositoryUrl + Uid;
try
{
void OnDownloadProgress(long total, long received, int percentage)
{
DownloadBytes = total;
DownloadPercentage = percentage;
}
var client = HttpClientFactory.Create();
var response = await client.GetAsync(mapUrl, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
innerData.Status = MapStatus.DownloadError;
return;
}
response.Headers.TryGetValues("Content-Disposition", out var values);
var mapFilename = values.First().Replace("attachment; filename = ", "");
var fileStream = new MemoryStream();
await response.ReadAsStreamWithProgress(fileStream, OnDownloadProgress, CancellationToken.None);
mapInstallPackage.Update(mapFilename, fileStream.ToArray());
Log.Write("debug", "Downloaded map to '{0}'", mapFilename);
var package = mapInstallPackage.OpenPackage(mapFilename, modData.ModFiles);
if (package == null)
innerData.Status = MapStatus.DownloadError;
else
{
UpdateFromMap(package, mapInstallPackage, MapClassification.User, null, GridType);
Game.RunAfterTick(onSuccess);
}
}
catch (Exception e)
{
Log.Write("debug", "Map installation failed with error: {0}", e);
innerData.Status = MapStatus.DownloadError;
}
});
}
public void Invalidate()
{
innerData.Status = MapStatus.Unavailable;
}
public void Dispose()
{
if (Package != null)
{
Package.Dispose();
Package = null;
}
}
public void Delete()
{
Invalidate();
(parentPackage as IReadWritePackage)?.Delete(Package.Name);
}
Stream IReadOnlyFileSystem.Open(string filename)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|") && Package.Contains(filename))
return Package.GetStream(filename);
return modData.DefaultFileSystem.Open(filename);
}
bool IReadOnlyFileSystem.TryGetPackageContaining(string path, out IReadOnlyPackage package, out string filename)
{
// Packages aren't supported inside maps
return modData.DefaultFileSystem.TryGetPackageContaining(path, out package, out filename);
}
bool IReadOnlyFileSystem.TryOpen(string filename, out Stream s)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|"))
{
s = Package.GetStream(filename);
if (s != null)
return true;
}
return modData.DefaultFileSystem.TryOpen(filename, out s);
}
bool IReadOnlyFileSystem.Exists(string filename)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|") && Package.Contains(filename))
return true;
return modData.DefaultFileSystem.Exists(filename);
}
bool IReadOnlyFileSystem.IsExternalModFile(string filename)
{
// Explicit package paths never refer to a map
if (filename.Contains("|"))
return modData.DefaultFileSystem.IsExternalModFile(filename);
return false;
}
}
}