Skip to content

Add support for custom train tracks - #5114

Open
TheCrazy17 wants to merge 16 commits into
multitheftauto:masterfrom
TheCrazy17:feature/custom-train-tracks-v2
Open

Add support for custom train tracks#5114
TheCrazy17 wants to merge 16 commits into
multitheftauto:masterfrom
TheCrazy17:feature/custom-train-tracks-v2

Conversation

@TheCrazy17

@TheCrazy17 TheCrazy17 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds custom train track support to MTA:SA. Scripts can build a track from any list of points and put a train on it, instead of being limited to the game's 4 built-in tracks.

New functions:

  • createTrainTrack(nodePositions): creates a track element from a list of positions.
  • setTrainTrack(vehicle, trainTrack) / getTrainTrack(vehicle): now also accept custom track elements, not just the 4 default tracks.
  • getDefaultTrack(trackID): returns the element for one of the game's 4 built-in tracks.

A custom track runs through the game's own train code unmodified, same physics, audio, station and derail logic, just fed a different set of track data. Track IDs are a byte (0xFF reserved for "no track"), so up to 255 tracks can exist at once.

Video

Motivation

GTA:SA only ships 4 built-in train tracks. Scripters wanting custom rail-based movement (roller coasters, custom subway systems, etc.) had no way to do this with real train physics, animation and audio, only workarounds like attaching vehicles to moving objects, which don't behave like an actual train.

This picks up where #250 (by qaisjp) left off; that PR aimed to do the same thing but was closed without being finished. Resolves #388.

Test plan

Tested manually with a local test resource. Verified: creating a custom track and spawning a train on it; a closed loop with several nodes and real curves, not just straight segments; a track with height changes along its length, to check the train handles slopes correctly; switching a train between a custom track and one of the game's default tracks; destroying a custom track while a train is on it, which derails the train; and a player joining or reconnecting mid-session still receiving existing custom tracks.

traintracktest.zip

Checklist

  • Your code should follow the coding guidelines.
  • Smaller pull requests are easier to review. If your pull request is beefy, your pull request should be reviewable commit-by-commit.

Adds createTrainTrack and node position accessors to the Lua API
(server-only for now), lets setTrainTrack accept a custom track
instead of just the 4 default ones, and syncs custom tracks to
clients through the entity-add packet.

Also fixes the train track puresync field, which used a single byte
that couldn't tell "no track" apart from "default track 0" and had
no way to represent a custom track. Both the client and server side
of that sync are updated to match.

The client doesn't render custom tracks yet, it just knows enough to
keep the network stream lined up.
Custom tracks synced from the server now become a real CClientTrainTrack
element instead of just being read and discarded, so scripts and other
client code can see the node data. Trains don't actually drive on these
yet, that still needs the native track lookup hooked, which is next.
Adds the game-layer piece that was still missing: CTrainTrackManagerSA
tracks custom node data per track ID, and a hook on CTrain::ProcessControl
takes over for any train whose track ID isn't one of the game's 4 built-in
ones (which the original function isn't safe to run for, since it indexes
fixed-size arrays with that ID). Verified the hook address and the bytes it
overwrites by disassembling gta_sa.exe in Ghidra first, since guessing wrong
here means crashing every train, not just custom ones.

The replacement only covers core movement: advancing along the track,
wrapping or stopping at the ends, and carriages following the one ahead.
Station stops, passenger boarding and the derail-on-sharp-turn check are
part of the vanilla station system and don't apply to a track a script
drew, so they're left out.

Wires the whole path end to end: setTrainTrack and the ongoing puresync
now resolve a custom track's element ID down to the byte-sized ID the
native code understands, on both the RPC and puresync paths.
setTrainTrackNodePosition used to only update the server's copy, so any
client that already had the track (or a train running on it) would drift
out of sync silently. Now it broadcasts a new RPC to already-joined
clients, and the client-side track element updates both its own node
list and the data the movement hook actually reads, recomputing the
distances that depend on it.
The game indexes its three track arrays by track ID with no bounds check, so a
custom ID read past the end. Relocating those arrays keeps every ID in bounds
and lets CTrain::ProcessControl drive custom tracks itself; engine audio,
throttle, brakes and derailing all work without reimplementing them.
A train created next to a custom track lands on it rather than on the nearest
built-in one; also drops the empty hook file and the game side track accessors
left without callers.
CTrain::ProcessControl only skips the track arrays for a derailed train, so one
still riding a freed track reads a null node pointer on its next frame. The
server derails its own trains, but that arrives over the network far too late.
The game stores track coordinates in eighths of a unit and rail distances in
thirds, so reading them back divides rather than multiplies; the built-in
tracks were landing about fifty times outside the world.
FindPositionOnTrackFromCoors recomputes the rail distance but leaves the node
index pointing into the node array of the track being left, so moving onto a
shorter track read past its end and threw the train across the map.
Inserting it mid list shifted the id of every RPC below it, so a client and a
server built either side of the change disagreed on all of them. Also drops a
stale TODO and the track accessors nothing calls.
Every other element type is included in the initial full sync a joining player
gets; train tracks weren't, so a track created before someone joined stayed
invisible to them for good. The 4 built-in tracks are naturally skipped since
they have no parent element.
getTrainTrackNodeCount, getTrainTrackNodePosition, and
setTrainTrackNodePosition (plus the RPC that synced live edits to
already-joined clients) were removed along with everything that
existed only to support them: the server-side node accessors on
CTrainTrack, the client-side CTrainTrackRPCs handler, and the
now-dead SetNodePosition chain running through CClientTrainTrack and
CTrainTrackSA down to the sdk interface.

createTrainTrack, setTrainTrack, and getTrainTrack are untouched -
tracks are still created with their full node list up front, which
is all any of this ever needed.
Dropped noexcept from two functions that touch raw game memory, per
FileEx's feedback on multitheftauto#5101. Removed an unused GetTrainTrack accessor,
a stray forward declaration, and an old comment from before this
branch existed.
Forgot to drop this when the native track call went in; it was
never read anywhere past being stored. Checked and there's no cheap
way to make it do something either: the native train code always
treats tracks as closed loops, so supporting this for real would
mean patching CTrain::ProcessControl, not just wiring a bool through.
@TheCrazy17
TheCrazy17 force-pushed the feature/custom-train-tracks-v2 branch from c6f29df to dd68f3b Compare July 31, 2026 18:08
@FileEX

FileEX commented Jul 31, 2026

Copy link
Copy Markdown
Member

What happens if the tracks intersect or cross each other? I remember there used to be an issue where GTA would end up in an infinite loop internally. Also, is it possible to switch from one track to another while the train is moving, like a railway switch?

@FileEX FileEX added the enhancement New feature or request label Jul 31, 2026
@TheCrazy17

TheCrazy17 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

What happens if the tracks intersect or cross each other? I remember there used to be an issue where GTA would end up in an infinite loop internally. Also, is it possible to switch from one track to another while the train is moving, like a railway switch?

Crossing tracks is fine, in both senses: two separate tracks crossing don't interact at all, each one is its own independent node list. And a single track crossing itself doesn't confuse the game either, since it walks the nodes in the order you gave them, not by where they sit in space.

The infinite loop you're remembering is #747; went and checked it against the actual game code. Turns out it's already fixed, since 2019 (adfbd69), just never got closed. The native wraparound in CTrain::ProcessControl used to hang on a zero-length track, but that commit replaced it with a safe fmodf-based wrap that also resets to 0 on a degenerate track, and it covers our custom tracks too since they share the same length lookup as the default ones. Verified it myself against the exe with Ghidra, same address as the callstack in that issue. That issue should be marked as closed.

Switching tracks while moving already works, that's meant to let scripters build their own junctions out of multiple tracks.

Keeping this PR small on purpose, so it stays easy to review and merge. I originally wanted to throw in more functions, fix train sync, node editing, and a few other things, but that was turning into way too many changes for one PR. Kept it to just this for now.

@Dryxio

Dryxio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

CSimVehiclePuresyncPacket::Read/Write still use the old uchar layout, shouldn't they be updated to the new train track format too?

@TheCrazy17

Copy link
Copy Markdown
Contributor Author

CSimVehiclePuresyncPacket::Read/Write still use the old uchar layout, shouldn't they be updated to the new train track format too?

Done. 😀

Comment thread Client/game_sa/CTrainTrackManagerSA.cpp
Trains on this branch only worked for whoever was driving them. The client
dropped the vehicle from its sync list the moment you climbed out, so the
server kept the position you left it at and boarding again failed the
distance check and snapped the train back there. The receiving side also
interpolated a world position onto a train the rail distance was already
placing, which made it jump off the track and back.

The new files pick up the standard header format while we're here.
@guibzo

guibzo commented Aug 1, 2026

Copy link
Copy Markdown

Good one!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to create custom train tracks

4 participants