Extracting ghosts from replays - BigBang1112/gbx-net GitHub Wiki

This page applies to GBX.NET 1.2.X and lower - updated 2.x.x info coming soon...

Replay (internally called CGameCtnReplayRecord) is container of map + ghosts or MediaTracker clip, with additional parameters. Ghost (internally called CGameCtnGhost) or clip (internally called CGameCtnMediaClip) alone doesn't know where to render itself, so the replay object packs the map object with it to use for displaying and rendering.

The goal is to extract all ghosts to a set of .Ghost.Gbx files, which means to Save() all of the ghost objects.

To parse a replay, it's as simple as this:

var replay = GameBox.ParseNode<CGameCtnReplayRecord>("Path/To/My/Replay.Replay.Gbx");

Ghosts can appear strictly part of the replay (in the replay.Ghosts member) or part of the MediaTracker clip (in the replay.Clip member). For maximum flexibility, you should always check both places.

  • Ghosts should be null only if you're parsing the header using Parse...Header(). No need for null checks if you're doing the full parse.
  • Clip can be null if there's no custom MediaTracker work done to the replay. Always check for null first.

I recommend using the <Nullable>enable</Nullable> csproj attribute, as it makes things many times easier with null checking. GBX.NET fully supports the nullable reference type idea.

There shouldn't be a case where there is something in both Ghosts and Clip. If Ghosts has ghost objects inside, MediaTracker would use the typical timeline plus game camera (CGameCtnMediaBlockTime + CGameCtnMediaBlockCameraGame) across the ghost length. And yes, it also adds the annoying "End game" sequence with it that you love and hate. If Clip is used instead, MediaTracker will not add anything additional.

Extraction method for each ghost found

To play an engineer moment first, it's good to make the code lead towards a single CGameCtnGhost object, and use that as a parameter to a method that will do the same thing for both Ghosts and Clip cases (even when it's a method that executes a single line of code). The thing we are talking about is extracting ghost objects to a file. So let's do a method for it:

void ExtractGhost(CGameCtnGhost ghost)
{
    ghost.Save($"{ghost.GhostUid}.Ghost.Gbx");
}

GhostUid member is the easiest way to get a unique identifier. However, the format of this value varies throughout different Trackmania versions. It's still an unsure parameter, like many others over the entire library.

For inspiration, format of the file name that is used in World Record Report project is [MapUid]_[RaceTime]_[GhostLogin].Ghost.Gbx. Be aware that this format is not ideal if you want to get the map UID from the file name itself - underscore isn't a great splitter.

Finding all of the CGameCtnGhost inside the replay

A good pattern to get all of the available ghosts is to first check for the Ghosts member and then for the Clip member.

However, in recent GBX.NET, GetGhosts() method exists, which can also read through the Clip, so the code can be reduced:

foreach (CGameCtnGhost ghost in replay.GetGhosts())
{
    ExtractGhost(ghost);
}

Reading traditionally

Without the use of the GetGhosts() helper, the code would look something like this

foreach (CGameCtnGhost ghost in replay.Ghosts!)
{
    ExtractGhost(ghost);
}

if (replay.Clip is not null) // C# 9+, otherwise use "!= null"
{
    // What to do here??
}

Clips have this nesting pattern:

  • Clip (CGameCtnMediaClip)
    • Tracks (IList<CGameCtnMediaTrack>)
      • Blocks (IList<CGameCtnMediaBlock>)
        • Either Keys or Start/End member (or both if the format was changed in later TM versions)

We need to go down to the Blocks level and search for all of the CGameCtnMediaBlockGhost, which is a special kind of ghost object holder inside a MediaTracker clip. This class has a member called GhostModel holding the CGameCtnGhost.

Unrelated but good to know: In replays, clip has no name (accessible with clip.Name) and unnamed clips are presented as empty string.

Here is the sweet nesting:

if (replay.Clip is not null) // C# 9+, otherwise use "!= null"
{
    foreach (CGameCtnMediaTrack track in replay.Clip.Tracks)
    {
        foreach (CGameCtnMediaBlock block in track.Blocks)
        {
            if (block is CGameCtnMediaBlockGhost mediaBlockGhost)
            {
                ExtractGhost(mediaBlockGhost.GhostModel);
            }
        }
    }
}

This should do the required job after executing the code. If you get any exceptions that aren't related to LZO, don't hesitate to report them in issues or on my Discord.

Final shorter code

var replay = GameBox.ParseNode<CGameCtnReplayRecord>("Path/To/My/Replay.Replay.Gbx");

foreach (CGameCtnGhost ghost in replay.GetGhosts())
{
    ExtractGhost(ghost);
}

void ExtractGhost(CGameCtnGhost ghost)
{
    ghost.Save($"{ghost.GhostUid}.Ghost.Gbx");
}

Final "traditional" code

var replay = GameBox.ParseNode<CGameCtnReplayRecord>("Path/To/My/Replay.Replay.Gbx");

foreach (CGameCtnGhost ghost in replay.Ghosts!)
{
    ExtractGhost(ghost);
}

if (replay.Clip is not null) // C# 9+, otherwise use "!= null"
{
    foreach (CGameCtnMediaTrack track in replay.Clip.Tracks)
    {
        foreach (CGameCtnMediaBlock block in track.Blocks)
        {
            if (block is CGameCtnMediaBlockGhost mediaBlockGhost)
            {
                ExtractGhost(mediaBlockGhost.GhostModel);
            }
        }
    }
}

void ExtractGhost(CGameCtnGhost ghost)
{
    ghost.Save($"{ghost.GhostUid}.Ghost.Gbx");
}
⚠️ **GitHub.com Fallback** ⚠️