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.
523 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|