Data snapshots
Read-only data marshalled by the host once per frame. Every accessor is a plain memory read—zero JNI, zero reflection, zero boxing.
How snapshots work
At the start of each frame the host marshals the instance's data in one go into a POD snapshot (fixed-length header + variable-length array pools + a UTF-8 string pool) and passes it to mtrRender together with the state block pointer. On top of that memory, the SDK's typed wrappers (mtr::Vehicle, mtr::Pids, mtr::Lift, mtr::EyeCandy) provide accessors with the same names as their JS counterparts:
- Strings come back as
mtr::StrView(data+len); convert with.str()to astd::stringand test with.empty(); the view is valid only within the currentrendercall. - Out-of-range indices in ordinary lists throw
std::out_of_range(matching the semantics of the Java side'sIndexOutOfBounds). - Do not keep snapshot pointers or wrappers across frames.
Snapshots cover the fields that are read at high frequency. Data that is not in the snapshot (networking, files, deep interfaces of the render manager and so on) is reached through mtr::Api—that is a low-frequency channel; do not use it on the hot path.
Vehicle
MTR 4 uniformly uses vehicle as its term (the resource pack declaration key vehicleScripts, the Java-side VehicleWrapper), so this SDK's data wrapper class is named mtr::Vehicle. Earlier SDK versions followed the MTR 3 convention and named it mtr::Train; that name is now retained as a source-compatible alias—either name compiles, and the behaviour is completely identical.
Note: the mtr::Vehicle name is in the repository's SDK sources and ships with the next release's zip; if your project uses the 2.3.0-beta-2.yanyang.7 (or earlier) SDK zip, refer to this class as mtr::Train (both spellings compile against the new SDK).
Provided by mtr::Vehicle, mirroring the JS VehicleWrapper + NTETrainWrapper:
| Accessor | Type | Description |
|---|---|---|
id() / siding_id() / this_route_id() / departure_index() | int64_t | Vehicle / siding / current route ids and the departure index (64-bit ids keep full precision throughout) |
car_count() / car(i) | int32_t / Car | Car count and per-car data (see below) |
speed_kmh() / speed_ms() | double | Vehicle speed |
rail_progress() / rail_progress(car) | double | Rail progress; the parameterised version works out the position of car i from the car lengths |
door_value() | double | Door opening 0..1 |
notch_level() / notch_position() | int32_t / double | Notch level 0..5 and its normalised position |
reversed() / on_route() / door_opening() | bool | Reversed / on a route / doors currently opening |
currently_manual() / manual_allowed() | bool | Manual driving state |
client_player_riding() / rendered() | bool | Client player riding / any car visible |
total_dwell_time_millis() / elapsed_dwell_time_millis() | double | Total dwell time / elapsed dwell time |
game_time_millis() / in_game_time() | int64_t | Host wall clock (server offset already corrected) / in-game tick 0..24000 |
stops() / this_route_stops() / next_route_stops() | StopList | All / current-route / next-route stop lists (see below) |
next_stop_index() | int32_t | Next-stop index on the current route (precomputed by the host—don't recompute it yourself) |
route_name() / route_color() / circular_state() / circular() | StrView / uint32_t / uint8_t / bool | Current route name / colour ARGB / circular state (0 none, 1 clockwise, 2 anticlockwise) |
getTransportMode() / getTransportType() | TransportMode / StrView | See transport modes and types |
doorDirection() / getDirectionOfDoorOpening() | DoorDirection / StrView | Door-opening side: NOT / "not" (neither side), LEFT / "left" (head-left), RIGHT / "right" (head-right), ALL / "all" (both sides). While dwelling at a platform the value is reported the same regardless of the current door phase (not-yet-open / partial / closing / closed); whilst running on the mainline it predicts the next stop — laying the vehicle out at its stopping point and running MTR's own canOpenDoors block scan per car. The prediction prefers the server-side per-route persistent memory (persisted with the save when the server runs this mod — valid beyond view distance, incrementally refreshed as platform blocks change); when the server has no answer it falls back to the client's own view-distance scan. If any one car on a side can open, that whole side counts as openable; left/right are relative to the vehicle head (bogie-1 end) — travel direction and reversing never flip them. Purely informational — MTR still owns which doors actually open. Identical to the JS vehicle.getDirectionOfDoorOpening() (ABI 11) |
siding_name() | StrView | Siding display name (user-defined name / description) |
raw() | JcmVehicleSnapshot& | Raw snapshot (see C ABI for ABI details) |
Car
| Accessor | Description |
|---|---|
length() / width() | Car length / width |
left_door_open() / right_door_open() | Whether the left / right doors are fully open |
rendered() | Whether this car survived culling and is visible this frame |
vehicle_type() | Car type id (e.g. mtr_custom_train_demo_cab_1) |
StopList
| Accessor | Description |
|---|---|
size() / empty() / at(i) / operator[](i) | Size and random access (out-of-range access throws) |
next_stop_index(rail_progress, tolerance=0.5) | Works out the next stop from rail progress; for the current route's list, just use Vehicle::next_stop_index() (already precomputed by the host) |
Stop / interchanges / exits
mtr::Stop mirrors the JS VehicleWrapper.Stop:
| Accessor | Description |
|---|---|
name() | Station name |
destination_name() | Destination display name for this platform |
has_custom_destination() / custom_destination() | Custom destination (JS returns null when unset; in C++ test with has_custom_destination()) |
distance() | Rail progress (-1 when unavailable) |
dwell_time_millis() | Dwell time at this stop |
route_id() / station_id() / platform_id() | Route / station / platform ids (64-bit) |
is_route_switchover() | Whether this is a route switchover stop |
route_circular_state() | Circular state of the route this stop belongs to (to distinguish direction changes after crossing routes) |
interchange_count() / interchange(i) | Interchange information: each carries color (ARGB), name (route name), getTransportMode() and getTransportType() |
exit_count() / exit(i) | Station exits: each carries name (the exit letter, e.g. "A") and destination(j) (a list of landmark strings) |
Transport modes and types
Two complementary classifications, for which the JS-side getTransportMode() / getTransportType() return exactly the same values, so scripts across languages can share layout logic:
- Mode (the
mtr::TransportModeenum):UNKNOWN / TRAIN / BOAT / CABLE_CAR / AIRPLANE, covering the four basic vehicle kinds. - Type (string ids, the
mtr::transport_typeconstants): the lower-case names of the basic modes ("train","boat","cable_car","airplane"), plus the extended route types from the MAGIC MTR Addon:train_normal / train_light_rail / train_high_speed / train_metro / train_bus / train_tram / train_sbahn; values that cannot be resolved become"unknown"or an empty view (old server data).
if (vehicle.getTransportType() == mtr::transport_type::TRAIN_METRO) { /* ... */ }
for (int i = 0; i < stop.interchange_count(); i++) {
mtr::Stop::Interchange ic = stop.interchange(i);
if (ic.getTransportType() == mtr::transport_type::TRAIN_HIGH_SPEED) { /* ... */ }
}
MAGIC types are resolved from local configuration when the client has the addon installed; when the server has it but the client does not, they arrive via a synchronised data set; old servers that send no route metadata yield empty / UNKNOWN—servers running this mod can supply complete metadata for remote stations.
Pids (arrival display)
mtr::Pids mirrors the JS PIDSWrapper:
| Accessor | Description |
|---|---|
width() / height() | Display width and height (1.0 unit = 16 px, screen coordinate system) |
rows() / is_row_hidden(i) | Number of text rows / whether row i is hidden (32 rows at most) |
station_id() | Id of the station the block is in |
is_key_block() / is_platform_number_hidden() | Whether this is a key block (controls hiding the other screens) / whether the platform number is hidden |
get_custom_message(i) | Custom message for row i; empty when unset (JS returns null) |
arrivals() | Arrivals: the arrival list (see below) |
game_time_millis() / in_game_time() | Clocks (server offset already corrected) |
Arrivals / Arrival
| Accessor | Description |
|---|---|
arrivals().size() / .at(i) | Arrival entry count and access |
arrivals().next_for_platform(platform_id, out, cursor) | Iterates in platform order (mirroring the JS forEach(platformId, cb)); the cursor is reused, zero allocation |
arrivals().mixed_car_length() | Whether the entries disagree on car count (mirrors mixedCarLength()) |
destination() / route_name() / route_number() / platform_name() | Destination / route name / route number / platform name |
arrival_time() / departure_time() / deviation() | Arrival / departure times and deviation (epoch milliseconds, already corrected) |
arrived() / departed() | Whether the vehicle has arrived / departed (compared against the current time) |
realtime() / terminating() / circular_state() | Real-time data / terminating here / circular state |
route_id() / platform_id() / route_color() / car_count() / departure_index() | Route / platform ids, route colour ARGB, car count, departure index |
Psd (platform screen door)
mtr::Psd mirrors the JS PSDWrapper (a psdScripts entry — see JS script types · PSD scripts):
| Accessor | Description |
|---|---|
type() / is_glass() / is_top() | Binding surface: Psd::GLASS or Psd::TOP |
width() / height() | Canvas size (PIDS pixels): glass — the whole segment's transparent window (96 px per block, full-height columns merge vertically); top panel — ~96 px per block wide (minus padding) × 42 high |
platform_id() / has_platform() / station_id() | Served platform and station (ids are 0 without a platform) |
platform_number() / station_name() | Platform number (e.g. "1") and station name |
door_value() / is_door_open() | Wall-wide current open amount (0..1) and whether any door is open |
doors() | PsdDoorList: the door units (below) |
arrivals() | PsdArrivalList: upcoming trains (below) |
routes() | PsdRouteList: routes calling at the platform (below; stop lists only with dataFetchMode: "ALL") |
stop() / has_station() | Complete station info of the served station (ABI 13): the mtr::PsdStation accessors below; has_station() is false (and stop() empty) when the wall has no platform / station |
game_time_millis() / in_game_time() | Clocks (server-offset corrected) |
PsdDoor (door unit)
| Accessor | Description |
|---|---|
number() | 1-based unit number along the wall — one sliding panel group (a LEFT+RIGHT pair, or the 3-block odd style) within one contiguous wall run |
is_open() / is_locked() | Live open / locked state (read straight off the door block entities every frame) |
x() / y() / z() | Block position of the unit's first door block |
PsdArrival (upcoming train)
Carries every Pids Arrival accessor (destination / route_name / route_number / platform_name / arrival_time / departure_time / deviation / realtime / terminating / circular_state / route_id / platform_id / route_color / car_count / departure_index — see Pids), plus:
| Accessor | Description |
|---|---|
door_count() / door_number(i) | The door units this train will open: the actually-open units while the train dwells (falling back to the prediction during the door-animation gaps); before arrival, an approximation — the train stops centered on the platform midpoint, its length summed from the per-car vehicle resources |
PsdRoute / PsdStop (routes and stations)
| Accessor | Description |
|---|---|
id() / name() / route_number() / color() / circular_state() / getTransportType() | Route id / name / number / ARGB color / circular state / canonical transport type id |
stops() | PsdStopList: the ordered stops, laid out like the vehicle Stop (name / destination_name / custom_destination / dwell_time_millis / route_id / station_id / platform_id / route_circular_state / interchange(i) / exit(i) — see Stop / interchanges / exits) |
PsdStation (the station record, ABI 13)
JcmPsdSnapshot.station_offset (byte offset 124; -1 = no station — the field was padding in ABI 12) points at the 96-byte JcmPsdStation record — the same data the JS psd.stop() object exposes:
PsdStation and its nested station accessors return absent objects, empty strings or defaults for invalid indices or records instead of throwing index exceptions. When constructing a snapshot outside the host, pass the actual buffer size to mtr::Psd(snapshot, byteLength). The compatible pointer-only constructor requires a fully readable header and its declared backing buffer; a raw pointer cannot reveal the allocation size.
| Field | Description |
|---|---|
station_id / name | Station id and name |
color / color_hex | Station colour — ARGB integer and #RRGGBB string |
zone1 / zone2 / zone3 | Fare zones 1–3 |
interchanges | Interchange routes: route_id / colour / name / transport_mode / type |
connecting_groups | Connecting-station routes grouped by station name (each group = the station name + its routes) |
connected_station_names | Physically connected station names |
exits | Exits: name + destinations |
platform_numbers | The station's platform numbers (the JS side adds the count) |
ABI 12 → 13 is purely additive (a padding field promoted in place, every pre-existing struct unchanged), so ABI 6–12 modules keep loading.
Lift
mtr::Lift (a snapshot + Java wrapper combination; the snapshot part):
| Accessor | Description |
|---|---|
id() | Lift instance id |
should_render() / is_double_sided() / is_client_player_riding() | Should render / double-sided / client player riding |
width() / height() / depth() | Dimensions |
door_value() | Door opening 0..1 |
angle_degrees() / angle_radians() | Facing angle |
direction() | Travel direction |
floor_count() / floor(i) | Floor list: each floor carries index, the current_floor / target_floor flags, position[3], and its floor number and description strings (fetched via string(offset,len)) |
game_time_millis() / in_game_time() | Clocks |
Deeper interfaces—the render manager, the sound manager, visibility and so on—are reached through Api's wrapper() (lifts have no dedicated JS documentation page; the Java wrapper is authoritative).
EyeCandy
mtr::EyeCandy mirrors EyecandyBlockEntityWrapper:
| Accessor | Description |
|---|---|
model_id() | Current model id |
translate_x() / _y() / _z() / rotate_x() / _y() / _z() | User-configured translation / rotation |
facing() | Facing (MC Direction.ordinal(), 0..5) |
facing_angle() | Facing angle in degrees (Direction.asRotation(): S 0 / W 90 / N 180 / E 270) — same as the JS facingAngle() |
full_brightness() | Full-brightness rendering |
redstone_level() | Redstone level 0..15 |
is_crosshair_target() | Whether targeted by the crosshair |
block_pos(i) | Block position component (i = 0..2) |
consume_block_use() | Consumes one onBlockUse event accumulated this frame (at most once per frame; multiple clicks within a frame are merged—consistent with the JS event queue behaviour) |
game_time_millis() / in_game_time() | Clocks |
Event details (player, held item and so on) are read through Api:
mtr::Api api(ctx.input());
auto event = api.context().call("events").get("onBlockUse");
if (event.call("occurred").as<bool>()) {
auto detail = event.call("detail");
auto player = detail.call("player");
auto item = detail.call("item");
}
The eyecandy drawing context EyeCandyContext provides draw_model(model, matrices), play_sound(sound, volume, pitch), set_outline_shape(boxes, count) and set_collision_shape(boxes, count) (6 floats per box; collision boxes keep the Java-side 1.5-block height limit, and anything beyond is validated and logged by the host). Lifts reuse the same context type (LiftContext = EyeCandyContext).
Helper types
<mtr/util.hpp> provides hot-path-friendly cycling and state-tracking utilities (zero allocation):
// "A|B|C" rotates one segment every 40 ticks; switching views is just an offset computation
mtr::CycleTracker dest{"KOWLOON|TSUEN WAN"};
StrView current = dest.value(vehicle.in_game_time());
| Member | Description |
|---|---|
CycleTracker(packed, interval_ticks=40) | Constructs the cycler (mirrors TextUtil.cycleString) |
value(game_tick) / value_str(game_tick) | The currently active segment (view / string) |
interval() / part_count() | Rotation interval and segment count |
StrView | A {data, len} string view: .str(), .empty(), comparable with == |
State-machine tracking mtr::StateTracker<T> (mirrors the upstream StateTrackerJS; the clock is passed explicitly — the snapshot's game_time_millis() is the natural equivalent): keep the tracker field in State and perform a single transition check per render:
struct State {
mtr::StateTracker<Phase> phase; // persists across frames
};
// in render():
if (s.phase.set_state(next, vehicle.game_time_millis())) {
// returned true — first frame in this state (new upstream prerelease.2 semantics)
}
double staying = s.phase.state_now_duration(vehicle.game_time_millis());
if (s.phase.changed_from_to(PHASE_IDLE, PHASE_ARRIVING)) { /* play arrival animation */ }
| Member | Description |
|---|---|
set_state(value, now_ms) | Update the state; returns whether this is the first frame in the state (setState returns a boolean since upstream v2.3.0-prerelease.2) |
state_now() / state_last() / state_now_first() | Current / previous state; first-frame flag |
state_now_duration(now_ms) | How long the current state has been active (milliseconds) |
changed_to(state) / changed_from_to(old, new) | Whether this frame entered the given state / transitioned from one given state to another |
Up next: Drawing and canvases — turning this data into pixels on screen.