Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for seeking to arbitrary ticks #49

Merged
merged 21 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/DemoFile/DemoEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ internal bool ReadDemoCommand(EDemoCommands msgType, ReadOnlySpan<byte> buffer)
DemoUserCmd?.Invoke(CDemoUserCmd.Parser.ParseFrom(buffer));
return true;
case EDemoCommands.DemFullPacket:
var fullPacket = CDemoFullPacket.Parser.ParseFrom(buffer);
DemoStringTables?.Invoke(fullPacket.StringTable);
DemoPacket?.Invoke(fullPacket.Packet);
DemoFullPacket?.Invoke(CDemoFullPacket.Parser.ParseFrom(buffer));
return true;
case EDemoCommands.DemSaveGame:
DemoSaveGame?.Invoke(CDemoSaveGame.Parser.ParseFrom(buffer));
Expand All @@ -69,6 +67,7 @@ internal bool ReadDemoCommand(EDemoCommands msgType, ReadOnlySpan<byte> buffer)
public Action<CDemoSendTables>? DemoSendTables;
public Action<CDemoClassInfo>? DemoClassInfo;
public Action<CDemoStringTables>? DemoStringTables;
public Action<CDemoFullPacket>? DemoFullPacket;
public Action<CDemoPacket>? DemoPacket;
public Action<CDemoConsoleCmd>? DemoConsoleCmd;
public Action<CDemoCustomData>? DemoCustomData;
Expand Down
12 changes: 0 additions & 12 deletions src/DemoFile/DemoParser.Entities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public partial class DemoParser
private readonly CEntityInstance?[] _entities = new CEntityInstance?[MaxEdicts];
private readonly CHandle<CCSTeam>[] _teamHandles = new CHandle<CCSTeam>[4];

private bool _fullSnapshotRead;
private CHandle<CCSGameRulesProxy> _gameRulesHandle;
private CHandle<CCSPlayerResource> _playerResourceHandle;

Expand Down Expand Up @@ -125,7 +124,6 @@ private void OnServerInfo(CSVCMsg_ServerInfo msg)
{
Debug.Assert(_playerInfos[msg.PlayerSlot] != null);
IsGotv = _playerInfos[msg.PlayerSlot]?.Ishltv ?? false;
_fullSnapshotRead = false;

MaxPlayers = msg.MaxClients;
_serverClassBits = (int)Math.Log2(msg.MaxClasses) + 1;
Expand Down Expand Up @@ -260,16 +258,6 @@ private void OnPacketEntities(CSVCMsg_PacketEntities msg)
// Clear out all entities - this is a full update.
if (!msg.LegacyIsDelta)
{
if (IsGotv)
{
// In GOTV recordings, we see a snapshot every 3840 ticks (60 seconds).
// After we've read the first one, we can ignore the rest.
if (_fullSnapshotRead)
return;

_fullSnapshotRead = true;
}

foreach (var entity in _entities)
{
if (entity == null)
Expand Down
160 changes: 160 additions & 0 deletions src/DemoFile/DemoParser.FullPacket.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Diagnostics;

namespace DemoFile;

public partial class DemoParser
{
private readonly record struct FullPacketPosition(DemoTick Tick, long StreamPosition)
: IComparable<FullPacketPosition>
{
public int CompareTo(FullPacketPosition other) => Tick.CompareTo(other.Tick);
}

/// Full packets occur every 60 seconds
private const int FullPacketInterval = 64 * 60;

private readonly List<FullPacketPosition> _fullPacketPositions = new(64);
private DemoTick _readFullPacketTick = DemoTick.PreRecord;
private int _fullPacketTickOffset;

private bool TryFindFullPacketBefore(DemoTick demoTick, out FullPacketPosition fullPacket)
{
var idx = _fullPacketPositions.BinarySearch(new FullPacketPosition(demoTick, 0L));
if (idx >= 0)
{
fullPacket = _fullPacketPositions[idx];
return true;
}

var fullPacketBeforeIdx = ~idx - 1;
if (fullPacketBeforeIdx >= 0 && fullPacketBeforeIdx < _fullPacketPositions.Count)
{
fullPacket = _fullPacketPositions[fullPacketBeforeIdx];
return true;
}

fullPacket = default;
return false;
}

/// <summary>
/// Seek to a specific tick within the demo file. Tick can be in the future or the past.
/// This works by first seeking to the nearest <see cref="CDemoFullPacket"/> before <paramref name="targetTick"/>,
/// decoding the full stringtable and entity snapshot, then reading tick-by-tick to <paramref name="targetTick"/>.
/// </summary>
/// <param name="targetTick">Tick to seek to.</param>
/// <param name="cancellationToken">Cancellation token for cancelling the seek.</param>
/// <exception cref="InvalidOperationException">Tick is invalid, or attempting to seek while reading commands.</exception>
/// <exception cref="EndOfStreamException">EOF before reaching <paramref name="targetTick"/>.</exception>
/// <remarks>
/// Seeking is not allowed while reading commands. See <see cref="IsReading"/>.
/// </remarks>
public async ValueTask SeekToTickAsync(DemoTick targetTick, CancellationToken cancellationToken)
{
if (IsReading)
throw new InvalidOperationException($"Cannot seek to tick while reading commands");

if (TickCount < DemoTick.Zero)
throw new InvalidOperationException($"Cannot seek to tick {targetTick}");

if (TickCount != default && targetTick > TickCount)
throw new InvalidOperationException($"Cannot seek to tick {targetTick}. The demo only contains data for {TickCount} ticks");

var hasFullPacket = TryFindFullPacketBefore(targetTick, out var fullPacket);
if (targetTick < CurrentDemoTick)
{
if (!hasFullPacket)
throw new InvalidOperationException($"Cannot seek backwards to tick {targetTick}. No {nameof(CDemoFullPacket)} has been read");

// Seeking backwards. Jump back to the full packet to read the snapshot
(CurrentDemoTick, _stream.Position) = fullPacket;
}
else
{
var deltaTicks = fullPacket.Tick - CurrentDemoTick;

// Only read the full packet if the jump is far enough ahead
if (hasFullPacket && deltaTicks.Value >= FullPacketInterval)
{
(CurrentDemoTick, _stream.Position) = fullPacket;
}
}

// Keep reading commands until we reach the full packet
_readFullPacketTick = new DemoTick(targetTick.Value / FullPacketInterval * FullPacketInterval + _fullPacketTickOffset);
SkipToTick(_readFullPacketTick);

// Advance ticks until we get to the target tick
while (CurrentDemoTick < targetTick)
{
var cmd = ReadCommandHeader();

if (CurrentDemoTick == targetTick)
{
_stream.Position = _commandStartPosition;
break;
}

if (!await MoveNextCoreAsync(cmd.Command, cmd.IsCompressed, cmd.Size, cancellationToken).ConfigureAwait(false))
{
throw new EndOfStreamException($"Reached EOF at tick {CurrentDemoTick} while seeking to tick {targetTick}");
}
}
}

private void SkipToTick(DemoTick targetTick)
{
while (CurrentDemoTick < targetTick)
{
var cmd = ReadCommandHeader();

// If we're at the target tick, jump back to the start of the command
if (CurrentDemoTick == targetTick && cmd.Command == EDemoCommands.DemFullPacket)
{
_stream.Position = _commandStartPosition;
break;
}

// Record fullpackets even when seeking to improve seeking performance
if (cmd.Command == EDemoCommands.DemFullPacket)
{
RecordFullPacket();
}

// Skip over the data and start reading the next command
_stream.Position += cmd.Size;
}
}

private void RecordFullPacket()
{
// DemoFullPackets are recorded in demos every 3,840 ticks (60 secs).
// Keep track of where they are to allow for fast seeking through the demo.
var idx = _fullPacketPositions.BinarySearch(new FullPacketPosition(CurrentDemoTick, 0L));
if (idx < 0)
{
_fullPacketPositions.Insert(~idx, new FullPacketPosition(CurrentDemoTick, _commandStartPosition));
}

// Some demos have fullpackets at tick 0, some at tick 1.
_fullPacketTickOffset = CurrentDemoTick.Value % FullPacketInterval;
Debug.Assert(_fullPacketTickOffset == 0 || _fullPacketTickOffset == 1, "Unexpected full packet tick offset");
}

private void OnDemoFullPacket(CDemoFullPacket fullPacket)
{
RecordFullPacket();

// We only want to read full packets if we're seeking to it
if (CurrentDemoTick == _readFullPacketTick)
{
foreach (var snapshot in fullPacket.StringTable.Tables)
{
var stringTable = _stringTables[snapshot.TableName];
stringTable.ReplaceWith(snapshot.Items);
}

OnDemoPacket(fullPacket.Packet);
}
}
}
46 changes: 16 additions & 30 deletions src/DemoFile/DemoParser.StringTables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,38 +24,31 @@ public partial class DemoParser
public bool TryGetStringTable(string tableName, [NotNullWhen(true)] out StringTable? stringTable) =>
_stringTables.TryGetValue(tableName, out stringTable);

private int _instanceBaselineTableId = -1;
private int _userInfoTableId = -1;

private void OnCreateStringTable(CSVCMsg_CreateStringTable msg)
{
StringTable.UpdateCallback? onUpdatedEntry = msg.Name switch
{
"instancebaseline" => OnInstanceBaselineUpdate,
"userinfo" => OnUserInfoUpdate,
_ => null
};

var stringTable = new StringTable(
msg.Name,
msg.Flags,
msg.UserDataSizeBits,
msg.UsingVarintBitcounts,
msg.UserDataFixedSize);

Action<int, KeyValuePair<string, byte[]>>? onUpdatedEntry = null;
if (msg.Name == "instancebaseline")
{
_instanceBaselineTableId = _stringTableList.Count;
onUpdatedEntry = OnInstanceBaselineUpdate;
}
else if (msg.Name == "userinfo")
{
_userInfoTableId = _stringTableList.Count;
onUpdatedEntry = OnUserInfoUpdate;
}
msg.UserDataFixedSize,
onUpdatedEntry);

if (msg.DataCompressed)
{
using var decompressed = Snappy.DecompressToMemory(msg.StringData.Span);
stringTable.ReadUpdate(decompressed.Memory.Span, msg.NumEntries, onUpdatedEntry);
stringTable.ReadUpdate(decompressed.Memory.Span, msg.NumEntries);
}
else
{
stringTable.ReadUpdate(msg.StringData.Span, msg.NumEntries, onUpdatedEntry);
stringTable.ReadUpdate(msg.StringData.Span, msg.NumEntries);
}

_stringTableList.Add(stringTable);
Expand All @@ -65,21 +58,15 @@ private void OnCreateStringTable(CSVCMsg_CreateStringTable msg)
private void OnUpdateStringTable(CSVCMsg_UpdateStringTable msg)
{
var stringTable = _stringTableList[msg.TableId];

Action<int, KeyValuePair<string, byte[]>>? onUpdatedEntry =
msg.TableId == _instanceBaselineTableId ? OnInstanceBaselineUpdate :
msg.TableId == _userInfoTableId ? OnUserInfoUpdate : null;

stringTable.ReadUpdate(msg.StringData.Span, msg.NumChangedEntries, onUpdatedEntry);
stringTable.ReadUpdate(msg.StringData.Span, msg.NumChangedEntries);
}

private void OnInstanceBaselineUpdate(int index, KeyValuePair<string, byte[]> entry)
{
if (index >= _instanceBaselines.Length)
{
var newBacking = new KeyValuePair<BaselineKey, byte[]>[(int) BitOperations.RoundUpToPowerOf2((uint) index + 1)];
((ReadOnlySpan<KeyValuePair<BaselineKey, byte[]>>)_instanceBaselines).CopyTo(newBacking);
_instanceBaselines = newBacking;
var newSize = (int) BitOperations.RoundUpToPowerOf2((uint) index + 1);
Array.Resize(ref _instanceBaselines, newSize);
}

ReadOnlySpan<char> key = entry.Key;
Expand All @@ -106,9 +93,8 @@ private void OnUserInfoUpdate(int index, KeyValuePair<string, byte[]> entry)
{
if (index >= _playerInfos.Length)
{
var newBacking = new CMsgPlayerInfo?[(int) BitOperations.RoundUpToPowerOf2((uint) index + 1)];
((ReadOnlySpan<CMsgPlayerInfo?>)_playerInfos).CopyTo(newBacking);
_playerInfos = newBacking;
var newSize = (int) BitOperations.RoundUpToPowerOf2((uint) index + 1);
Array.Resize(ref _playerInfos, newSize);
}

_playerInfos[index] = entry.Value.Length == 0
Expand Down
Loading
Loading