Implement game save/load backend.
This commit is contained in:
@@ -561,7 +561,7 @@ namespace OpenRA
|
|||||||
Cursor.Tick();
|
Cursor.Tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
var worldTimestep = world == null ? Timestep : world.Timestep;
|
var worldTimestep = world == null ? Timestep : world.IsLoadingGameSave ? 1 : world.Timestep;
|
||||||
var worldTickDelta = tick - orderManager.LastTickTime;
|
var worldTickDelta = tick - orderManager.LastTickTime;
|
||||||
if (worldTimestep != 0 && worldTickDelta >= worldTimestep)
|
if (worldTimestep != 0 && worldTickDelta >= worldTimestep)
|
||||||
{
|
{
|
||||||
@@ -645,7 +645,10 @@ namespace OpenRA
|
|||||||
{
|
{
|
||||||
Renderer.BeginFrame(worldRenderer.Viewport.TopLeft, worldRenderer.Viewport.Zoom);
|
Renderer.BeginFrame(worldRenderer.Viewport.TopLeft, worldRenderer.Viewport.Zoom);
|
||||||
Sound.SetListenerPosition(worldRenderer.Viewport.CenterPosition);
|
Sound.SetListenerPosition(worldRenderer.Viewport.CenterPosition);
|
||||||
worldRenderer.Draw();
|
|
||||||
|
// Use worldRenderer.World instead of OrderManager.World to avoid a rendering mismatch while processing orders
|
||||||
|
if (!worldRenderer.World.IsLoadingGameSave)
|
||||||
|
worldRenderer.Draw();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
Renderer.BeginFrame(int2.Zero, 1f);
|
Renderer.BeginFrame(int2.Zero, 1f);
|
||||||
@@ -743,6 +746,13 @@ namespace OpenRA
|
|||||||
var maxFramerate = Settings.Graphics.CapFramerate ? Settings.Graphics.MaxFramerate.Clamp(1, 1000) : 1000;
|
var maxFramerate = Settings.Graphics.CapFramerate ? Settings.Graphics.MaxFramerate.Clamp(1, 1000) : 1000;
|
||||||
var renderInterval = 1000 / maxFramerate;
|
var renderInterval = 1000 / maxFramerate;
|
||||||
|
|
||||||
|
// Tick as fast as possible while restoring game saves, capping rendering at 5 FPS
|
||||||
|
if (OrderManager.World != null && OrderManager.World.IsLoadingGameSave)
|
||||||
|
{
|
||||||
|
logicInterval = 1;
|
||||||
|
renderInterval = 200;
|
||||||
|
}
|
||||||
|
|
||||||
var now = RunTime;
|
var now = RunTime;
|
||||||
|
|
||||||
// If the logic has fallen behind too much, skip it and catch up
|
// If the logic has fallen behind too much, skip it and catch up
|
||||||
@@ -762,7 +772,7 @@ namespace OpenRA
|
|||||||
LogicTick();
|
LogicTick();
|
||||||
|
|
||||||
// Force at least one render per tick during regular gameplay
|
// Force at least one render per tick during regular gameplay
|
||||||
if (OrderManager.World != null && !OrderManager.World.IsReplay)
|
if (OrderManager.World != null && !OrderManager.World.IsLoadingGameSave)
|
||||||
forceRender = true;
|
forceRender = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
313
OpenRA.Game/Network/GameSave.cs
Normal file
313
OpenRA.Game/Network/GameSave.cs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
#region Copyright & License Information
|
||||||
|
/*
|
||||||
|
* Copyright 2007-2019 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.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using OpenRA.Primitives;
|
||||||
|
using OpenRA.Server;
|
||||||
|
|
||||||
|
namespace OpenRA.Network
|
||||||
|
{
|
||||||
|
public class SlotClient
|
||||||
|
{
|
||||||
|
public readonly Color Color;
|
||||||
|
public readonly string Faction;
|
||||||
|
public readonly int SpawnPoint;
|
||||||
|
public readonly int Team;
|
||||||
|
public readonly string Slot;
|
||||||
|
public readonly string Bot;
|
||||||
|
public readonly bool IsAdmin;
|
||||||
|
|
||||||
|
public readonly string BotName;
|
||||||
|
|
||||||
|
public SlotClient() { }
|
||||||
|
|
||||||
|
public SlotClient(Session.Client client)
|
||||||
|
{
|
||||||
|
Color = client.Color;
|
||||||
|
Faction = client.Faction;
|
||||||
|
SpawnPoint = client.SpawnPoint;
|
||||||
|
Team = client.Team;
|
||||||
|
Slot = client.Slot;
|
||||||
|
Bot = client.Bot;
|
||||||
|
IsAdmin = client.IsAdmin;
|
||||||
|
|
||||||
|
if (client.Bot != null)
|
||||||
|
BotName = client.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyTo(Session.Client client)
|
||||||
|
{
|
||||||
|
client.Color = Color;
|
||||||
|
client.Faction = Faction;
|
||||||
|
client.SpawnPoint = SpawnPoint;
|
||||||
|
client.Team = Team;
|
||||||
|
client.Slot = Slot;
|
||||||
|
client.Bot = Bot;
|
||||||
|
client.IsAdmin = IsAdmin;
|
||||||
|
|
||||||
|
if (Bot != null)
|
||||||
|
client.Name = BotName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SlotClient Deserialize(MiniYaml data)
|
||||||
|
{
|
||||||
|
return FieldLoader.Load<SlotClient>(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MiniYamlNode Serialize(string key)
|
||||||
|
{
|
||||||
|
return new MiniYamlNode("SlotClient@{0}".F(key), FieldSaver.Save(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GameSave
|
||||||
|
{
|
||||||
|
public const int EOFMarker = -2;
|
||||||
|
public const int MetadataMarker = -1;
|
||||||
|
public const int TraitDataMarker = -3;
|
||||||
|
|
||||||
|
readonly MemoryStream ordersStream = new MemoryStream();
|
||||||
|
|
||||||
|
// Loaded from file and updated during gameplay
|
||||||
|
public int LastOrdersFrame { get; private set; }
|
||||||
|
public int LastSyncFrame { get; private set; }
|
||||||
|
byte[] lastSyncPacket = new byte[0];
|
||||||
|
|
||||||
|
// Loaded from file or set on game start
|
||||||
|
public Session.Global GlobalSettings { get; private set; }
|
||||||
|
public Dictionary<string, Session.Slot> Slots { get; private set; }
|
||||||
|
public Dictionary<string, SlotClient> SlotClients { get; private set; }
|
||||||
|
public Dictionary<int, MiniYaml> TraitData = new Dictionary<int, MiniYaml>();
|
||||||
|
|
||||||
|
// Set on game start
|
||||||
|
int[] clientsBySlotIndex = { };
|
||||||
|
int firstBotSlotIndex = -1;
|
||||||
|
|
||||||
|
public GameSave()
|
||||||
|
{
|
||||||
|
LastOrdersFrame = -1;
|
||||||
|
Slots = new Dictionary<string, Session.Slot>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameSave(string filepath)
|
||||||
|
{
|
||||||
|
using (var rs = File.OpenRead(filepath))
|
||||||
|
{
|
||||||
|
rs.Seek(-12, SeekOrigin.End);
|
||||||
|
var metadataOffset = rs.ReadInt32();
|
||||||
|
var traitDataOffset = rs.ReadInt32();
|
||||||
|
if (rs.ReadInt32() != EOFMarker)
|
||||||
|
throw new InvalidDataException("Invalid orasav file");
|
||||||
|
|
||||||
|
rs.Seek(metadataOffset, SeekOrigin.Begin);
|
||||||
|
if (rs.ReadInt32() != MetadataMarker)
|
||||||
|
throw new InvalidDataException("Invalid orasav file");
|
||||||
|
|
||||||
|
LastOrdersFrame = rs.ReadInt32();
|
||||||
|
LastSyncFrame = rs.ReadInt32();
|
||||||
|
lastSyncPacket = rs.ReadBytes(5);
|
||||||
|
|
||||||
|
var globalSettings = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength));
|
||||||
|
GlobalSettings = Session.Global.Deserialize(globalSettings[0].Value);
|
||||||
|
|
||||||
|
var slots = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength));
|
||||||
|
Slots = new Dictionary<string, Session.Slot>();
|
||||||
|
foreach (var s in slots)
|
||||||
|
{
|
||||||
|
var slot = Session.Slot.Deserialize(s.Value);
|
||||||
|
Slots.Add(slot.PlayerReference, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
var slotClients = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength));
|
||||||
|
SlotClients = new Dictionary<string, SlotClient>();
|
||||||
|
foreach (var s in slotClients)
|
||||||
|
{
|
||||||
|
var slotClient = SlotClient.Deserialize(s.Value);
|
||||||
|
SlotClients.Add(slotClient.Slot, slotClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rs.Position != traitDataOffset || rs.ReadInt32() != TraitDataMarker)
|
||||||
|
throw new InvalidDataException("Invalid orasav file");
|
||||||
|
|
||||||
|
var traitData = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength));
|
||||||
|
foreach (var td in traitData)
|
||||||
|
TraitData.Add(int.Parse(td.Key), td.Value);
|
||||||
|
|
||||||
|
rs.Seek(0, SeekOrigin.Begin);
|
||||||
|
ordersStream.Write(rs.ReadBytes(metadataOffset), 0, metadataOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartGame(Session lobbyInfo, MapPreview map)
|
||||||
|
{
|
||||||
|
// Game orders are mapped from a client index to the slot that they occupy
|
||||||
|
// Orders from spectators are ignored, which is not a problem in practice
|
||||||
|
// because all immediate orders are also ignored
|
||||||
|
clientsBySlotIndex = lobbyInfo.Slots.Keys.Select(s =>
|
||||||
|
{
|
||||||
|
var client = lobbyInfo.ClientInSlot(s);
|
||||||
|
return client != null ? client.Index : -1;
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
// Perform a deep clone by round-tripping the data
|
||||||
|
GlobalSettings = Session.Global.Deserialize(lobbyInfo.GlobalSettings.Serialize().Value);
|
||||||
|
Slots = new Dictionary<string, Session.Slot>();
|
||||||
|
SlotClients = new Dictionary<string, SlotClient>();
|
||||||
|
foreach (var s in lobbyInfo.Slots)
|
||||||
|
{
|
||||||
|
Slots[s.Key] = Session.Slot.Deserialize(s.Value.Serialize().Value);
|
||||||
|
|
||||||
|
var playerReference = map.Players.Players[s.Value.PlayerReference];
|
||||||
|
var client = lobbyInfo.ClientInSlot(s.Key);
|
||||||
|
|
||||||
|
// Only save the client state relevant to the game (faction, team, etc).
|
||||||
|
// Admin and bot controller state is inherited and/or reassigned by the server at load time
|
||||||
|
if (playerReference.Playable && client != null)
|
||||||
|
{
|
||||||
|
SlotClients[s.Key] = new SlotClient(client);
|
||||||
|
|
||||||
|
// See HACK comment in DispatchOrders about reassigning bot orders
|
||||||
|
if (client.Bot != null && firstBotSlotIndex < 0)
|
||||||
|
firstBotSlotIndex = clientsBySlotIndex.IndexOf(client.Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DispatchOrders(Connection conn, int frame, byte[] data)
|
||||||
|
{
|
||||||
|
// Sync packet - we only care about the last value
|
||||||
|
if (data.Length > 0 && data[0] == 0x65 && frame > LastSyncFrame)
|
||||||
|
{
|
||||||
|
LastSyncFrame = frame;
|
||||||
|
lastSyncPacket = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame <= LastOrdersFrame)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Ignore immediate orders
|
||||||
|
if (data.Length > 0 && data[0] == 0xFE)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var clientSlot = clientsBySlotIndex.IndexOf(conn.PlayerIndex);
|
||||||
|
|
||||||
|
// Handle orders that were sent by spectators
|
||||||
|
if (clientSlot == -1)
|
||||||
|
{
|
||||||
|
// HACK: Assume that this is a bot order sent by its controller client
|
||||||
|
// who is a spectator. The network data doesn't contain enough information
|
||||||
|
// for us to confirm this, or to know which bot this is supposed to belong to...
|
||||||
|
//
|
||||||
|
// For skirmish games it is sufficient to map everything to the first bot,
|
||||||
|
// because even if the bot choice is wrong, the bot-to-client remapping in ParseOrders
|
||||||
|
// will give the right client as there is only one human client to choose from!
|
||||||
|
// TODO: This will need to be fixed properly before implementing multiplayer saves
|
||||||
|
clientSlot = firstBotSlotIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
ordersStream.WriteArray(BitConverter.GetBytes(data.Length + 8));
|
||||||
|
ordersStream.WriteArray(BitConverter.GetBytes(frame));
|
||||||
|
ordersStream.WriteArray(BitConverter.GetBytes(clientSlot));
|
||||||
|
ordersStream.WriteArray(data);
|
||||||
|
LastOrdersFrame = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ParseOrders(Session lobbyInfo, Action<int, int, byte[]> packetFn)
|
||||||
|
{
|
||||||
|
// Send the trait data first to guarantee that it is available when needed
|
||||||
|
foreach (var kv in TraitData)
|
||||||
|
{
|
||||||
|
var data = new List<MiniYamlNode>() { new MiniYamlNode(kv.Key.ToString(), kv.Value) }.WriteToString();
|
||||||
|
packetFn(0, 0, new ServerOrder("SaveTraitData", data).Serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
ordersStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
while (ordersStream.Position < ordersStream.Length)
|
||||||
|
{
|
||||||
|
var dataLength = ordersStream.ReadInt32() - 8;
|
||||||
|
var frame = ordersStream.ReadInt32();
|
||||||
|
var slot = ordersStream.ReadInt32();
|
||||||
|
var data = ordersStream.ReadBytes(dataLength);
|
||||||
|
|
||||||
|
// Remap bot orders to their controller client
|
||||||
|
var clientIndex = clientsBySlotIndex[slot];
|
||||||
|
var client = lobbyInfo.ClientWithIndex(clientIndex);
|
||||||
|
if (client.Bot != null)
|
||||||
|
clientIndex = client.BotControllerClientIndex;
|
||||||
|
|
||||||
|
packetFn(frame, clientIndex, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sync hash to validate restore
|
||||||
|
packetFn(LastSyncFrame, 0, lastSyncPacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddTraitData(int traitIndex, MiniYaml data)
|
||||||
|
{
|
||||||
|
TraitData[traitIndex] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(string path)
|
||||||
|
{
|
||||||
|
// File format:
|
||||||
|
// - List of orders in network frame format
|
||||||
|
// - Metadata start marker
|
||||||
|
// - Last frame number containing orders (int32)
|
||||||
|
// - Last frame number containing sync hash (int32)
|
||||||
|
// - Last sync packet (5 x byte)
|
||||||
|
// - Lobby global settings (yaml)
|
||||||
|
// - Lobby slots (yaml)
|
||||||
|
// - Lobby slot-client data (yaml)
|
||||||
|
// - Trait data start marker
|
||||||
|
// - Custom trait yaml
|
||||||
|
// - File offset of metadata start marker
|
||||||
|
// - File offset of custom trait data
|
||||||
|
// - Metadata end marker
|
||||||
|
var file = File.Create(path);
|
||||||
|
|
||||||
|
ordersStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
ordersStream.CopyTo(file);
|
||||||
|
file.Write(BitConverter.GetBytes(MetadataMarker), 0, 4);
|
||||||
|
file.Write(BitConverter.GetBytes(LastOrdersFrame), 0, 4);
|
||||||
|
file.Write(BitConverter.GetBytes(LastSyncFrame), 0, 4);
|
||||||
|
file.Write(lastSyncPacket, 0, 5);
|
||||||
|
|
||||||
|
var globalSettingsNodes = new List<MiniYamlNode>() { GlobalSettings.Serialize() };
|
||||||
|
file.WriteString(Encoding.UTF8, globalSettingsNodes.WriteToString());
|
||||||
|
|
||||||
|
var slotNodes = Slots
|
||||||
|
.Select(s => s.Value.Serialize())
|
||||||
|
.ToList();
|
||||||
|
file.WriteString(Encoding.UTF8, slotNodes.WriteToString());
|
||||||
|
|
||||||
|
var slotClientNodes = SlotClients
|
||||||
|
.Select(s => s.Value.Serialize(s.Key))
|
||||||
|
.ToList();
|
||||||
|
file.WriteString(Encoding.UTF8, slotClientNodes.WriteToString());
|
||||||
|
|
||||||
|
var traitDataOffset = file.Length;
|
||||||
|
file.Write(BitConverter.GetBytes(TraitDataMarker), 0, 4);
|
||||||
|
|
||||||
|
var traitDataNodes = TraitData
|
||||||
|
.Select(kv => new MiniYamlNode(kv.Key.ToString(), kv.Value))
|
||||||
|
.ToList();
|
||||||
|
file.WriteString(Encoding.UTF8, traitDataNodes.WriteToString());
|
||||||
|
|
||||||
|
file.Write(BitConverter.GetBytes(ordersStream.Length), 0, 4);
|
||||||
|
file.Write(BitConverter.GetBytes(traitDataOffset), 0, 4);
|
||||||
|
file.Write(BitConverter.GetBytes(EOFMarker), 0, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ namespace OpenRA.Network
|
|||||||
public bool GameStarted { get { return NetFrameNumber != 0; } }
|
public bool GameStarted { get { return NetFrameNumber != 0; } }
|
||||||
public IConnection Connection { get; private set; }
|
public IConnection Connection { get; private set; }
|
||||||
|
|
||||||
|
internal int GameSaveLastFrame = -1;
|
||||||
|
internal int GameSaveLastSyncFrame = -1;
|
||||||
|
|
||||||
List<Order> localOrders = new List<Order>();
|
List<Order> localOrders = new List<Order>();
|
||||||
|
|
||||||
List<ChatLine> chatCache = new List<ChatLine>();
|
List<ChatLine> chatCache = new List<ChatLine>();
|
||||||
@@ -70,8 +73,10 @@ namespace OpenRA.Network
|
|||||||
generateSyncReport = !(Connection is ReplayConnection) && LobbyInfo.GlobalSettings.EnableSyncReports;
|
generateSyncReport = !(Connection is ReplayConnection) && LobbyInfo.GlobalSettings.EnableSyncReports;
|
||||||
|
|
||||||
NetFrameNumber = 1;
|
NetFrameNumber = 1;
|
||||||
for (var i = NetFrameNumber; i <= FramesAhead; i++)
|
|
||||||
Connection.Send(i, new List<byte[]>());
|
if (GameSaveLastFrame < 0)
|
||||||
|
for (var i = NetFrameNumber; i <= FramesAhead; i++)
|
||||||
|
Connection.Send(i, new List<byte[]>());
|
||||||
}
|
}
|
||||||
|
|
||||||
public OrderManager(string host, int port, string password, IConnection conn)
|
public OrderManager(string host, int port, string password, IConnection conn)
|
||||||
@@ -105,7 +110,7 @@ namespace OpenRA.Network
|
|||||||
public void TickImmediate()
|
public void TickImmediate()
|
||||||
{
|
{
|
||||||
var immediateOrders = localOrders.Where(o => o.IsImmediate).ToList();
|
var immediateOrders = localOrders.Where(o => o.IsImmediate).ToList();
|
||||||
if (immediateOrders.Count != 0)
|
if (immediateOrders.Count != 0 && GameSaveLastFrame < NetFrameNumber + FramesAhead)
|
||||||
Connection.SendImmediate(immediateOrders.Select(o => o.Serialize()).ToList());
|
Connection.SendImmediate(immediateOrders.Select(o => o.Serialize()).ToList());
|
||||||
localOrders.RemoveAll(o => o.IsImmediate);
|
localOrders.RemoveAll(o => o.IsImmediate);
|
||||||
|
|
||||||
@@ -178,13 +183,18 @@ namespace OpenRA.Network
|
|||||||
if (!IsReadyForNextFrame)
|
if (!IsReadyForNextFrame)
|
||||||
throw new InvalidOperationException();
|
throw new InvalidOperationException();
|
||||||
|
|
||||||
Connection.Send(NetFrameNumber + FramesAhead, localOrders.Select(o => o.Serialize()).ToList());
|
if (GameSaveLastFrame < NetFrameNumber + FramesAhead)
|
||||||
|
Connection.Send(NetFrameNumber + FramesAhead, localOrders.Select(o => o.Serialize()).ToList());
|
||||||
|
|
||||||
localOrders.Clear();
|
localOrders.Clear();
|
||||||
|
|
||||||
foreach (var order in frameData.OrdersForFrame(World, NetFrameNumber))
|
foreach (var order in frameData.OrdersForFrame(World, NetFrameNumber))
|
||||||
UnitOrders.ProcessOrder(this, World, order.Client, order.Order);
|
UnitOrders.ProcessOrder(this, World, order.Client, order.Order);
|
||||||
|
|
||||||
Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(World.SyncHash()));
|
if (NetFrameNumber + FramesAhead >= GameSaveLastSyncFrame)
|
||||||
|
Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(World.SyncHash()));
|
||||||
|
else
|
||||||
|
Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(0));
|
||||||
|
|
||||||
if (generateSyncReport)
|
if (generateSyncReport)
|
||||||
using (new PerfSample("sync_report"))
|
using (new PerfSample("sync_report"))
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ namespace OpenRA.Network
|
|||||||
public bool EnableSingleplayer;
|
public bool EnableSingleplayer;
|
||||||
public bool EnableSyncReports;
|
public bool EnableSyncReports;
|
||||||
public bool Dedicated;
|
public bool Dedicated;
|
||||||
|
public bool GameSavesEnabled;
|
||||||
|
|
||||||
[FieldLoader.Ignore]
|
[FieldLoader.Ignore]
|
||||||
public Dictionary<string, LobbyOptionState> LobbyOptions = new Dictionary<string, LobbyOptionState>();
|
public Dictionary<string, LobbyOptionState> LobbyOptions = new Dictionary<string, LobbyOptionState>();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenRA.Primitives;
|
using OpenRA.Primitives;
|
||||||
@@ -114,11 +115,45 @@ namespace OpenRA.Network
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Game.AddChatLine(Color.White, ServerChatName, "The game has started.");
|
if (!string.IsNullOrEmpty(order.TargetString))
|
||||||
|
{
|
||||||
|
var data = MiniYaml.FromString(order.TargetString);
|
||||||
|
var saveLastOrdersFrame = data.FirstOrDefault(n => n.Key == "SaveLastOrdersFrame");
|
||||||
|
if (saveLastOrdersFrame != null)
|
||||||
|
orderManager.GameSaveLastFrame =
|
||||||
|
FieldLoader.GetValue<int>("saveLastOrdersFrame", saveLastOrdersFrame.Value.Value);
|
||||||
|
|
||||||
|
var saveSyncFrame = data.FirstOrDefault(n => n.Key == "SaveSyncFrame");
|
||||||
|
if (saveSyncFrame != null)
|
||||||
|
orderManager.GameSaveLastSyncFrame =
|
||||||
|
FieldLoader.GetValue<int>("SaveSyncFrame", saveSyncFrame.Value.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Game.AddChatLine(Color.White, ServerChatName, "The game has started.");
|
||||||
|
|
||||||
Game.StartGame(orderManager.LobbyInfo.GlobalSettings.Map, WorldType.Regular);
|
Game.StartGame(orderManager.LobbyInfo.GlobalSettings.Map, WorldType.Regular);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "SaveTraitData":
|
||||||
|
{
|
||||||
|
var data = MiniYaml.FromString(order.TargetString)[0];
|
||||||
|
var traitIndex = int.Parse(data.Key);
|
||||||
|
|
||||||
|
if (world != null)
|
||||||
|
world.AddGameSaveTraitData(traitIndex, data.Value);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GameSaved":
|
||||||
|
if (!orderManager.World.IsReplay)
|
||||||
|
Game.AddChatLine(Color.White, ServerChatName, "Game saved");
|
||||||
|
|
||||||
|
foreach (var nsr in orderManager.World.WorldActor.TraitsImplementing<INotifyGameSaved>())
|
||||||
|
nsr.GameSaved(orderManager.World);
|
||||||
|
break;
|
||||||
|
|
||||||
case "PauseGame":
|
case "PauseGame":
|
||||||
{
|
{
|
||||||
var client = orderManager.LobbyInfo.ClientWithIndex(clientId);
|
var client = orderManager.LobbyInfo.ClientWithIndex(clientId);
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
<Compile Include="CacheStorage.cs" />
|
<Compile Include="CacheStorage.cs" />
|
||||||
<Compile Include="FileFormats\Png.cs" />
|
<Compile Include="FileFormats\Png.cs" />
|
||||||
<Compile Include="FileSystem\IPackage.cs" />
|
<Compile Include="FileSystem\IPackage.cs" />
|
||||||
|
<Compile Include="Network\GameSave.cs" />
|
||||||
<Compile Include="Primitives\Color.cs" />
|
<Compile Include="Primitives\Color.cs" />
|
||||||
<Compile Include="Primitives\Int32Matrix4x4.cs" />
|
<Compile Include="Primitives\Int32Matrix4x4.cs" />
|
||||||
<Compile Include="LogProxy.cs" />
|
<Compile Include="LogProxy.cs" />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ namespace OpenRA.Server
|
|||||||
|
|
||||||
// Managed by LobbyCommands
|
// Managed by LobbyCommands
|
||||||
public MapPreview Map;
|
public MapPreview Map;
|
||||||
|
public GameSave GameSave = null;
|
||||||
|
|
||||||
readonly int randomSeed;
|
readonly int randomSeed;
|
||||||
readonly TcpListener listener;
|
readonly TcpListener listener;
|
||||||
@@ -558,6 +559,9 @@ namespace OpenRA.Server
|
|||||||
InterpretServerOrders(conn, data);
|
InterpretServerOrders(conn, data);
|
||||||
else
|
else
|
||||||
DispatchOrdersToClients(conn, frame, data);
|
DispatchOrdersToClients(conn, frame, data);
|
||||||
|
|
||||||
|
if (GameSave != null && conn != null)
|
||||||
|
GameSave.DispatchOrders(conn, frame, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
void InterpretServerOrders(Connection conn, byte[] data)
|
void InterpretServerOrders(Connection conn, byte[] data)
|
||||||
@@ -661,6 +665,119 @@ namespace OpenRA.Server
|
|||||||
|
|
||||||
SyncClientPing();
|
SyncClientPing();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GameSaveTraitData":
|
||||||
|
{
|
||||||
|
if (GameSave != null)
|
||||||
|
{
|
||||||
|
var data = MiniYaml.FromString(so.Data)[0];
|
||||||
|
GameSave.AddTraitData(int.Parse(data.Key), data.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CreateGameSave":
|
||||||
|
{
|
||||||
|
if (GameSave != null)
|
||||||
|
{
|
||||||
|
// Sanitize potentially malicious input
|
||||||
|
var filename = so.Data;
|
||||||
|
var invalidIndex = -1;
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1)
|
||||||
|
filename = filename.Remove(invalidIndex, 1);
|
||||||
|
|
||||||
|
var baseSavePath = Platform.ResolvePath(
|
||||||
|
Platform.SupportDirPrefix,
|
||||||
|
"Saves",
|
||||||
|
ModData.Manifest.Id,
|
||||||
|
ModData.Manifest.Metadata.Version);
|
||||||
|
|
||||||
|
if (!Directory.Exists(baseSavePath))
|
||||||
|
Directory.CreateDirectory(baseSavePath);
|
||||||
|
|
||||||
|
GameSave.Save(Path.Combine(baseSavePath, filename));
|
||||||
|
DispatchOrdersToClients(null, 0, new ServerOrder("GameSaved", filename).Serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "LoadGameSave":
|
||||||
|
{
|
||||||
|
if (Dedicated || State >= ServerState.GameStarted)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Sanitize potentially malicious input
|
||||||
|
var filename = so.Data;
|
||||||
|
var invalidIndex = -1;
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1)
|
||||||
|
filename = filename.Remove(invalidIndex, 1);
|
||||||
|
|
||||||
|
var savePath = Platform.ResolvePath(
|
||||||
|
Platform.SupportDirPrefix,
|
||||||
|
"Saves",
|
||||||
|
ModData.Manifest.Id,
|
||||||
|
ModData.Manifest.Metadata.Version,
|
||||||
|
filename);
|
||||||
|
|
||||||
|
GameSave = new GameSave(savePath);
|
||||||
|
LobbyInfo.GlobalSettings = GameSave.GlobalSettings;
|
||||||
|
LobbyInfo.Slots = GameSave.Slots;
|
||||||
|
|
||||||
|
// Reassign clients to slots
|
||||||
|
// - Bot ordering is preserved
|
||||||
|
// - Humans are assigned on a first-come-first-serve basis
|
||||||
|
// - Leftover humans become spectators
|
||||||
|
|
||||||
|
// Start by removing all bots and assigning all players as spectators
|
||||||
|
foreach (var c in LobbyInfo.Clients)
|
||||||
|
{
|
||||||
|
if (c.Bot != null)
|
||||||
|
{
|
||||||
|
LobbyInfo.Clients.Remove(c);
|
||||||
|
var ping = LobbyInfo.PingFromClient(c);
|
||||||
|
if (ping != null)
|
||||||
|
LobbyInfo.ClientPings.Remove(ping);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
c.Slot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild/remap the saved client state
|
||||||
|
// TODO: Multiplayer saves should leave all humans as spectators so they can manually pick slots
|
||||||
|
var adminClientIndex = LobbyInfo.Clients.First(c => c.IsAdmin).Index;
|
||||||
|
foreach (var kv in GameSave.SlotClients)
|
||||||
|
{
|
||||||
|
if (kv.Value.Bot != null)
|
||||||
|
{
|
||||||
|
var bot = new Session.Client()
|
||||||
|
{
|
||||||
|
Index = ChooseFreePlayerIndex(),
|
||||||
|
State = Session.ClientState.NotReady,
|
||||||
|
BotControllerClientIndex = adminClientIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
kv.Value.ApplyTo(bot);
|
||||||
|
LobbyInfo.Clients.Add(bot);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// This will throw if the server doesn't have enough human clients to fill all player slots
|
||||||
|
// See TODO above - this isn't a problem in practice because MP saves won't use this
|
||||||
|
var client = LobbyInfo.Clients.First(c => c.Slot == null);
|
||||||
|
kv.Value.ApplyTo(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncLobbyInfo();
|
||||||
|
SyncLobbyClients();
|
||||||
|
SyncClientPing();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -810,6 +927,12 @@ namespace OpenRA.Server
|
|||||||
if (LobbyInfo.NonBotClients.Count() == 1)
|
if (LobbyInfo.NonBotClients.Count() == 1)
|
||||||
LobbyInfo.GlobalSettings.OrderLatency = 1;
|
LobbyInfo.GlobalSettings.OrderLatency = 1;
|
||||||
|
|
||||||
|
// Enable game saves for singleplayer missions only
|
||||||
|
// TODO: Enable for skirmish once the AI supports state-restoration
|
||||||
|
// TODO: Enable for multiplayer (non-dedicated servers only) once the lobby UI has been created
|
||||||
|
LobbyInfo.GlobalSettings.GameSavesEnabled = !Dedicated && Map.Visibility == MapVisibility.MissionSelector &&
|
||||||
|
LobbyInfo.NonBotClients.Count() == 1;
|
||||||
|
|
||||||
SyncLobbyInfo();
|
SyncLobbyInfo();
|
||||||
State = ServerState.GameStarted;
|
State = ServerState.GameStarted;
|
||||||
|
|
||||||
@@ -817,11 +940,37 @@ namespace OpenRA.Server
|
|||||||
foreach (var d in Conns)
|
foreach (var d in Conns)
|
||||||
DispatchOrdersToClient(c, d.PlayerIndex, 0x7FFFFFFF, new byte[] { 0xBF });
|
DispatchOrdersToClient(c, d.PlayerIndex, 0x7FFFFFFF, new byte[] { 0xBF });
|
||||||
|
|
||||||
|
if (GameSave == null && LobbyInfo.GlobalSettings.GameSavesEnabled)
|
||||||
|
GameSave = new GameSave();
|
||||||
|
|
||||||
|
var startGameData = "";
|
||||||
|
if (GameSave != null)
|
||||||
|
{
|
||||||
|
GameSave.StartGame(LobbyInfo, Map);
|
||||||
|
if (GameSave.LastOrdersFrame >= 0)
|
||||||
|
{
|
||||||
|
startGameData = new List<MiniYamlNode>()
|
||||||
|
{
|
||||||
|
new MiniYamlNode("SaveLastOrdersFrame", GameSave.LastOrdersFrame.ToString()),
|
||||||
|
new MiniYamlNode("SaveSyncFrame", GameSave.LastSyncFrame.ToString())
|
||||||
|
}.WriteToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DispatchOrders(null, 0,
|
DispatchOrders(null, 0,
|
||||||
new ServerOrder("StartGame", "").Serialize());
|
new ServerOrder("StartGame", startGameData).Serialize());
|
||||||
|
|
||||||
foreach (var t in serverTraits.WithInterface<IStartGame>())
|
foreach (var t in serverTraits.WithInterface<IStartGame>())
|
||||||
t.GameStarted(this);
|
t.GameStarted(this);
|
||||||
|
|
||||||
|
if (GameSave != null && GameSave.LastOrdersFrame >= 0)
|
||||||
|
{
|
||||||
|
GameSave.ParseOrders(LobbyInfo, (frame, client, data) =>
|
||||||
|
{
|
||||||
|
foreach (var c in Conns)
|
||||||
|
DispatchOrdersToClient(c, client, frame, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,6 +356,15 @@ namespace OpenRA.Traits
|
|||||||
public interface INotifySelection { void SelectionChanged(); }
|
public interface INotifySelection { void SelectionChanged(); }
|
||||||
|
|
||||||
public interface IWorldLoaded { void WorldLoaded(World w, WorldRenderer wr); }
|
public interface IWorldLoaded { void WorldLoaded(World w, WorldRenderer wr); }
|
||||||
|
public interface INotifyGameLoading { void GameLoading(World w); }
|
||||||
|
public interface INotifyGameLoaded { void GameLoaded(World w); }
|
||||||
|
public interface INotifyGameSaved { void GameSaved(World w); }
|
||||||
|
|
||||||
|
public interface IGameSaveTraitData
|
||||||
|
{
|
||||||
|
List<MiniYamlNode> IssueTraitData(Actor self);
|
||||||
|
void ResolveTraitData(Actor self, List<MiniYamlNode> data);
|
||||||
|
}
|
||||||
|
|
||||||
[RequireExplicitImplementation]
|
[RequireExplicitImplementation]
|
||||||
public interface ICreatePlayers { void CreatePlayers(World w); }
|
public interface ICreatePlayers { void CreatePlayers(World w); }
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ namespace OpenRA
|
|||||||
get { return OrderManager.Connection is ReplayConnection; }
|
get { return OrderManager.Connection is ReplayConnection; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsLoadingGameSave
|
||||||
|
{
|
||||||
|
get { return OrderManager.NetFrameNumber <= OrderManager.GameSaveLastFrame; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GameSaveLoadingPercentage
|
||||||
|
{
|
||||||
|
get { return OrderManager.NetFrameNumber * 100 / OrderManager.GameSaveLastFrame; }
|
||||||
|
}
|
||||||
|
|
||||||
void SetLocalPlayer(Player localPlayer)
|
void SetLocalPlayer(Player localPlayer)
|
||||||
{
|
{
|
||||||
if (localPlayer == null)
|
if (localPlayer == null)
|
||||||
@@ -162,6 +172,8 @@ namespace OpenRA
|
|||||||
|
|
||||||
public bool RulesContainTemporaryBlocker { get; private set; }
|
public bool RulesContainTemporaryBlocker { get; private set; }
|
||||||
|
|
||||||
|
bool wasLoadingGameSave;
|
||||||
|
|
||||||
internal World(ModData modData, Map map, OrderManager orderManager, WorldType type)
|
internal World(ModData modData, Map map, OrderManager orderManager, WorldType type)
|
||||||
{
|
{
|
||||||
Type = type;
|
Type = type;
|
||||||
@@ -230,6 +242,14 @@ namespace OpenRA
|
|||||||
|
|
||||||
public void LoadComplete(WorldRenderer wr)
|
public void LoadComplete(WorldRenderer wr)
|
||||||
{
|
{
|
||||||
|
if (IsLoadingGameSave)
|
||||||
|
{
|
||||||
|
wasLoadingGameSave = true;
|
||||||
|
Game.Sound.DisableAllSounds = true;
|
||||||
|
foreach (var nsr in WorldActor.TraitsImplementing<INotifyGameLoading>())
|
||||||
|
nsr.GameLoading(this);
|
||||||
|
}
|
||||||
|
|
||||||
// ScreenMap must be initialized before anything else
|
// ScreenMap must be initialized before anything else
|
||||||
using (new PerfTimer("ScreenMap.WorldLoaded"))
|
using (new PerfTimer("ScreenMap.WorldLoaded"))
|
||||||
ScreenMap.WorldLoaded(this, wr);
|
ScreenMap.WorldLoaded(this, wr);
|
||||||
@@ -344,6 +364,12 @@ namespace OpenRA
|
|||||||
|
|
||||||
public int WorldTick { get; private set; }
|
public int WorldTick { get; private set; }
|
||||||
|
|
||||||
|
Dictionary<int, MiniYaml> gameSaveTraitData = new Dictionary<int, MiniYaml>();
|
||||||
|
internal void AddGameSaveTraitData(int traitIndex, MiniYaml yaml)
|
||||||
|
{
|
||||||
|
gameSaveTraitData[traitIndex] = yaml;
|
||||||
|
}
|
||||||
|
|
||||||
public void SetPauseState(bool paused)
|
public void SetPauseState(bool paused)
|
||||||
{
|
{
|
||||||
if (PauseStateLocked)
|
if (PauseStateLocked)
|
||||||
@@ -360,6 +386,29 @@ namespace OpenRA
|
|||||||
|
|
||||||
public void Tick()
|
public void Tick()
|
||||||
{
|
{
|
||||||
|
if (wasLoadingGameSave && !IsLoadingGameSave)
|
||||||
|
{
|
||||||
|
foreach (var kv in gameSaveTraitData)
|
||||||
|
{
|
||||||
|
var tp = TraitDict.ActorsWithTrait<IGameSaveTraitData>()
|
||||||
|
.Skip(kv.Key)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (tp.Actor == null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
tp.Trait.ResolveTraitData(tp.Actor, kv.Value.Nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
gameSaveTraitData.Clear();
|
||||||
|
|
||||||
|
Game.Sound.DisableAllSounds = false;
|
||||||
|
foreach (var nsr in WorldActor.TraitsImplementing<INotifyGameLoaded>())
|
||||||
|
nsr.GameLoaded(this);
|
||||||
|
|
||||||
|
wasLoadingGameSave = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Paused)
|
if (!Paused)
|
||||||
{
|
{
|
||||||
WorldTick++;
|
WorldTick++;
|
||||||
@@ -460,6 +509,35 @@ namespace OpenRA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RequestGameSave(string filename)
|
||||||
|
{
|
||||||
|
// Allow traits to save arbitrary data that will be passed back via IGameSaveTraitData.ResolveTraitData
|
||||||
|
// at the end of the save restoration
|
||||||
|
// TODO: This will need to be generalized to a request / response pair for multiplayer game saves
|
||||||
|
var i = 0;
|
||||||
|
foreach (var tp in TraitDict.ActorsWithTrait<IGameSaveTraitData>())
|
||||||
|
{
|
||||||
|
var data = tp.Trait.IssueTraitData(tp.Actor);
|
||||||
|
if (data != null)
|
||||||
|
{
|
||||||
|
var yaml = new List<MiniYamlNode>() { new MiniYamlNode(i.ToString(), new MiniYaml("", data)) };
|
||||||
|
IssueOrder(new Order("GameSaveTraitData", null, false)
|
||||||
|
{
|
||||||
|
IsImmediate = true,
|
||||||
|
TargetString = yaml.WriteToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
IssueOrder(new Order("CreateGameSave", null, false)
|
||||||
|
{
|
||||||
|
IsImmediate = true,
|
||||||
|
TargetString = filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public bool Disposing;
|
public bool Disposing;
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -470,6 +548,8 @@ namespace OpenRA
|
|||||||
|
|
||||||
Game.Sound.StopAudio();
|
Game.Sound.StopAudio();
|
||||||
Game.Sound.StopVideo();
|
Game.Sound.StopVideo();
|
||||||
|
if (IsLoadingGameSave)
|
||||||
|
Game.Sound.DisableAllSounds = false;
|
||||||
|
|
||||||
ModelCache.Dispose();
|
ModelCache.Dispose();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user