diff --git a/OpenRA.Game/FileSystem/FileSystem.cs b/OpenRA.Game/FileSystem/FileSystem.cs index 76be7dd17e..9c92de38bb 100644 --- a/OpenRA.Game/FileSystem/FileSystem.cs +++ b/OpenRA.Game/FileSystem/FileSystem.cs @@ -64,6 +64,32 @@ namespace OpenRA.FileSystem return new Folder(Platform.ResolvePath(filename)); } + public IReadOnlyPackage OpenPackage(string filename, IReadOnlyPackage parent) + { + // HACK: limit support to zip and folder until we generalize the PackageLoader support + if (parent is Folder) + { + var path = Path.Combine(parent.Name, filename); + + // HACK: work around SharpZipLib's lack of support for writing to in-memory files + if (filename.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) + return new ZipFile(this, path); + if (filename.EndsWith(".oramap", StringComparison.InvariantCultureIgnoreCase)) + return new ZipFile(this, path); + + var subFolder = Platform.ResolvePath(path); + if (Directory.Exists(subFolder)) + return new Folder(subFolder); + } + + if (filename.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) + return new ZipFile(this, filename, parent.GetStream(filename)); + if (filename.EndsWith(".oramap", StringComparison.InvariantCultureIgnoreCase)) + return new ZipFile(this, filename, parent.GetStream(filename)); + + return null; + } + public IReadWritePackage OpenWritablePackage(string filename) { if (filename.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) diff --git a/OpenRA.Game/FileSystem/Folder.cs b/OpenRA.Game/FileSystem/Folder.cs index 7219fba7df..ebdbe08069 100644 --- a/OpenRA.Game/FileSystem/Folder.cs +++ b/OpenRA.Game/FileSystem/Folder.cs @@ -33,6 +33,8 @@ namespace OpenRA.FileSystem { foreach (var filename in Directory.GetFiles(path, "*", SearchOption.TopDirectoryOnly)) yield return Path.GetFileName(filename); + foreach (var filename in Directory.GetDirectories(path)) + yield return Path.GetFileName(filename); } } @@ -59,7 +61,12 @@ namespace OpenRA.FileSystem public void Delete(string filename) { - var filePath = Path.Combine(path, filename); + // HACK: ZipFiles can't be loaded as read-write from a stream, so we are + // forced to bypass the parent package and load them with their full path + // in FileSystem.OpenPackage. Their internal name therefore contains the + // full parent path too. We need to be careful to not add a second path + // prefix to these hacked packages. + var filePath = filename.StartsWith(path) ? filename : Path.Combine(path, filename); if (Directory.Exists(filePath)) Directory.Delete(filePath, true); else if (File.Exists(filePath)) diff --git a/OpenRA.Game/FileSystem/ZipFile.cs b/OpenRA.Game/FileSystem/ZipFile.cs index 376e797e81..217ec076e5 100644 --- a/OpenRA.Game/FileSystem/ZipFile.cs +++ b/OpenRA.Game/FileSystem/ZipFile.cs @@ -27,6 +27,16 @@ namespace OpenRA.FileSystem ZipConstants.DefaultCodePage = Encoding.UTF8.CodePage; } + public ZipFile(FileSystem context, string filename, Stream stream, bool createOrClearContents = false) + { + Name = filename; + + if (createOrClearContents) + pkg = SZipFile.Create(stream); + else + pkg = new SZipFile(stream); + } + public ZipFile(IReadOnlyFileSystem context, string filename, bool createOrClearContents = false) { Name = filename; diff --git a/OpenRA.Game/Map/Map.cs b/OpenRA.Game/Map/Map.cs index b4a1303f4b..33518de4ff 100644 --- a/OpenRA.Game/Map/Map.cs +++ b/OpenRA.Game/Map/Map.cs @@ -492,7 +492,7 @@ namespace OpenRA public void Save(IReadWritePackage toPackage) { - MapFormat = 8; + MapFormat = SupportedMapFormat; var root = new List(); var fields = new[] @@ -534,10 +534,12 @@ namespace OpenRA root.Add(new MiniYamlNode("Notifications", null, NotificationDefinitions)); root.Add(new MiniYamlNode("Translations", null, TranslationDefinitions)); + // Saving to a new package: copy over all the content from the map if (Package != null && toPackage != Package) foreach (var file in Package.Contents) toPackage.Update(file, Package.GetStream(file).ReadAllBytes()); + // Update the package with the new map data var s = root.WriteToString(); toPackage.Update("map.yaml", Encoding.UTF8.GetBytes(s)); toPackage.Update("map.bin", SaveBinaryData()); diff --git a/OpenRA.Game/Map/MapCache.cs b/OpenRA.Game/Map/MapCache.cs index f837de10e0..e3722fc8d6 100644 --- a/OpenRA.Game/Map/MapCache.cs +++ b/OpenRA.Game/Map/MapCache.cs @@ -26,6 +26,8 @@ namespace OpenRA public sealed class MapCache : IEnumerable, IDisposable { public static readonly MapPreview UnknownMap = new MapPreview(null, MapGridType.Rectangular, null); + public readonly IReadOnlyDictionary MapLocations; + readonly Cache previews; readonly ModData modData; readonly SheetBuilder sheetBuilder; @@ -41,6 +43,36 @@ namespace OpenRA var gridType = Exts.Lazy(() => modData.Manifest.Get().Type); previews = new Cache(uid => new MapPreview(uid, gridType.Value, this)); sheetBuilder = new SheetBuilder(SheetType.BGRA); + + // Enumerate map directories + var mapLocations = new Dictionary(); + foreach (var kv in modData.Manifest.MapFolders) + { + var name = kv.Key; + var classification = string.IsNullOrEmpty(kv.Value) + ? MapClassification.Unknown : Enum.Parse(kv.Value); + + IReadOnlyPackage package; + var optional = name.StartsWith("~"); + if (optional) + name = name.Substring(1); + + try + { + package = modData.ModFiles.OpenPackage(name); + } + catch + { + if (optional) + continue; + + throw; + } + + mapLocations.Add(package, classification); + } + + MapLocations = new ReadOnlyDictionary(mapLocations); } public void LoadMaps() @@ -49,33 +81,33 @@ namespace OpenRA if (!modData.Manifest.Contains()) return; - // Expand the dictionary (dir path, dir type) to a dictionary of (map path, dir type) - var mapPaths = modData.Manifest.MapFolders.SelectMany(kv => - FindMapsIn(modData.ModFiles, kv.Key).ToDictionary(p => p, p => string.IsNullOrEmpty(kv.Value) - ? MapClassification.Unknown : Enum.Parse(kv.Value))); - - var mapGrid = modData.Manifest.Get(); - foreach (var path in mapPaths) + var mapGrid = Game.ModData.Manifest.Get(); + foreach (var kv in MapLocations) { - IReadOnlyPackage package; - try + foreach (var map in kv.Key.Contents) { - using (new Support.PerfTimer(path.Key)) + IReadOnlyPackage mapPackage = null; + try { - package = modData.ModFiles.OpenPackage(path.Key); - var uid = Map.ComputeUID(package); - previews[uid].UpdateFromMap(package, path.Value, modData.Manifest.MapCompatibility, mapGrid.Type); - } - } - catch (Exception e) - { - if (package != null) - package.Dispose(); + using (new Support.PerfTimer(map)) + { + mapPackage = modData.ModFiles.OpenPackage(map, kv.Key); + if (mapPackage == null) + continue; - Console.WriteLine("Failed to load map: {0}", path); - Console.WriteLine("Details: {0}", e); - Log.Write("debug", "Failed to load map: {0}", path); - Log.Write("debug", "Details: {0}", e); + var uid = Map.ComputeUID(mapPackage); + previews[uid].UpdateFromMap(mapPackage, kv.Key, kv.Value, modData.Manifest.MapCompatibility, mapGrid.Type); + } + } + catch (Exception e) + { + if (mapPackage != null) + mapPackage.Dispose(); + Console.WriteLine("Failed to load map: {0}", map); + Console.WriteLine("Details: {0}", e); + Log.Write("debug", "Failed to load map: {0}", map); + Log.Write("debug", "Details: {0}", e); + } } } } @@ -123,31 +155,6 @@ namespace OpenRA new Download(url, _ => { }, onInfoComplete); } - public static IEnumerable FindMapsIn(FileSystem.FileSystem context, string dir) - { - string[] noMaps = { }; - - // Ignore optional flag - if (dir.StartsWith("~")) - dir = dir.Substring(1); - - // HACK: We currently only support maps loaded from Folders - // This is a temporary workaround that resolves the filesystem paths to a system directory - IReadOnlyPackage package; - string filename; - if (context.TryGetPackageContaining(dir, out package, out filename)) - dir = Path.Combine(package.Name, filename); - else if (Directory.Exists(Platform.ResolvePath(dir))) - dir = Platform.ResolvePath(dir); - else - return noMaps; - - var dirsWithMaps = Directory.GetDirectories(dir) - .Where(d => Directory.GetFiles(d, "map.yaml").Any() && Directory.GetFiles(d, "map.bin").Any()); - - return dirsWithMaps.Concat(Directory.GetFiles(dir, "*.oramap")); - } - void LoadAsyncInternal() { Log.Write("debug", "MapCache.LoadAsyncInternal started"); diff --git a/OpenRA.Game/Map/MapPreview.cs b/OpenRA.Game/Map/MapPreview.cs index 0877c9de83..f181a509ff 100644 --- a/OpenRA.Game/Map/MapPreview.cs +++ b/OpenRA.Game/Map/MapPreview.cs @@ -60,6 +60,7 @@ namespace OpenRA public readonly string Uid; public IReadOnlyPackage Package { get; private set; } + IReadOnlyPackage parentPackage; public string Title { get; private set; } public string Type { get; private set; } @@ -116,7 +117,7 @@ namespace OpenRA Visibility = MapVisibility.Lobby; } - public void UpdateFromMap(IReadOnlyPackage p, MapClassification classification, string[] mapCompatibility, MapGridType gridType) + public void UpdateFromMap(IReadOnlyPackage p, IReadOnlyPackage parent, MapClassification classification, string[] mapCompatibility, MapGridType gridType) { Dictionary yaml; using (var yamlStream = p.GetStream("map.yaml")) @@ -128,6 +129,7 @@ namespace OpenRA } Package = p; + parentPackage = parent; GridType = gridType; Class = classification; @@ -270,11 +272,7 @@ namespace OpenRA return; Status = MapStatus.Downloading; - var baseMapPath = Platform.ResolvePath("^", "maps", Game.ModData.Manifest.Mod.Id); - - // Create the map directory if it doesn't exist - if (!Directory.Exists(baseMapPath)) - Directory.CreateDirectory(baseMapPath); + var mapInstallPackage = new Folder(Platform.ResolvePath("^", "maps", Game.ModData.Manifest.Mod.Id)); var modData = Game.ModData; new Thread(() => @@ -282,7 +280,7 @@ namespace OpenRA // Request the filename from the server // Run in a worker thread to avoid network delays var mapUrl = Game.Settings.Game.MapRepository + Uid; - var mapPath = string.Empty; + var mapFilename = string.Empty; try { var request = WebRequest.Create(mapUrl); @@ -296,11 +294,11 @@ namespace OpenRA return; } - mapPath = System.IO.Path.Combine(baseMapPath, res.Headers["Content-Disposition"].Replace("attachment; filename = ", "")); + mapFilename = res.Headers["Content-Disposition"].Replace("attachment; filename = ", ""); } Action onDownloadProgress = i => { DownloadBytes = i.BytesReceived; DownloadPercentage = i.ProgressPercentage; }; - Action onDownloadComplete = (i, cancelled) => + Action onDownloadComplete = (i, cancelled) => { download = null; @@ -313,15 +311,16 @@ namespace OpenRA return; } - Log.Write("debug", "Downloaded map to '{0}'", mapPath); + mapInstallPackage.Update(mapFilename, i.Result); + Log.Write("debug", "Downloaded map to '{0}'", mapFilename); Game.RunAfterTick(() => { - using (var package = modData.ModFiles.OpenPackage(mapPath)) - UpdateFromMap(package, MapClassification.User, null, GridType); + var package = modData.ModFiles.OpenPackage(mapFilename, mapInstallPackage); + UpdateFromMap(package, mapInstallPackage, MapClassification.User, null, GridType); }); }; - download = new Download(mapUrl, mapPath, onDownloadProgress, onDownloadComplete); + download = new Download(mapUrl, onDownloadProgress, onDownloadComplete); } catch (Exception e) { @@ -353,5 +352,13 @@ namespace OpenRA Package = null; } } + + public void Delete() + { + Invalidate(); + var deleteFromPackage = parentPackage as IReadWritePackage; + if (deleteFromPackage != null) + deleteFromPackage.Delete(Package.Name); + } } } diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/SaveMapLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/SaveMapLogic.cs index e20172d45e..dde32ffca1 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/SaveMapLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/SaveMapLogic.cs @@ -28,6 +28,20 @@ namespace OpenRA.Mods.Common.Widgets.Logic public string UiLabel; } + class SaveDirectory + { + public readonly Folder Folder; + public readonly string DisplayName; + public readonly MapClassification Classification; + + public SaveDirectory(Folder folder, MapClassification classification) + { + Folder = folder; + DisplayName = Platform.UnresolvePath(Folder.Name); + Classification = classification; + } + } + [ObjectCreator.UseCtor] public SaveMapLogic(Widget widget, ModData modData, Action onSave, Action onExit, Map map, List playerDefinitions, List actorDefinitions) @@ -60,58 +74,57 @@ namespace OpenRA.Mods.Common.Widgets.Logic visibilityDropdown.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 210, mapVisibility, setupItem); } - Func makeMapDirectory = dir => - { - if (dir.StartsWith("~")) - dir = dir.Substring(1); + var writableDirectories = new List(); + SaveDirectory selectedDirectory = null; - IReadOnlyPackage package; - string f; - if (modData.ModFiles.TryGetPackageContaining(dir, out package, out f)) - dir = Path.Combine(package.Name, f); - - return Platform.UnresolvePath(dir); - }; - - var mapDirectories = modData.Manifest.MapFolders - .ToDictionary(kv => makeMapDirectory(kv.Key), kv => Enum.Parse(kv.Value)); - - var mapPath = map.Package != null ? map.Package.Name : null; var directoryDropdown = widget.Get("DIRECTORY_DROPDOWN"); { - Func setupItem = (option, template) => + Func setupItem = (option, template) => { var item = ScrollItemWidget.Setup(template, - () => directoryDropdown.Text == option, - () => directoryDropdown.Text = option); - item.Get("LABEL").GetText = () => option; + () => selectedDirectory == option, + () => selectedDirectory = option); + item.Get("LABEL").GetText = () => option.DisplayName; return item; }; - // TODO: This won't work for maps inside oramod packages - var mapDirectory = mapPath != null ? Platform.UnresolvePath(Path.GetDirectoryName(mapPath)) : null; - var initialDirectory = mapDirectories.Keys.FirstOrDefault(f => f == mapDirectory); + foreach (var kv in modData.MapCache.MapLocations) + { + var folder = kv.Key as Folder; + if (folder == null) + continue; + + try + { + using (var fs = File.Create(Path.Combine(folder.Name, ".testwritable"), 1, FileOptions.DeleteOnClose)) + { + // Do nothing: we just want to test whether we can create the file + } + + writableDirectories.Add(new SaveDirectory(folder, kv.Value)); + } + catch + { + // Directory is not writable + } + } + + if (map.Package != null) + selectedDirectory = writableDirectories.FirstOrDefault(k => k.Folder.Contains(map.Package.Name)); // Prioritize MapClassification.User directories over system directories - if (initialDirectory == null) - initialDirectory = mapDirectories.OrderByDescending(kv => kv.Value).First().Key; + if (selectedDirectory == null) + selectedDirectory = writableDirectories.OrderByDescending(kv => kv.Classification).First(); - directoryDropdown.Text = initialDirectory; + directoryDropdown.GetText = () => selectedDirectory == null ? "" : selectedDirectory.DisplayName; directoryDropdown.OnClick = () => - directoryDropdown.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 210, mapDirectories.Keys, setupItem); + directoryDropdown.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 210, writableDirectories, setupItem); } - var mapIsUnpacked = false; - - // TODO: This won't work for maps inside oramod packages - if (mapPath != null) - { - var attr = File.GetAttributes(mapPath); - mapIsUnpacked = attr.HasFlag(FileAttributes.Directory); - } + var mapIsUnpacked = map.Package != null && map.Package is Folder; var filename = widget.Get("FILENAME"); - filename.Text = mapIsUnpacked ? Path.GetFileName(mapPath) : Path.GetFileNameWithoutExtension(mapPath); + filename.Text = map.Package == null ? "" : mapIsUnpacked ? Path.GetFileName(map.Package.Name) : Path.GetFileNameWithoutExtension(map.Package.Name); var fileType = mapIsUnpacked ? MapFileType.Unpacked : MapFileType.OraMap; var fileTypes = new Dictionary() @@ -159,46 +172,33 @@ namespace OpenRA.Mods.Common.Widgets.Logic map.RequiresMod = modData.Manifest.Mod.Id; - // Create the map directory if required - Directory.CreateDirectory(Platform.ResolvePath(directoryDropdown.Text)); - - // TODO: This won't work for maps inside oramod packages - var combinedPath = Platform.ResolvePath(Path.Combine(directoryDropdown.Text, filename.Text + fileTypes[fileType].Extension)); + var combinedPath = Platform.ResolvePath(Path.Combine(selectedDirectory.Folder.Name, filename.Text + fileTypes[fileType].Extension)); // Invalidate the old map metadata - if (map.Uid != null && combinedPath == mapPath) + if (map.Uid != null && map.Package != null && map.Package.Name == combinedPath) modData.MapCache[map.Uid].Invalidate(); - var package = map.Package as IReadWritePackage; - if (package == null || package.Name != combinedPath) + try { - try + var package = map.Package as IReadWritePackage; + if (package == null || package.Name != combinedPath) { + selectedDirectory.Folder.Delete(combinedPath); if (fileType == MapFileType.OraMap) - { - if (File.Exists(combinedPath)) - File.Delete(combinedPath); - package = new ZipFile(modData.DefaultFileSystem, combinedPath, true); - } else - { - if (Directory.Exists(combinedPath)) - Directory.Delete(combinedPath, true); package = new Folder(combinedPath); - } + } - map.Save(package); - } - catch - { - Console.WriteLine("Failed to save map at {0}", combinedPath); - } + map.Save(package); + } + catch + { + Console.WriteLine("Failed to save map at {0}", combinedPath); } // Update the map cache so it can be loaded without restarting the game - var classification = mapDirectories[directoryDropdown.Text]; - modData.MapCache[map.Uid].UpdateFromMap(map.Package, classification, null, map.Grid.Type); + modData.MapCache[map.Uid].UpdateFromMap(map.Package, selectedDirectory.Folder, selectedDirectory.Classification, null, map.Grid.Type); Console.WriteLine("Saved current map at {0}", combinedPath); Ui.CloseWindow(); diff --git a/OpenRA.Mods.Common/Widgets/Logic/MapChooserLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/MapChooserLogic.cs index 9149a2fde1..a1ce1dd4a8 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/MapChooserLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/MapChooserLogic.cs @@ -288,22 +288,15 @@ namespace OpenRA.Mods.Common.Widgets.Logic string DeleteMap(string map) { - var path = modData.MapCache[map].Package.Name; try { - if (File.Exists(path)) - File.Delete(path); - else if (Directory.Exists(path)) - Directory.Delete(path, true); - - modData.MapCache[map].Invalidate(); - + modData.MapCache[map].Delete(); if (selectedUid == map) selectedUid = WidgetUtils.ChooseInitialMap(tabMaps[currentTab].Select(mp => mp.Uid).FirstOrDefault()); } catch (Exception ex) { - Game.Debug("Failed to delete map '{0}'. See the debug.log file for details.", path); + Game.Debug("Failed to delete map '{0}'. See the debug.log file for details.", map); Log.Write("debug", ex.ToString()); }