-
-
Notifications
You must be signed in to change notification settings - Fork 21
Extracting ghosts from replays
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 = Gbx.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 benull
only if you're parsing the header usingParseHeader...()
. No need fornull
checks if you're doing the full parse. -
Clip
can benull
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
andClip
. IfGhosts
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. IfClip
is used instead, MediaTracker will not add anything additional.
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.
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);
}
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
orStart
/End
member (or both if the format was changed in later TM versions)
- Either
- Blocks (
- Tracks (
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.
var replay = Gbx.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");
}
var replay = Gbx.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");
}
- Beginner
- Extracting basic map data
- Extracting ghosts from replays
- Extracting input data from ghosts
- Extracting checkpoints from ghosts (soon)
- Builders - create new nodes extremely easily (soon)
- Intermediate
- Basic map modification (soon)
- Embedding custom items to a map
- Working with script metadata
- Extracting samples from ghosts (later)
- Fast header reading and writing (later)
- Advanced
- Advanced map modification (soon)
- Creating a MediaTracker clip from scratch (soon)
- Lightmap modification (later)
- Integrate GBX.NET with other languages (later)
-
TimeInt32
andTimeSingle
(soon) - Chunks in depth - why certain properties lag? (soon)
- High-performance parsing (later)
- Purpose of Async methods (soon)
- Compatibility, class ID remapping (soon)
- Class structure (soon)
- Class verification (soon)
- Class documentation
- Gbx from noob to master
- Reading chunks in your parser