Previously, the MiniYaml.From* helpers such as FromStream would consume the entire input and then return a list of top-level nodes. Now, the input is processed using deferred execution and top-level nodes are yielded as they are resolved from the input. The motivating use-case is MapCache, which currently manually buffers nodes before passing to MiniYaml.FromString in order to improve responsiveness when large payloads are processed. Now that MiniYaml.FromStream yields results back as they come in, we can switch to that without disadvantage. The maintains the performance where map cache can update search results as each node comes in over the network rather than having to wait for the entire batch to be transferred. Now we can also remove the string buffer that captures each node, reducing memory pressure and simplifying the code.
324 lines
10 KiB
C#
324 lines
10 KiB
C#
#region Copyright & License Information
|
|
/*
|
|
* Copyright (c) The OpenRA Developers and Contributors
|
|
* 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 int Handicap;
|
|
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;
|
|
Handicap = client.Handicap;
|
|
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.Handicap = Handicap;
|
|
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@{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();
|
|
|
|
// Loaded from file and updated during gameplay
|
|
public int LastOrdersFrame { get; private set; }
|
|
public int LastSyncFrame { get; private set; }
|
|
byte[] lastSyncPacket = [];
|
|
|
|
// 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 = [];
|
|
|
|
// Set on game start
|
|
int[] clientsBySlotIndex = [];
|
|
int firstBotSlotIndex = -1;
|
|
|
|
public GameSave()
|
|
{
|
|
LastOrdersFrame = -1;
|
|
Slots = [];
|
|
}
|
|
|
|
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(Order.SyncHashOrderLength);
|
|
|
|
var globalSettings = MiniYaml.FromString(rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:globalSettings");
|
|
GlobalSettings = Session.Global.Deserialize(globalSettings.First().Value);
|
|
|
|
var slots = MiniYaml.FromString(rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:slots");
|
|
Slots = [];
|
|
foreach (var s in slots)
|
|
{
|
|
var slot = Session.Slot.Deserialize(s.Value);
|
|
Slots.Add(slot.PlayerReference, slot);
|
|
}
|
|
|
|
var slotClients = MiniYaml.FromString(rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:slotClients");
|
|
SlotClients = [];
|
|
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.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength), $"{filepath}:traitData");
|
|
foreach (var td in traitData)
|
|
TraitData.Add(Exts.ParseInt32Invariant(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 = [];
|
|
SlotClients = [];
|
|
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] == (byte)OrderType.SyncHash && frame > LastSyncFrame)
|
|
{
|
|
if (data.Length != Order.SyncHashOrderLength)
|
|
{
|
|
Log.Write("debug", $"Dropped sync order with length {data.Length}. Expected length {Order.SyncHashOrderLength}.");
|
|
return;
|
|
}
|
|
|
|
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.Write(data.Length + 8);
|
|
ordersStream.Write(frame);
|
|
ordersStream.Write(clientSlot);
|
|
ordersStream.Write(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(kv.Key.ToStringInvariant(), kv.Value) }.WriteToString();
|
|
packetFn(0, 0, Order.FromTargetString("SaveTraitData", data, true).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
|
|
using (var file = File.Create(path))
|
|
{
|
|
ordersStream.Seek(0, SeekOrigin.Begin);
|
|
ordersStream.CopyTo(file);
|
|
file.Write(MetadataMarker);
|
|
file.Write(LastOrdersFrame);
|
|
file.Write(LastSyncFrame);
|
|
file.Write(lastSyncPacket, 0, Order.SyncHashOrderLength);
|
|
|
|
var globalSettingsNodes = new List<MiniYamlNode>() { GlobalSettings.Serialize() };
|
|
file.WriteLengthPrefixedString(Encoding.UTF8, globalSettingsNodes.WriteToString());
|
|
|
|
var slotNodes = Slots
|
|
.Select(s => s.Value.Serialize())
|
|
.ToList();
|
|
file.WriteLengthPrefixedString(Encoding.UTF8, slotNodes.WriteToString());
|
|
|
|
var slotClientNodes = SlotClients
|
|
.Select(s => s.Value.Serialize(s.Key))
|
|
.ToList();
|
|
file.WriteLengthPrefixedString(Encoding.UTF8, slotClientNodes.WriteToString());
|
|
|
|
var traitDataOffset = file.Length;
|
|
file.Write(TraitDataMarker);
|
|
|
|
var traitDataNodes = TraitData
|
|
.Select(kv => new MiniYamlNode(kv.Key.ToStringInvariant(), kv.Value))
|
|
.ToList();
|
|
file.WriteLengthPrefixedString(Encoding.UTF8, traitDataNodes.WriteToString());
|
|
|
|
file.Write((int)ordersStream.Length);
|
|
file.Write((int)traitDataOffset);
|
|
file.Write(EOFMarker);
|
|
}
|
|
}
|
|
}
|
|
}
|