Load, scrub, lock, spectate and save race ghosts in ManiaPlanet 4 / TrackMania 2 and Trackmania Turbo, from an Openplanet plugin. A clone of the TM2020 plugin Ghosts++ rebuilt on the MP4 engine.
Grab the
.opfile from the Releases page and drop it into your plugins folder —Documents › ManiaPlanet › Openplanet4 › Pluginsfor ManiaPlanet 4,Documents › TrackmaniaTurbo › OpenplanetTurbo › Pluginsfor Turbo. Reload plugins from the Openplanet menu, or restart the game. The same.opruns on both games.
- Your own ghost is already there — entering a map loads your personal best by itself, once per map, the way Ghosts++ does. It steps aside when your ghost is in the race already, and never interrupts you about it.
- Load ghosts from anywhere — your PB, the author / gold / silver / bronze medal ghosts, any
.Replay.Gbx/.Ghost.Gbxfrom a folder browser over your Replays folder, and any entry of the map's world / zone leaderboard (paged, one click per record). The browser says who drove each replay and what they got, and greys out the ones belonging to a different map. - Scrub every ghost like a video — pause, step, seek on a time bar, speeds from ¼x to 4x, and a resync that hands the clock back to the game. A paused car is perfectly still and seeks land on the exact millisecond: Ghosts2 hooks the engine's per-record clock instead of restarting ghosts.
- Ghost lock — one padlock drives every started ghost together and keeps them in sync, so a whole leaderboard pack replays as one and late starters join at the group time.
- Spectate with the camera you want — Replay (the engine's cinematic clip), Follow with Cam 1 / 2 / 3 (behind far, behind close, internal), the free-fly camera, or the game's own spectator controls. Right-click the eye for a picker of every loaded ghost.
- Stop spectating cleanly — either restart together with the ghosts (like Ghosts++), or carry on from where you are: Ghosts2 knows how to end the engine's spectator camera clip without a respawn.
- Save any loaded ghost back to a replay file, remove ghosts one by one or all at once, and keep
ghosts alive across the mode script's periodic
RaceGhost_RemoveAlland plugin reloads. - Tells you when there is a new version — one GitHub releases check per day at most, cached, with a line in the window and a link when a newer release exists.
- Scriptable — exports for other plugins and a command pack for scripted control.
- Runs on Trackmania Turbo too — loading, the map's record table, playback control and the lock all work there; see Trackmania Turbo for the one feature that does not yet.
| Follow camera, Cam 2 | Internal camera (Cam 3) |
|---|---|
![]() |
![]() |
| Replay camera (engine clip) | Playback tab |
|---|---|
![]() |
![]() |
| Load tab: medals, leaderboard, replay files | Ghosts tab |
|---|---|
![]() |
![]() |
Open the window from the Openplanet Plugins › Ghosts2 menu. Four tabs:
- Load — Load my PB, Load author ghost (Gold / Silver / Bronze), the leaderboard table
(Fetch, then + on a record), and the replay folder browser (+ on a file). A file the game's
replay index says belongs to another map is greyed out and tagged
[another map]. - Playback — one row per started ghost: spectate / scrubber / pause / speed / resync buttons, the name, the ghost's time over its race time, and state icons (eye = spectated, clock = clock owned by Ghosts2, padlock = in the lock group). The top row toggles the lock, pauses, resumes or releases every started ghost at once, and resets the camera.
- Ghosts — what the race holds and what Ghosts2 loaded, with per-row remove, re-add, spectate and save, plus Remove all and the engine's own medal/PB ghost toggles.
- State — the current playground, map and hook status.
The strip at the bottom of the screen opens by itself for the first ghost you load and follows the Ghosts++ rules: visible during the race countdown, while you spectate and while you drag it; otherwise it hides 1.5 s after the mouse leaves it, and hovering the (invisible) strip area brings it back (settings Scrubber → Show during the race countdown / Auto-hide / Hide delay).
Buttons: step back / play-pause / step forward (step scales with the speed), speed (click faster, right-click slower), resync, eye (spectate this ghost; right-click for a picker of every ghost; with the lock on it shows whichever ghost is spectated), camera (Replay / Follow / FreeCam / Game, right-click cycles backwards; in Follow a Cam 1/2/3 button appears), and the padlock. Right-click on the time bar toggles pause.
On by default (Scrubber → Lock all ghosts by default). With the lock on, the scrubber, the Playback rows and the exports drive every started ghost: pause, seek, step and speed apply to all of them, and each frame the others mirror the scrubber ghost's clock.
The eye writes the ghost's instance id into the game's SpectatorForcedTarget and forces you into
spectator mode (what Nadeo's own replay-focus UI does). The camera button picks the spectator camera:
| Camera | What you get |
|---|---|
| Replay | the engine's cinematic camera clip |
| Follow | chase cam on the ghost; Cam 1 behind far, Cam 2 behind close, Cam 3 internal |
| FreeCam | the free-fly camera (cam 7 in TM2020 terms), moved with the game's own free-cam keys |
| Game | no override: the game's spectator camera controls apply |
Stopping either restarts you and the ghosts together (setting Spectate → Restart when you stop spectating, on by default, same as Ghosts++) or, with that setting off, ends the spectator camera clip in place so you carry on without a restart. Reset camera on the Ghosts / Playback tabs points the camera back at your car if anything ever leaves it on a ghost.
Ghosts2 asks the GitHub releases API whether a newer version has been published, at most once every 24 hours, and shows a line at the top of the window with a link when there is one. Turn it off with Settings → Updates → Check GitHub for a new release; State → Check for updates now asks immediately.
The daily budget is the point of the design, because unauthenticated api.github.com allows 60 requests an
hour per IP shared with everything else on the machine:
- the gate is a persisted wall-clock timestamp, so restarting the game or reloading the plugin does not buy another request;
- the timestamp is written before the request goes out, so a timeout, an error or a crash mid-flight still consumes the day rather than becoming a retry loop;
- a clock that has moved backwards counts as "never checked" instead of locking the check out;
- a version you have been told about is not announced again — the notification fires once per new version, and after that it is just the line in the window.
All under Openplanet › Settings › Ghosts2: replay folder, auto-load your PB, ghosts from other maps, auto re-add, time control on/off, scrubber visibility and step size, lock default, spectate options (force spectator, restart on stop, respawn delay, camera type, Follow camera, classic-race camera hook), leaderboard zone, update check.
Two of them share a shape worth knowing: Restart the run when a ghost is added (Loading) and Restart when you stop spectating (Spectate) are both Never / Unless mid-lap / Always, defaulting to Unless mid-lap. A restart is how a queued ghost starts playing and how the engine's spectator camera clip ends, but it also throws away the lap you are driving - so Ghosts2 does it freely while you are on the start line and holds off once you are past a checkpoint and still moving. When it holds off, the Ghosts tab shows a Restart now button.
Ghosts2 exports Ghosts2::ListGhosts, Browse, LoadReplay, LoadPB, LoadMedal, Remove, RemoveAll,
Spectate, StopSpectating, StopSpectatingEx(respawn), State, ShowWindow, SelectTab,
MoveWindow, GetGhostTime, Seek, SetPaused, SetSpeed, Resync, ShowScrubber, SetLockAll,
SetCameraType, SetFollowCam, ResetCamera and the leaderboard calls for other Openplanet scripts
(see src/Exports.as). The optional pack in mp4pack/ (plugin id tm-ghosts2-mp4pack) exposes the same
operations to the tm-mp4-control plugin as ghosts2.* commands
(list, state, browse [dir=], load_replay, load_pb, load_medal, lb_fetch, lb_list, load_lb, remove,
remove_all, spectate, stop_spectating [respawn=], cam type=, follow_cam cam=, cam_reset,
lock all=, ghost_time, seek, pause, speed, resync, scrubber, show_window). The pack must
not re-declare the imports: Openplanet compiles a dependency's exports files into the dependent module.
SKIP_RELOAD=1 ./build.sh dev # lint (openplanet-lsp, MP4 type db) + stage to ~/Openplanet4/Plugins
./build.sh dev # same, plus hot-reload via tm-remote-build
./build.sh release # produce tm-ghosts2-<version>.op
build.sh runs openplanet-lsp check --game-target MP4 first and refuses to stage on errors.
GAME=turbo ./build.sh dev targets Trackmania Turbo instead (--game-target TURBO, staging into
~/OpenplanetTurbo/Plugins, RemoteBuild on the Turbo port). See Trackmania Turbo below for what
works there. ./build.sh dev also reloads the tm-ghosts2-mp4pack command pack, because reloading this
plugin unloads anything that depends on it.
tools/tm2-smoke.sh runs a live smoke test against the game through the command pack: adds actually
starting, the replay browser, a leaderboard round trip, playback and the lock, spectate/stop and the
camera reset, removal, and hook health. It adapts to what the race can do, so it is also useful in the
legacy solo playground where ghosts cannot be added, at a CampaignSolo challenge card where there is no
run to restart, and on Turbo where spectating is refused by design (GAME=turbo tools/tm2-smoke.sh).
Current: 23/23 on ManiaPlanet 4 (TimeAttack and a started CampaignSolo run); Turbo's last full run was
a clean 19/19 on 0.5.0, before the two update-check assertions were added - the update check itself has
since been verified on Turbo by hand.
tools/showcase-shots.sh and tools/readme-shots.sh regenerate the screenshots through the command pack
(GAME=turbo SHOTS=ui tools/showcase-shots.sh <instId> for the Turbo set).
- Playback control hooks the engine's per-record clock update (
RaceGhostRecord_UpdatePlaybackTime,Dev::Hook) and sets each record's StartTime from the exact tick time, so paused cars do not vibrate and seeks land exactly. Resync hands the clock back; a respawn rebuilds the records, which releases any owned clock. The setting Time control turns the hook off. - Starting an added ghost is a plain
SpawnPlayer, never an unspawn.RaceGhost_Addonly queues the ghost; the engine builds its playback record at the next spawn.SpawnPlayeron an already-spawned player is enough to trigger that, on both games and in every mode tried. Unspawning first looks like the cleaner restart and does work in TimeAttack, butCampaignSolo— the mode behind the game's own SOLO campaign, i.e. what most people play — owns spawning:UnspawnPlayertakes the car away, drops the map's challenge card back over the track, and discards every laterSpawnPlayeron the same frame. Ghosts2 also asks whether there is a run to restart at all:CampaignSoloparks the car on the track behind that card withIsSpawnedtrue butRaceStartTime0, and spawning from there costs you the screen you are on. An add made there is held, not dropped, and fires the moment you start your run. - Which map a replay belongs to — the
CGameGhostScripthanded back byReplay_Loadhas only a nickname, a time and checkpoints, so a ghost carries no map identity.CGameCtnApp.ReplayRecordInfosdoes: oneCGameCtnReplayRecordInfoper replay the game has catalogued, withMapUid,PlayerNicknameandBestTime. ItsFileNameis relative to the Replays folder and backslash-separated, so Ghosts2 matches it as a suffix of the absolute browse path. A file missing from that index is treated as unknown, never as wrong - a replay outside the Replays tree still loads. - Auto re-add — the stock solo mode calls
RaceGhost_RemoveAll()on every phase transition. Ghosts2 keeps theCGameGhostScript@handles and puts them back, rate limited, giving up after a few failed attempts so it never fights the mode script.RaceGhost_Addtakes effect at the next (re)spawn: the engine keeps a script-facing add list and a live copy rebuilt when the player spawns, and Ghosts2 tracks (and, after a reload, adopts) ghosts from both. - Stop spectating — forcing the spectator makes the engine play a spectator camera clip on the
ghost (
CGameCtnMediaClipPlayeron the game terminal) that clearing the UI config never stops; only a (re)spawn does, or a ref-counted release of the terminal's clip slot a few frames after the UI config restore has reached the engine (the no-restart mode). With the Follow / FreeCam / Game cameras the camera system's auto target also stays on the ghost, so stopping writes it back to your car. - Follow Cam 1/2/3 — the engine hard-codes the far chase cam for the forced Follow spectator camera; Ghosts2 writes the chosen vehicle cam id into the camera system from its camera-target hook, which runs right before the camera update reads it.
- Classic race camera hook — in the campaign race (
CTrackManiaRace1P) the engine never copiesSpectatorForcedTargetinto the camera, so Ghosts2 hooks the camera target resolver and writes the ghost's id into the forced-target slot right before it is read (setting Camera hook (classic race)). All hooks are removed on unload.
The research behind these lives in the openplanet/research/mp4/ notes (Ghidra decompiles and runtime
notes; engine build 2019-11-19_18_50, Openplanet 1.29.14).
Ghosts2 runs on Trackmania Turbo, and almost everything works. Correction to the 0.4.0 notes: Turbo
does have a mode script. The earlier reading came from the legacy CTrackManiaRace1P playground,
which is equally ruleless on ManiaPlanet 4. Launch a map through the campaign flow (INSERT COIN →
CAMPAIGN → SOLO CAMPAIGN → a series → a map) and the playground is a CTrackManiaRaceNew driven by a
real CTrackManiaRaceRules with ServerModeName TMC_CampaignSolo, the whole RaceGhost_* surface,
SpawnPlayer, UIManager, DataMgr and ScoreMgr.
| Playback tab: three medal ghosts at mixed speeds | Ghosts tab |
|---|---|
![]() |
![]() |
What works on Turbo, all measured live on campaign map 003:
-
Loading — author / gold / silver / bronze medal ghosts, and Load my PB. Gold, silver and bronze come from
DataMgr.Ghosts, which the engine fills when the campaign map loads: offline, instantly, no web task. The author ghost is never preloaded, so Ghosts2 pulls it out of the map's record table instead (see below) — it costs one fetch and then behaves like any other ghost. -
The map's record table as a leaderboard —
DataMgr.RetrieveRecords(MapInfo, UserId)fillsDataMgr.Recordswith the medal times and your own record, each row carrying aGhostUrlthatGhostRetrieveaccepts. It is local and works with no network at all, so the Load tab calls it Map records rather than pretending there is a world leaderboard. -
Adding ghosts, and the restart that starts them —
RaceGhost_Addplus aSpawnPlayer, exactly as on ManiaPlanet 4. -
Playback control, held from inside the engine's own playback tick. This took three attempts and the first two are worth recording, because each looked right from the inside.
0.6.0 held
record + 0x0c = rules.Now - wantedfrom ourUpdate().rules.Nowis not the clock the engine renders ghosts against: measured on campaign 003 over 90 samples,EngineNow - RaceNowran −88…−39 ms and a ghost held at 8000 ms rendered 8017–8123 — 106 ms of wander, about a metre of position wobble.0.7.0 recovered the engine's own clock instead. It wrote
elapsed = EngineNow - StartTimeat its last tick andStartTimeis a value we put there, soEngineNow = writtenStart + elapsedgets it back exactly. That removed the skew but not the jitter, because writing fromUpdate()still means predicting the engine's next tick and Turbo ticks ghosts at about 30 Hz. The rolling hold error was −9…+16 ms — small, and still visible as a shudder on a paused ghost.0.7.1 stopped predicting.
TickPlayback(0x009116F0) receives the record in ECX andnowMson the stack; five bytes in, at theMOV EDX,0xF4240that begins the ms→ns conversion, both values are still in registers and the instruction has no relative operand, so it relocates with no padding. Inside the tickStartTime = nowMs - wantedis not an estimate —nowMsis the value the engine is about to use. Measured paused at 9000 over a rolling 600-tick window, the hold error read 0 - and the car still shuddered.0.7.2 fixed the callback and stopped trusting that number. Two of the tick's three callers run every frame off clocks a few milliseconds apart, so
nowMsarriving in the hook is not monotonic; 0.7.1 read a backwards step as a bad reading and returned, skipping the write, so the lagging caller rendered from a StartTime one tick stale. AndholdErrorcould never have caught it: it compares the record's elapsed against what we asked for, while the hook derives StartTime from the samenowMsthe engine subtracts, so it reads 0 by construction. The honest measurement is where the car is - through the record's mobil to the vis entry's Iso4 - and there a ghost held at 49.825 was oscillating over 0.67 m. Writing on every call, whichever clock it came from, takes that to 0.00000 m.ghosts2.listreports that position aspos, and the smoke suite asserts on it. Playback and speed track (1x/2x/0.5x/0.25x measured 3110/6200/1550/775 ms per 3 s), and locked ghosts sit at spread 0. The prologue is byte-checked before patching; a mismatch falls back to theUpdate()path rather than refusing to run.The 0.5.0 note claiming this was "measured exact" was self-confirming — Ghosts2 reports an owned ghost's time as the value it is asking for, so the old measurement compared our intention against itself.
ghosts2.listnow also reportsengineGhostTimeandholdError, andghosts2.statereportsholdErr/holdErrMin/holdErrMax/tickEstper owned clock, so the real error stays visible. -
Adopting ghosts the mode added. Through 0.7.0, no ghost Ghosts2 had not added itself ever appeared in the list on Turbo — a bronze medal ghost could be driving on track and simply not be there. Two faults were stacked: the add entry's ghost handle is at
+4on Turbo (a 32-bit pointer behind a sentinel word) and+0on ManiaPlanet, and adoption read a fixed+0; and underneath that, the nod sanity check refused every raw pointer on Turbo because the 32-bit vtable signature had never been measured, so even a correct pointer resolved to nothing. It is measured now: the first vtable slot is one shared function for everyCMwNodsubclass, at image offset0x55ce50, agreeing across App, the race, the rules, the map,DataMgrand threeCGameCtnGhosts. This also cured a duplicate pile — because adoption always failed, every plugin reload added a fresh copy of your PB instead of adopting the one already in the race. -
The lock, the scrubber, removal, re-add and the save path (Turbo has no replay-file writer, so saving goes through
DataMgr.StoreRecordName— which writes into the map's own record table as your record, so Ghosts2 asks for confirmation first).
Spectating: half solved. The target now resolves, and that was the part nobody had. A race ghost's
instance id is not a GameMobilId, which is why 0.6.0's attempt did nothing: it wrote the instance id
straight into CGameControlCamera.FollowedGameMobilId, every camera accepted it and read it back happily,
and the view never moved. The scene's mobil for the ghost carries both numbers — CGameMobil.ReplicaId is
the race instance id and CGameMobil.GameMobilId is what cameras follow (0 = your own car) — so
CGameCtnPlayground.GameScene.GameMobils is the translation. Measured on campaign 001: instId 0x0FE0000A
→ GameMobilId 11, written to all seven managed cameras, and it sticks.
The view still does not move, and the reason is now precise rather than a guess: the camera the race actually
runs, CGameControlCameraTrackManiaRace3, ignores FollowedGameMobilId. Nothing on the script surface
redirects it. CamsMaster.CurrentCam is an index into ManagedCams (confirmed — it always matches the entry
reporting IsActive), but writing it is overwritten by the engine every frame, even when re-asserted from
Update(). CGameTerminal.SpectatorCameraType changes nothing, CGamePlayerCameraSet.DefaultCam accepts a
new EGameCam (Close → Free → Spectator → Behind) without changing the active camera, and
CGamePlaygroundSpectating has no fields at all. So Turbo needs what ManiaPlanet 4 needed — a hook on
whatever the race camera reads for its target — and that address is not known yet. Ghosts2 aims the cameras
and says so; it does not pretend the view followed.
The camera list is worth having regardless: Turbo selects a camera object rather than forcing a camera
type, and the list is per-playground — campaign 001 offers TrackManiaRace3 ×2, VehicleInternal ×2,
TrackManiaRace, a bare camera, and a real Free camera. Ghosts2 reads them by type (repeats get a #2
suffix so a saved setting names exactly one), exposes them in ghosts2.state as turboCams, and
ghosts2.turbo_cam kind=<name> selects one.
What does not work yet:What does not work yet:
- The engine's own race ghosts (the medal opponents the game itself puts in the race) list but have no playback record Ghosts2 can resolve, so they cannot be scrubbed. Load the same medals through the Load tab and they are fully drivable.
.Ghost.Gbxfiles. A Turbo profile'sMapsGhosts/folder holds ~20-byte index stubs, not ghost data, andGhostRetrievetakes urls rather than file paths. The browser still opens there (there is no Replays folder on Turbo) and says so plainly rather than failing silently.
Two things about Turbo that are easy to lose an afternoon to:
- Turbo pauses whenever its window loses focus — arcade-port behaviour. The playground clock freezes behind an INSERT COIN / CAMPAIGN panel. Any timing measurement needs the window focused, or you will measure a stopped clock and conclude your pause is perfect.
UnspawnPlayeris destructive there — it drops the playground to the arcade attract mode and never comes back. Ghosts2 never calls it (on either game; see How it works).
Measured detail: openplanet/research/turbo/2026-09-08-Turbo-Setup.md.
- The hooks target engine build 2019-11-19_18_50. Prologue bytes are checked before hooking and each
feature disables itself on a mismatch; the script API alone only offers the forward-only
RaceGhost_AddWithOffset. - Ghost identity is a heuristic for engine-loaded ghosts (
CGameCtnGhost.Idis0xffffffff), so they are matched by stripped nickname + race time. DataFileMgris documented as "only available for local solo modes", so replay loading and saving are expected to be unavailable online.- Saving writes
.Replay.Gbx(MP4 has noGhost_Save), underReplays/Ghosts2/. - Only ghosts loaded by Ghosts2 (or adopted from the race) can be removed individually, spectated or saved; the engine's own medal/PB ghosts have no instance id.
- The legacy solo playground (
CTrackManiaRace1P) will not take new ghosts. That is the race you get from the classic Campaigns menu: itsCTrackManiaRaceRulesnod has an emptyPlayerslist, soRaceGhost_AddreturnsMwId(0)and there is nobody forSpawnPlayerto restart. Everything else works there - the opponents you picked in the game's own dialog are listed, scrubbed, locked together and spectated as usual. A script-driven race (CTrackManiaRaceNew) takes ghosts normally. - The game's own race ghosts cannot be taken out of the race; Ghosts2 checks whether a removal landed and tells you when it did not, rather than dropping the row and letting the ghost reappear.
- In
CampaignSolo, a ghost added at the map's challenge card waits for your start. That screen is not a run — the car is parked on the track behind it — and asking the engine for a spawn from there takes the car away. Ghosts2 holds the restart and fires it as soon as you begin, so the ghost starts with you; the status line says which of the two happened. - On Turbo, the spectator view does not follow the ghost (the target resolves, but the race camera
ignores it), scrubbing the engine's own race ghosts is not available, and
.Ghost.Gbxfiles cannot be loaded. See Trackmania Turbo. - Your race HUD stays on screen while you spectate, showing a frozen chrono. Setting the
OverlayHide*fields (evenOverlayHideAll) onrules.UIManager.UIAlldoes not affect it - the writes stick but the HUD does not change - so the solo HUD is driven from somewhere else. Still open.
Verified against ~/Openplanet4/Openplanet.h + Openplanet4.json (engine build
2019-11-20 04:50:52, Openplanet 1.29.14) and the research notes under
~/src/openplanet/research/mp4/.
Exists and is used:
| API | Signature |
|---|---|
CTrackManiaRaceRules.RaceGhost_Add |
MwId (CGameGhostScript@ Ghost, bool DisplayAsPlayerBest) |
CTrackManiaRaceRules.RaceGhost_AddWithOffset |
MwId (CGameGhostScript@, uint OffsetMs) — offset is unsigned, forward-only |
CTrackManiaRaceRules.RaceGhost_Remove / _RemoveAll |
void (MwId) / void () |
CGameDataFileManagerScript.Replay_Load |
CWebServicesTaskResult_GhostListScript@ (wstring Path), .Ghosts is MwFastBuffer<CGameGhostScript@> |
CGameDataFileManagerScript.Replay_Save |
CWebServicesTaskResult@ (wstring Path, CGameCtnChallenge@ Map, CGameGhostScript@ Ghost) |
CGameDataFileManagerScript.Ghost_Download |
CWebServicesTaskResult_GhostScript@ (string FileName, string Url) — leaderboard entries carry both |
CGameDataFileManagerScript.TaskResult_Release |
void (MwId TaskId) — also on ScoreMgr |
CGameScoreAndLeaderBoardManagerScript.Map_GetRecordGhost |
CWebServicesTaskResult_GhostScript@ (MwId UserId, string MapUid, string Context), .Ghost |
CGameScoreAndLeaderBoardManagerScript.MapLeaderBoard_GetPlayerList |
(MwId(0), mapUid, "", zone, offset, count) → CGameNaturalLeaderBoardInfoScript entries |
CWebServicesTaskResult |
Id, IsProcessing, HasSucceeded, HasFailed, IsCanceled, ErrorType/Code/Description |
CGamePlaygroundUIConfig |
SpectatorForcedTarget, SpectatorAutoTarget, ForceSpectator, SpectatorForceCameraType, UISequence (all writable) |
CGameGhostScript |
only Id, Result (CTmRaceResultNod, .Time is int), Nickname |
CGameCtnGhost |
GhostLogin, GhostNickname, RaceTime, NbRespawns, EventsDuration, Validate_* |
MwId |
value type with MwId(), MwId(uint), opEquals, GetName/SetName, .Value |
CMwNod.Id |
every nod has an MwId Id, which is how the local user id is obtained from rules.Users[i] |
Does not exist in MP4 (present in TM2020 / Ghosts++, do not port):
NGameGhostClips_SMgr,CGameGhostMgrScript— no ghost-clip manager at all.CGamePlaygroundUIConfig.Spectator_SetForcedTarget_Ghost— write the plainSpectatorForcedTargetfield instead.CSmArenaRulesMode.Ghosts_SetStartTime— no start-time setter (Ghosts2 hooks the clock instead).DataFileMgr.Ghost_Save— onlyReplay_Save.CGamePlaygroundScript.ModeName— detect the mode byapp.PlaygroundScriptbeing aCTrackManiaRaceRules.- MLHook and any
SendCustomEventevent bus — driveCTrackManiaRaceRulesmethods directly.
Verified in-game (TimeAttack on A01, and the classic campaign race):
LocalUserId()(rules.Users[i].Idmatched byGetLocalLogin()) is the idMap_GetRecordGhostwants.SpectatorForcedTargetwritten from Openplanet sticks; the camera follows the ghost until the values are restored.- In script modes
RaceGhostsstays empty, so plugin ghosts are tracked per instance withRaceGhost_GetStartTime/IsVisible; the nickname+time match is only used for the classic race list. Replay_Saveaccepts a bare filename and writes under the game'sReplaysfolder;Replay_Loadreads it back.- Releasing a task result while holding its
CGameGhostScript@keeps the ghost usable.
Verified against ~/OpenplanetTurbo/OpenplanetTurbo.json (32-bit build, Openplanet 1.29.14). Turbo's
CTrackManiaRaceRules is nearly the ManiaPlanet one; src/Compat.as holds every difference.
| Missing on Turbo | What Ghosts2 does instead |
|---|---|
RaceGhost_IsVisible |
vanished-ghost detection runs on RaceGhost_GetStartTime + add-list membership |
RaceGhost_GetPosition |
not needed by any current feature |
CTmRaceRulesPlayer.IdleDuration / .Speed |
mid-lap detection tracks .Position itself (20 cm threshold) |
CGamePlaygroundUIConfig.SpectatorForcedTarget / ForceSpectator / SpectatorForceCameraType |
nothing yet — spectating is refused, see above |
DataFileMgr (the whole manager) |
CGameDataManagerScript off rules.DataMgr / the title ManiaApp |
ScoreMgr.Map_GetMultiAsyncLevelRecordGhost |
DataMgr.Ghosts for gold/silver/bronze, DataMgr.Records[i].GhostUrl for the author |
ScoreMgr.MapLeaderBoard_GetPlayerList |
DataMgr.RetrieveRecords(MapInfo, UserId) → DataMgr.Records |
DataFileMgr.Replay_Load / Replay_Save |
DataMgr.GhostRetrieve(url) / DataMgr.StoreRecordName(...) |
CGameGhostScript.Result |
.RaceResult |
Ghost record layout, measured live (stride and field offsets differ from MP4 — src/TimeControl.as has
both sets): add entries at race+0x0c4/0x0c8 (script) and race+0x3ac/0x3b0 (live), playback records at
race+0x3b8, classic wrappers at race+0x59c/0x5a0; a record is +0x04 ghost, +0x0c StartTime
(0xffffffff = not started), +0x10 started, +0x1c displayAsPB, +0x24 instance id — and there is no
elapsed field, the engine computes it on demand.
Hazards found the hard way, so nobody repeats them:
- A generic Reflection sweep over Turbo nods hangs Openplanet's script engine (game alive, plugin
socket dead, RemoteBuild refuses). Walk
MwClassInfo.GetMember(name)by name instead. CTrackManiaMenus.MenuCampaignChallenges_Solo()hangs it the same way.DialogQuickChooseGhostOpponents()kills the whole process — on ManiaPlanet, not just Turbo.CGameManiaApp::LayerCustomEventwantsMwFastBuffer<wstring>, notstring[];openplanet-lspaccepts the latter and the game rejects it.
- Ghosts++ (TM2020) for the feature set and UI this plugin imitates.
- FortTM for the leaderboard ghost flow (
MapLeaderBoard_GetPlayerListwithMwId(0), an empty context and a zone name;FileName/ReplayUrlon each entry;Ghost_Downloadtaking them directly). - Created by Max Kaye (XertroV) + AI.
Dual-licensed under the Unlicense and
CC0 1.0, at your option. See LICENSE.









