JS script types

Vehicle scripts

A 'vehicle' in MTR 4 is a single car model entry; players freely marshal them into trains on the siding screen. A script binds to neither a single car nor a whole train, but to a script entry: vehicle entries reference it via scriptId, and multiple vehicles can reuse the same script—mixed consists, performance and NTE compatibility all at once.

The three callbacks (ctx is a VehicleScriptContext, vehicle a VehicleWrapper):

function create(ctx, state, vehicle) { /* Before the first render: build textures, register callbacks */ }
function render(ctx, state, vehicle) { /* Every frame: read state, submit draws and sounds */ }
function dispose(ctx, state, vehicle) { /* On retirement: release textures */ }

VehicleScriptContext (ctx)

MemberDescription
getCarRenderManager(carIndex): RenderManager?The render manager for car number carIndex. Base = the car's centre, 1 m above the floor. Returns null for cars not attached to this entry
getCarBogieRenderManager(carIndex, bogieIndex): RenderManager?Bogie rendering: base relative to bogie number bogieIndex (0 front, 1 rear; 0 for single-bogie cars)
getCarSoundManager(carIndex): SoundManager?The car's sound source (base = the car's centre)
playAnnSound(id, volume, pitch)Plays the in-car announcement—audible only when the player is riding this train; unpositioned
getScriptEntryId(): StringThe script entry id
getMyCars(): int[]The array of car indices attached to this entry
setDebugInfo(key, value) / setDebugInfo(value)Debug overlay display (present on every kind's ctx; a GraphicsTexture is displayed directly as an image)

Deprecated drawCarModel(...) / playCarSound(...) / setDataFetchMode(...) still work, but new code should use the RenderManager / SoundManager / declaration fields above. Both overloads of drawConnModel (car connectors) throw NotImplementedException; drawConnStretchTexture is not exposed in this context. Neither API is usable.

VehicleWrapper (vehicle)

MemberDescription
getStops() / getThisRouteStops() / getNextRouteStops()The Stop lists for all / this route / the next route
getNextStopIndex(stopList, overrunTolerance=0.5): intThe index of the next stop in the list; the overrun tolerance is in metres; returns stopList.size() once all stops have been passed
isStopsDataFetched(): booleanWhether the stop data has been fetched from the server (always false in SKIP mode)
isRendered() / isCarRendered(cars...)Whether the whole train / the given cars are being rendered (not culled)
isClientPlayerRiding(): booleanWhether the local player is riding this train
getId(): long · getSiding(): Siding? · getTransportMode(): TransportModeUnique id / owning siding / transport mode
getVehicleId(carIndex): String? · getLength(carIndex) · getWidth(carIndex) · getCarCount()Resource pack vehicle id / car length and width / car count
getServiceAcceleration() · getServiceDeceleration() · getMaxManualSpeed() · isManualAllowed()The siding's configured acceleration / deceleration and the manual-driving speed cap
getSpeedMs() / getSpeedKmh() · getNotchLevel() / getNotchPosition()Speed; the notch level (-5..5 normal, ±7 = 140%, -8 emergency brake) and its normalised position
getDoorValue(): double · isDoorOpenable(car, pos[, pos2]) · isDoorOpen(car, pos[, pos2])Door opening 0..1; whether the doors at the given in-car relative coordinates are openable / open
getPathData() · getRailProgress(carIndex=0) · getRailIndex(progress, roundDown) · getRailSpeed(pathIndex)Path and progress (MTR 4 synchronises only part of the path)
isCurrentlyManual() · isReversed() · isOnRoute() · getManualToAutomaticTime()Manual driving / reversed / in service / the time limit (milliseconds) before an unattended train hands back to ATO
getDepartureIndex(): long · getTotalDwellTime() / getElapsedDwellTime()The departure index (resets at UTC midnight; -1 = not departed / manual); total and elapsed dwell time (milliseconds, -1 when not at a platform; the elapsed value is passed through exactly as synchronised by the server)
getMtrVehicle(): VehicleExtensionThe underlying MTR vehicle object—below the internal API boundary; tread carefully
Yanyang getDirectionOfDoorOpening(): StringThe remembered door-opening side: "not" / "left" / "right" / "all"—see Yanyang extensions
Yanyang getTransportType(): StringThe canonical transport type id (including the MAGIC refinements)—see Yanyang extensions

Deprecated: the old NTE-legacy interfaces of the same names (getAllPlatforms / trainCars / speed / doorValue / mtrTrain and so on) are all retained, each equivalent to a newer interface in the table above.

Stop (station stops)

Field / methodDescription
route: SimplifiedRoute? · station: Station? · platform: Platform?The owning route / station / platform; null when the data has not been fetched (remember your null checks)
destinationName: String · customDestination: String?The destination display name and the raw custom destination
routeInterchanges: List<RouteInterchange>Interchange routes at this stop (excluding the current route and hidden routes)
connectingInterchanges: Map<String, List<RouteInterchange>>The interchange table grouped by 'connected station name' (same-named stations are merged)
dwellTimeMillis: long · distance: doubleDwell time (-1 if unknown); rail progress at the stopping point (-1 in SKIP mode)
isRouteSwitchoverStop: boolean · roundUpRoute: StopWhether this is a route switchover stop; when true, roundUpRoute yields a copy of the same stop as seen from the next route
turnbackPlatform: booleanWhether this stop is a turnback

RouteInterchange (interchange routes)

color: int (RGB) · name: String; the Yanyang extension fields getTransportMode(): TransportMode?, getTransportType(): String (including the MAGIC refinements) and getRouteId(): long (the source route id, 0 when data is missing)—see Yanyang extensions.

Hiding the built-in destination sign

Vehicles built on the stock models can use hideDisplayParts (MTR 4) / hide_display_cube (MTR 3) to hide the bundled destination display cube and draw their own instead.

Vehicle data fetch modes

MTR 4 only synchronises nearby data, whilst vehicle scripts often want station names for the whole route. JCM's three levels (the dataFetchMode field):

ModeBehaviour
SKIPThe MTR 4 default. Requests nothing from the server and locally assembles a best-effort stop list—completeness not guaranteed. Enough for scripts that only look at speed, like 'ring a bell the moment the train moves'
ALLFetches the whole stop list first, then fetches station / route data one id at a time. Behaves like SKIP until it arrives; even with the stop list in hand, Stop.station / Stop.route may still be null—null-check them
MANDATORYSame as ALL, but the script does not start at all until all the data has arrived. The default for the MTR 3 format (old scripts expect data ready to use and cannot cope with null)

Eyecandy scripts

Attached to MTR eyecandy (Decoration Object) model entries: the script starts working when an eyecandy block using that model is rendered. Register with objects + objectScripts (the MTR 3 legacy format writes to eyecandy.json); isScriptControlled: true on the content entry hides the original model and hands the whole picture over to the script.

The three callbacks: ctx is an EyeCandyScriptContext, eyecandy an EyecandyWrapper.

EyeCandyScriptContext (ctx)

MemberDescription
getRenderManager(): RenderManagerBase = the block position + the GUI offset, including the GUI rotation
getSoundManager(): SoundManagerBase = the block position; the convenience ctx.playSound(id, volume, pitch) also plays right at the block
drawModel(model, matrices?)Draws a model (also accepts a DynamicModelHolder)—equivalent to getRenderManager().drawModel(...)
events(): EyecandyEventsEvent access: right-click → events().onBlockUse.occurred() → handle it → confirm with events().handled(); the event detail .detail().player() gives the acting PlayerEntity (right-clicks with the brush in hand don't count)
setOutlineShape(shape) / setCollisionShape(shape)Custom outline / collision box (VoxelShape); ineffective while wielding the brush
setDebugInfo(...)Same as the other kinds

EyecandyWrapper (eyecandy)

getModelId(): String (the current model entry id) · getTranslateX/Y/Z(): float (the GUI offset, metres) · getRotateX/Y/Z(): float (the GUI rotation, radians) · pos(): Vector3f (block + offset) · blockPos(): Vector3f · facing(): Direction · facingAngle(): double (the facing angle) · isCrosshairTarget(): boolean (the crosshair is on it—handy for popping up a hint) · getFullBrightness(): boolean · redstoneLevel(): int (0–15).

Deprecated: the direct field-reading forms (eyecandy.translateX, eyecandy.prefabId and so on) still work.

Example: looped music that dims on right-click

Mount looped music with TickableSoundInstance.create(...) + setRepeatable(true) + play(); within 5 seconds of a right-click setSoundVolume(0.25), restore 1.0 when the time is up, and stop in dispose—full code in the upstream sounds documentation.

Lift scripts

Attached to a lift car style (texture) entry; it takes effect once the player selects that style with the lift refresher. Register with lifts + liftScripts; isScriptControlled: true on the style entry hides the original model.

The three callbacks: ctx is a LiftScriptContext, lift a LiftWrapper.

LiftScriptContext (ctx)

getRenderManager(): RenderManager (base = the lift's base position + offset, rotating with the lift) · getSoundManager(): SoundManager (same base) · setDebugInfo(...).

LiftWrapper (lift) and Floor

MemberDescription
getMtrLift(): Lift · getId(): longThe underlying MTR object and the unique id
getWidth() / getHeight() / getDepth()The car's dimensions (metres, configured via the refresher)
getOffset(): Vector3f · getPos() / getRawPos()The offset; base position + offset / base position
getAngleDegrees() / getAngleRadians() · isDoubleSided()The facing angle and whether it has doors on both sides
getDoorValue(): float · getDirection(): intDoor opening 0..1; travel direction: -1 down / 0 stationary / 1 up
isDoorway1Openable() / isDoorway1Open() / isDoorway2...Whether the front / rear doors are openable and open
isClientPlayerRiding(): boolean · shouldRender(): booleanRiding; not occlusion-culled
getFloors(): List<Floor>The served floors; each has getIndex() (0 = the bottom) · getNumber() / getDescription() (configured on the floor rail via the brush) · getPos() · isCurrentFloor() / isTargetFloor()

PIDS scripts

A separate system

This system is mutually incompatible with PIDS Layout Editor / PIDS Modularization / PIDS JSON (EpicPuppy) and the information-screen customisation of the early MTR 4 test builds; the formats cannot be imported into one another—a higher barrier to entry, but it can do far more sophisticated layouts.

In the pids_images array of joban_custom_resources.json, a preset object carrying scriptFiles or scriptTexts becomes a script preset (scriptInput / scriptRateLimit mean the same as for the other kinds; Yanyang also accepts scriptLocations to mix in C++—see Mixed entries):

{
    "pids_images": [
        {
            "id": "pids_demo",
            "name": "Demo Scripted PIDS",
            "scriptFiles": ["jsblock:scripts/pids_demo.js"],
            "scriptInput": { "theme": "dark" },
            "scriptRateLimit": 0.75
        }
    ]
}

The preset field outOfBound: boolean (upstream v2.3.0-prerelease.3) keeps the preset rendering when the screen leaves the camera's bounds — needed for projector presets (pids_projector) that must keep running constantly.

What you draw on a PIDS are polygons in the world, not a 2D canvas—elements may spill past the screen's edges, and clipping to the view is the script's own responsibility (when you genuinely need complex clipping / image processing, paint a GraphicsTexture first and mount it). Draw order is layering: later draws cover earlier ones; by default each draw advances a small step along z (0.0002) to prevent z-fighting, which can be switched off with ctx.setAutoZOrdering(false) in favour of manual zOrder() (with ctx.setZOrderStep(m) adjusting the step).

PIDSScriptContext (ctx) and PIDSBlockEntity (pids)

ctx.getRenderManager() / ctx.getSoundManager() (base = the block position) · ctx.setAutoZOrdering(b) · ctx.setZOrderStep(m) · ctx.setDebugInfo(...).

pids memberDescription
type: StringThe screen type: rv_pids / rv_pids_sil_1 / rv_pids_sil_2 / lcd_pids / pids_projector / pids_1a
width / height / rowsThe usable screen width / height and the number of arrival rows
isRowHidden(i) / getCustomMessage(i)Whether row i is hidden by the block's configuration; the row's custom message (an empty string when unset)
getTargetPlatformIds() · isPlatformAutoDetected() · isPlatformNumberHidden()The detected platform id list; whether auto-detection is on; whether the platform number is hidden
blockPos() · isKeyBlock() · station(): Station?The block position; whether this is the 'key' block of a double-sided screen; the station (null when not inside one or unknown to the client)
arrivals(): ArrivalEntriesThe arrival data—see below

ArrivalEntries and ArrivalEntry

ArrivalEntries: get(i): ArrivalEntry? (at most 10 arrivals per platform) · length(): int (the total arrivals count, upstream v2.3.0-prerelease.3) · mixedCarLength(): boolean · platforms(): List<Platform> · forEach(consumer) / forEach(platformId, consumer) (iterates entry by entry, sparing you the manual null checks).

ArrivalEntry: destination(): String · arrivalTime() / departureTime() (Unix milliseconds; new Date(v) converts to a JS date) · arrived() / departed() · deviation(): long (relative to schedule: positive late / negative early) · realtime(): boolean (scheduled or real-time extrapolated) · departureIndex(): long · terminating(): boolean (terminates here) · route(): SimplifiedRoute? / routeId() / routeName() / routeNumber() / routeColor() / circularState() · platform(): Platform? / platformId() / platformName() · carCount() / cars(): List<CarDetails>.

Text / Texture / Rect (the drawing trio)

All three are fluent objects of the 'create → chained configuration → draw(ctx)' kind; Rect is a Texture with its texture hardcoded to a plain white PNG:

CommonDescription
create() / create(comment)Creates a new one (the comment exists only to make the code readable)
pos(x, y) · size(w, h)Position and size (in screen pixel units)
color(rgb)Tint (an RGB integer)
naturalLight() · renderType(str) · matrices(m) · zOrder(n) · draw(ctx)Takes its brightness from the world lighting (fully lit by default); render type (same as QuadDrawCall); extra transform; manual z order (effective once automatic z is off); submits the draw
Text-specificDescription
text(str) · scale(i) · fontMC() / font(id)The content; uniform scale (1 by default—convert it yourself when combining with size); the native font / a resource font (default mtr:mtr; ineffective when 'custom font' is switched off in the MTR settings)
leftAlign() / centerAlign() / rightAlign()Alignment (left by default)
shadowed() · italic() · bold() · lineHeight(factor)Shadow / italic / bold; the line-height factor (used by wrapping; the default 1 = 9 units)
stretchXY() / scaleXY() / wrapText() / marquee() / marquee(durationTick) / withMarqueeProgress(p)Pick exactly one overflow strategy: stretch along the overflow axis / scale proportionally / wrap / marquee (with a period, or drive the 0..1 progress directly)
measureWidth(): intMeasures the width under the current configuration—returns an int, so this is where the chain ends

Texture-specific: texture(id) (a PNG or mcmeta), uv(u1, v1, u2, v2) (0–1).

Dynamic textures on PIDS

A PIDS can mount a GraphicsTexture too: new GraphicsTexture(pids.width, pids.height) in create, paint and upload() in render, then Texture.create().texture(state.tex.identifier).size(pids.width, pids.height).draw(ctx), and close() in dispose—the fallback for when text layout lets you down, at the cost of performance and memory.

Don't need a script? A pids_images entry with only the JSON fields background / color / fonts / showWeather / showClock / hidePlatform / hideRow / topPadding / textOverflowMode and so on is a 'JSON preset'—enough for a simple facelift, and mutually exclusive with a script preset (the two cannot be combined in one entry).

PSD scripts (platform screen doors)

Scripts for platform screen door glass and top panels. Declare them under the psdScripts category of mtr_custom_resources.json — the same entry shape as vehicleScripts (scriptLocations freely mixes JS and C++ with per-entry language detection) plus a type field selecting the binding surface:

"psdScripts": [
    {
        "id": "psd_glass_pids",
        "type": "glass",
        "scriptLocations": [
            "mtr:my_pack/js/psd_glass_pids/main.js",
            "mtr:my_pack/native/psd_glass_pids"
        ],
        "dataFetchMode": "ALL"
    }
]

In game, use the new PSD Script Editor item (in the same creative tab as MTR's brush): right-click any PSD glass (including glass ends and half-height APG gates — MTR, Tianjin Metro and every addon building on MTR's PSD classes) or psd_top block and pick either an entry of the matching type or any scripted PIDS preset (PIDS scripts run unchanged; the canvas adapts to the PSD surface). "No script" removes the binding. Bindings live on the server and sync to every player in the dimension.

  • Glass gets an opaque backing — black on the platform side (a real-PIDS dark display), plain white on the track side (passengers inside a train see a plain white panel, never mirrored content). The script canvas sits on the platform side, inside the pane, and is clipped to the pane's transparent window (derived from the resource pack's model and texture alpha, so frames and bars stay visible in front and occlude the content); the whole glass run between two doors shares one canvas, and a full-height two-block column merges vertically into one. The canvas adapts to the window (96 px per block).
  • Top panels: the whole psd_top stretch over one door run shares one canvas — that stretch stops showing the built-in arrow / route strip and its content is fully script-controlled; other stretches keep the vanilla content. Canvas is 96 px per block wide (minus a 0.125/16 side padding each) × 42 px high.

A continuous glass or top-panel section of at most 64 blocks retains one complete canvas, even across coordinate grid boundaries. Longer sections are divided into aligned 64-block intervals along the signed world coordinate in the canvas-right direction; the first and last intervals may be shorter. Binding, unbinding and hiding the original top-panel content all use the same subsection.

Drawing is identical to PIDS scripts (Text / Texture / Rect + ctx.draw; the canvas origin is the top-left of the visible face). The data API (JS psd argument / C++ mtr::Psd; full accessors in Data snapshots · Psd):

JSDescription
psd.type() / psd.width() / psd.height()Binding surface ("glass" / "top") and canvas size
psd.platform()Platform: number / name (number() / name()), station, dwell time
psd.routes()Every route calling at the platform; with dataFetchMode: "ALL" each carries its full stop list (interchanges / exits)
psd.arrivals()Upcoming trains: destination, route, car count, countdown (arrivalCountdown()), the door numbers that will open (doorNumbers()), realtime / terminating / circular state
psd.stop()Complete station info (ABI 13): name, colour (ARGB + hex), fare zones 1–3, every route calling at the station, connecting-station routes (grouped by station name), physically connected station names, exits with destinations, platform numbers + count — null when there is no platform / station. C++ counterpart: has_station() + the mtr::PsdStation accessors (see Data snapshots · Psd)
psd.doors() / psd.doorValue() / psd.isDoorOpen()Door unit list (1-based, live open / locked states) and the wall-wide open amount

On the C++ side this is the fifth script base class mtr::PsdScript<State> (MTR_REGISTER_PSD_SCRIPT) with the JcmPsdSnapshot snapshot (ABI 12; station entries since ABI 13); see native/examples/psd_glass.cpp.

DisplayHelper (the built-in multi-screen helper)

A ready-made solution for multi-screen vehicles: after include(Resources.id("mtrsteamloco:scripts/display_helper.js"))—

let slotCfg = {
    "version": 1,
    "texSize": [2048, 1024],
    "slots": [
        { "name": "lcd_north", "texArea": [0, 0, 2048, 274],
          "pos": [[[-0.59, 2.125, -1.75], [-0.755, 2.03, -1.75],
                   [-0.755, 2.03, -3.25], [-0.59, 2.125, -3.25]]],
          "offsets": [[0, 0, -5], [0, 0, 0], [0, 0, 5], [0, 0, 10]] }
    ]
};
let dhBase = new DisplayHelper(slotCfg);

function create(ctx, state, vehicle) {
    state.rl = new RateLimit(0.05);
    state.dh = dhBase.create();          // Instantiate and bind the texture
}
function render(ctx, state, vehicle) {
    if (state.rl.shouldUpdate()) {
        let g = state.dh.graphicsFor("lcd_north");  // The slot's canvas (auto-offset)
        // ... AWT drawing ...
        state.dh.upload();
    }
    for (let car of ctx.getMyCars())
        ctx.getCarRenderManager(car).drawModel(state.dh.model, null);  // Must be drawn every frame
}
function dispose(ctx, state, vehicle) {
    state.dh.close();
}
  • All slots share one large texSize texture; texArea picks the region each slot uses (x, y, w, h).
  • pos is a three-dimensional array: the four corner coordinates of each screen (top-left → bottom-left → bottom-right → top-right), in metres (divide Blockbench's 1/16 units by 16); the origin sits at the train's centre at floor height, with +X left / +Y up / +Z rear.
  • offsets duplicates screens showing the same content (a texture saver for runs of small screens above doors); graphicsFor(name) hands you the auto-offset canvas for a slot, graphics() the whole thing.
  • Put the upload inside the RateLimit and the model drawing outside it—the model must be submitted every frame.

Next: Yanyang extensions — mixed JS+C++ entries, door-opening side memory, fine-grained transport types and direct-write pixel upload.