Data snapshots

How snapshots work

At the start of each frame, the host marshals the instance's data in one shot into a POD snapshot (fixed-length header + variable-length array pools + a UTF-8 string pool) and passes it, together with the state block pointer, to mtrRender. The SDK's typed wrappers (mtr::Vehicle, mtr::Pids, mtr::Lift, mtr::EyeCandy) provide same-named accessors over that memory as the JS Wrappers:

  • Strings come back as mtr::StrView (data + len); call .str() to convert to std::string and .empty() to test for empty; views are valid only within the current render call.
  • Out-of-range indexes in ordinary lists throw std::out_of_range (matching the Java side's IndexOutOfBounds semantics).
  • Do not keep snapshot pointers or wrappers across frames.
Coverage

Snapshots cover the fields read at high frequency. Data that is not in the snapshot (networking, files, deep interfaces of the render managers, etc.) is reached through mtr::Api โ€” that is the low-frequency channel; keep it off the hot path.

Vehicle

Terminology: MTR 3's train โ†’ MTR 4's vehicle

MTR 4 uniformly calls these vehicles (the resource pack declaration key is vehicleScripts, the Java side is VehicleWrapper), so this SDK's data wrapper class is named mtr::Vehicle. Earlier SDK releases named it mtr::Train after the MTR 3 convention; that name is now kept as a source-compatible alias โ€” both spellings compile and behave identically.

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:

AccessorTypeDescription
id() / siding_id() / this_route_id() / departure_index()int64_tVehicle / siding / current route ids, departure index (64-bit ids keep full precision end to end)
car_count() / car(i)int32_t / CarCar count and per-car data (see below)
speed_kmh() / speed_ms()doubleVehicle speed
rail_progress() / rail_progress(car)doubleRail progress; the parameterized version derives the position of car i from car lengths
door_value()doubleDoor opening 0..1
notch_level() / notch_position()int32_t / doubleNotch level 0..5 and normalized position
reversed() / on_route() / door_opening()boolReversed / on a route / doors currently opening
currently_manual() / manual_allowed()boolManual driving state
client_player_riding() / rendered()boolClient player riding / any car visible
total_dwell_time_millis() / elapsed_dwell_time_millis()doubleTotal dwell time / elapsed dwell time
game_time_millis() / in_game_time()int64_tHost wall clock (server-offset corrected) / in-game tick 0..24000
stops() / this_route_stops() / next_route_stops()StopListStop lists for all stops / the current route / the next route (see below)
next_stop_index()int32_tNext-stop index within the current route (precomputed by the host โ€” don't recompute it yourself)
route_name() / route_color() / circular_state() / circular()StrView / uint32_t / uint8_t / boolCurrent route name / color ARGB / circular state (0 none, 1 clockwise, 2 counterclockwise)
getTransportMode() / getTransportType()TransportMode / StrViewSee Transport modes and types
doorDirection() / getDirectionOfDoorOpening()DoorDirection / StrViewDoor-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); while 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()StrViewSiding display name (user-defined name / description)
raw()JcmVehicleSnapshot&Raw snapshot (ABI details in C ABI)

Car

AccessorDescription
length() / width()Car length / width
left_door_open() / right_door_open()Whether the left / right doors are fully open
rendered()Whether this car is still visible after culling this frame
vehicle_type()Car type id (e.g. mtr_custom_train_demo_cab_1)

StopList

AccessorDescription
size() / empty() / at(i) / operator[](i)Size and random access (out-of-range throws)
next_stop_index(rail_progress, tolerance=0.5)Derives the next stop from rail progress; for the current route's list use Vehicle::next_stop_index() directly (already precomputed by the host)

Stop / interchanges / exits

mtr::Stop mirrors the JS VehicleWrapper.Stop:

AccessorDescription
name()Stop 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 this stop's route (distinguishes direction changes after crossing routes)
interchange_count() / interchange(i)Interchange info: each has color (ARGB), name (route name), getTransportMode(), getTransportType()
exit_count() / exit(i)Station exits: each has name (the exit letter, e.g. "A") and destination(j) (a list of landmark strings)

Transport modes and types

Two complementary views: the JS-side getTransportMode() / getTransportType() return exactly the same values, so scripts across languages can share layout logic:

  • Mode (the mtr::TransportMode enum): UNKNOWN / TRAIN / BOAT / CABLE_CAR / AIRPLANE, covering the four basic vehicles.
  • Type (a string id, the mtr::transport_type constants): the lowercase names of the basic modes ("train", "boat", "cable_car", "airplane") plus the extended route types of the MAGIC MTR Addon โ€” train_normal / train_light_rail / train_high_speed / train_metro / train_bus / train_tram / train_sbahn; unresolvable values come back as "unknown" or an empty view (older 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) { /* ... */ }
}
Where the data comes from

MAGIC types resolve from local configuration when the client has the addon installed; when the server has it but the client doesn't, they arrive through the synchronized data set; older servers that don't send route metadata yield empty / UNKNOWN โ€” servers running this mod can supply full metadata for remote stations.

Pids (arrival display)

mtr::Pids mirrors the JS PIDSWrapper:

AccessorDescription
width() / height()Display width / height (1.0 unit = 16 px, screen coordinate system)
rows() / is_row_hidden(i)Number of text rows / whether row i is hidden (up to 32 rows)
station_id()Id of the station the PIDS is at
is_key_block() / is_platform_number_hidden()Whether it is a key block (controls hiding 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 corrected)

Arrivals / Arrival

AccessorDescription
arrivals().size() / .at(i)Arrival entry count and access
arrivals().next_for_platform(platform_id, out, cursor)Iterates in platform order (mirrors the JS forEach(platformId, cb)); the cursor is reused, zero allocations
arrivals().mixed_car_length()Whether 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, corrected)
arrived() / departed()Whether it has arrived / departed (compared against the current time)
realtime() / terminating() / circular_state()Realtime data / terminating this trip / circular state
route_id() / platform_id() / route_color() / car_count() / departure_index()Route / platform ids, route color ARGB, car count, departure index

Psd (platform screen door)

mtr::Psd mirrors the JS PSDWrapper (a psdScripts entry โ€” see JS script types ยท PSD scripts):

AccessorDescription
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)

AccessorDescription
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:

AccessorDescription
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)

AccessorDescription
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.

FieldDescription
station_id / nameStation id and name
color / color_hexStation color โ€” ARGB integer and #RRGGBB string
zone1 / zone2 / zone3Fare zones 1โ€“3
interchangesInterchange routes: route_id / color / name / transport_mode / type
connecting_groupsConnecting-station routes grouped by station name (each group = the station name + its routes)
connected_station_namesPhysically connected station names
exitsExits: name + destinations
platform_numbersThe 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):

AccessorDescription
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()Heading angle
direction()Travel direction
floor_count() / floor(i)Floor list: each floor has index, current_floor / target_floor flags, position[3], and floor-number / description strings (fetched via string(offset,len))
game_time_millis() / in_game_time()Clocks

Deeper interfaces โ€” render manager, sound manager, visibility, and so on โ€” are reached through Api's wrapper() (lifts have no standalone JS documentation page; the Java wrapper is authoritative).

EyeCandy

mtr::EyeCandy mirrors EyecandyBlockEntityWrapper:

AccessorDescription
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 the crosshair points at it
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 โ€” same behavior as the JS event queue)
game_time_millis() / in_game_time()Clocks

Event details (player, held item, etc.) 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 draw 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's 1.5-block height cap, with overflow 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 helpers (zero allocation):

// "A|B|C" rotates one segment every 40 ticks; switching views is pure offset math
mtr::CycleTracker dest{"KOWLOON|TSUEN WAN"};
StrView current = dest.value(vehicle.in_game_time());
MemberDescription
CycleTracker(packed, interval_ticks=40)Constructs a 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
StrViewA {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 do 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 */ }
MemberDescription
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.