Skip to content

Commit

Permalink
Add support for seeking to arbitrary ticks (#49)
Browse files Browse the repository at this point in the history
Closes #47
  • Loading branch information
saul committed Feb 29, 2024
1 parent e2392c8 commit cad95be
Show file tree
Hide file tree
Showing 13 changed files with 575 additions and 158 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Easy discoverability of available data through your IDE's inbuilt autocompletion
| POV demos | ➖ Support planned |
| Game events (e.g. `player_death`) | ✅ Full support |
| Entity updates (player positions, grenades, etc.) | ✅ Full support |
| Seeking forwards/backwards through the demo | ✅ Full support |

## Examples

Expand Down
5 changes: 5 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### 0.12.1 (2024-02-29)

- Add support for seeking to arbitrary ticks with `DemoParser.SeekToTickAsync`. \
It supports seeking backwards and forwards, and makes use of Source 2 demo 'full packet' snapshots to do this efficiently.

### 0.11.1 (2024-02-19)

- Reading CDemoFileInfo is best effort
Expand Down
148 changes: 148 additions & 0 deletions src/DemoFile.Test/Integration/SeekToTickIntegrationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Text;

namespace DemoFile.Test.Integration;

[TestFixture]
public class SeekToTickIntegrationTest
{
private static readonly DemoTick[] SeekToTickStartTicks = Enumerable.Range(1, 8)
.Select(i => new DemoTick(i * 3840 * 8))
.ToArray();

[TestCaseSource(nameof(SeekToTickStartTicks))]
public async Task SeekToTick_StartAtTick(DemoTick startTick)
{
// Arrange
var demo = new DemoParser();

// Act
await demo.StartReadingAsync(GotvCompetitiveProtocol13992, default);
await demo.SeekToTickAsync(startTick, default);

while (await demo.MoveNextAsync(default))
{
}

// Assert
Assert.That(demo.CurrentDemoTick.Value, Is.EqualTo(251327));
}

[Test]
public async Task SeekToTick_ReadToEndThenRestart()
{
// Arrange
var phase = 0;
var demo = new DemoParser();

var snapshot = new StringBuilder[] {new(), new()};

// Smallest prime below 38400 ticks (10 mins)
var snapshotTickInterval = 38393;

IDisposable? timer = null;

void OnSnapshotTimer()
{
SnapshotPlayerInfos();
SnapshotEntities();

timer = demo.CreateTimer(
demo.CurrentDemoTick + snapshotTickInterval,
OnSnapshotTimer);
}

// Act
await demo.StartReadingAsync(GotvCompetitiveProtocol13992, default);

phase = 0;
timer = demo.CreateTimer(new DemoTick(1), OnSnapshotTimer);

while (await demo.MoveNextAsync(default))
{
}

// Seek back to beginning
timer?.Dispose();
phase = 1;

await demo.SeekToTickAsync(DemoTick.Zero, default(CancellationToken));
Assert.That(demo.CurrentDemoTick.Value, Is.EqualTo(0));
timer = demo.CreateTimer(new DemoTick(1), OnSnapshotTimer);

while (await demo.MoveNextAsync(default))
{
}

// Assert
Snapshot.Assert(snapshot[1].ToString());

Assert.That(
snapshot[1].ToString(),
Is.EqualTo(snapshot[0].ToString()),
"Expected second play through to match first");

Assert.That(demo.CurrentDemoTick.Value, Is.EqualTo(251327));


void SnapshotPlayerInfos()
{
snapshot[phase].AppendLine($"[{demo.CurrentDemoTick.Value}] Player infos:");
var playerInfos = demo.PlayerInfos.Reverse().SkipWhile(x => x == null).Reverse().ToList();
for (var index = 0; index < playerInfos.Count; index++)
{
var playerInfo = demo.PlayerInfos[index];
snapshot[phase].AppendLine($" #{index}: {playerInfo?.ToString() ?? "<null>"}");
}
}

void SnapshotEntities()
{
snapshot[phase].AppendLine($"[{demo.CurrentDemoTick.Value}] Entities:");
foreach (var entity in demo.Entities)
{
snapshot[phase].AppendLine($" #{entity.EntityIndex.Value}: {entity.ToString()} {{ Active = {entity.IsActive}, {entity.EntityHandle} }}");
}
}
}

[Test]
public async Task SeekToTick_ForwardBackwards()
{
// Arrange
var demo = new DemoParser();
var skipInterval = TimeSpan.FromSeconds(77);

// Act
await demo.StartReadingAsync(GotvCompetitiveProtocol13992, default);
demo.DemoEvents.DemoFileInfo += e =>
{
var x = demo;
Console.WriteLine(e);
};


var nextSkipTick = DemoTick.Zero + skipInterval;
DemoTick? nextSkipBackTick = DemoTick.Zero + skipInterval.Divide(2);

while (await demo.MoveNextAsync(default))
{
if (nextSkipTick <= demo.CurrentDemoTick && demo.CurrentDemoTick + skipInterval < demo.TickCount)
{
Console.WriteLine($"Fast forward to {demo.CurrentDemoTick + skipInterval}...");
await demo.SeekToTickAsync(demo.CurrentDemoTick + skipInterval, default);
nextSkipTick = demo.CurrentDemoTick + skipInterval;
nextSkipBackTick = demo.CurrentDemoTick + skipInterval.Divide(2);
}

if (nextSkipBackTick <= demo.CurrentDemoTick)
{
Console.WriteLine($"Rewind to {demo.CurrentDemoTick - skipInterval.Divide(4)}...");
await demo.SeekToTickAsync(demo.CurrentDemoTick - skipInterval.Divide(4), default);
nextSkipBackTick = default(DemoTick?);
}
}

// Assert
Assert.That(demo.CurrentDemoTick.Value, Is.EqualTo(251327));
}
}
61 changes: 42 additions & 19 deletions src/DemoFile/DemoEvents.cs
Original file line number Diff line number Diff line change
@@ -1,74 +1,97 @@
#pragma warning disable CS1591
using System.Buffers;
using Google.Protobuf;
using Snappier;

#pragma warning disable CS1591

namespace DemoFile;

public struct DemoEvents
{
internal bool ReadDemoCommand(EDemoCommands msgType, ReadOnlySpan<byte> buffer)
internal bool ReadDemoCommand(EDemoCommands msgType, ReadOnlySpan<byte> buffer, bool isCompressed)
{
switch (msgType)
{
case EDemoCommands.DemStop:
return false;
case EDemoCommands.DemFileHeader:
DemoFileHeader?.Invoke(CDemoFileHeader.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoFileHeader, CDemoFileHeader.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemFileInfo:
DemoFileInfo?.Invoke(CDemoFileInfo.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoFileInfo, CDemoFileInfo.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemSyncTick:
DemoSyncTick?.Invoke(CDemoSyncTick.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoSyncTick, CDemoSyncTick.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemSendTables:
DemoSendTables?.Invoke(CDemoSendTables.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoSendTables, CDemoSendTables.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemClassInfo:
DemoClassInfo?.Invoke(CDemoClassInfo.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoClassInfo, CDemoClassInfo.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemStringTables:
DemoStringTables?.Invoke(CDemoStringTables.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoStringTables, CDemoStringTables.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemSignonPacket:
case EDemoCommands.DemPacket:
DemoPacket?.Invoke(CDemoPacket.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoPacket, CDemoPacket.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemConsoleCmd:
DemoConsoleCmd?.Invoke(CDemoConsoleCmd.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoConsoleCmd, CDemoConsoleCmd.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemCustomData:
DemoCustomData?.Invoke(CDemoCustomData.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoCustomData, CDemoCustomData.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemCustomDataCallbacks:
DemoCustomDataCallbacks?.Invoke(CDemoCustomDataCallbacks.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoCustomDataCallbacks, CDemoCustomDataCallbacks.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemUserCmd:
DemoUserCmd?.Invoke(CDemoUserCmd.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoUserCmd, CDemoUserCmd.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemFullPacket:
var fullPacket = CDemoFullPacket.Parser.ParseFrom(buffer);
DemoStringTables?.Invoke(fullPacket.StringTable);
DemoPacket?.Invoke(fullPacket.Packet);
ReadDemoCommandCore(DemoFullPacket, CDemoFullPacket.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemSaveGame:
DemoSaveGame?.Invoke(CDemoSaveGame.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoSaveGame, CDemoSaveGame.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemSpawnGroups:
DemoSpawnGroups?.Invoke(CDemoSpawnGroups.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoSpawnGroups, CDemoSpawnGroups.Parser, buffer, isCompressed);
return true;
case EDemoCommands.DemAnimationData:
DemoAnimationData?.Invoke(CDemoAnimationData.Parser.ParseFrom(buffer));
ReadDemoCommandCore(DemoAnimationData, CDemoAnimationData.Parser, buffer, isCompressed);
return true;
default:
throw new ArgumentOutOfRangeException(nameof(msgType), msgType, null);
}
}

private static void ReadDemoCommandCore<T>(Action<T>? callback, MessageParser<T> parser, ReadOnlySpan<byte> buffer, bool isCompressed)
where T : IMessage<T>
{
if (callback == null)
return;

if (isCompressed)
{
var uncompressedSize = Snappy.GetUncompressedLength(buffer);
var rented = ArrayPool<byte>.Shared.Rent(uncompressedSize);
Snappy.Decompress(buffer, rented);
callback(parser.ParseFrom(rented[..uncompressedSize]));
ArrayPool<byte>.Shared.Return(rented);
}
else
{
callback(parser.ParseFrom(buffer));
}
}

public Action<CDemoFileHeader>? DemoFileHeader;
public Action<CDemoFileInfo>? DemoFileInfo;
public Action<CDemoSyncTick>? DemoSyncTick;
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
Loading

0 comments on commit cad95be

Please sign in to comment.