JS script types
Each of the four kinds' ctx, wrapper and data types. For registration and the lifecycle see JS getting started; for the shared classes see JS common APIs.
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)
| Member | Description |
|---|---|
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(): String | The 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)
| Member | Description |
|---|---|
getStops() / getThisRouteStops() / getNextRouteStops() | The Stop lists for all / this route / the next route |
getNextStopIndex(stopList, overrunTolerance=0.5): int | The index of the next stop in the list; the overrun tolerance is in metres; returns stopList.size() once all stops have been passed |
isStopsDataFetched(): boolean | Whether 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(): boolean | Whether the local player is riding this train |
getId(): long · getSiding(): Siding? · getTransportMode(): TransportMode | Unique 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(): VehicleExtension | The underlying MTR vehicle object—below the internal API boundary; tread carefully |
Yanyang getDirectionOfDoorOpening(): String | The remembered door-opening side: "not" / "left" / "right" / "all"—see Yanyang extensions |
Yanyang getTransportType(): String | The 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 / method | Description |
|---|---|
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: double | Dwell time (-1 if unknown); rail progress at the stopping point (-1 in SKIP mode) |
isRouteSwitchoverStop: boolean · roundUpRoute: Stop | Whether this is a route switchover stop; when true, roundUpRoute yields a copy of the same stop as seen from the next route |
turnbackPlatform: boolean | Whether 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.
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):
| Mode | Behaviour |
|---|---|
SKIP | The 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' |
ALL | Fetches 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 |
MANDATORY | Same 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)
| Member | Description |
|---|---|
getRenderManager(): RenderManager | Base = the block position + the GUI offset, including the GUI rotation |
getSoundManager(): SoundManager | Base = 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(): EyecandyEvents | Event 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.
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
| Member | Description |
|---|---|
getMtrLift(): Lift · getId(): long | The 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(): int | Door 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(): boolean | Riding; 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
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 member | Description |
|---|---|
type: String | The screen type: rv_pids / rv_pids_sil_1 / rv_pids_sil_2 / lcd_pids / pids_projector / pids_1a |
width / height / rows | The 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(): ArrivalEntries | The 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:
| Common | Description |
|---|---|
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-specific | Description |
|---|---|
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(): int | Measures 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).
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):
| JS | Description |
|---|---|
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
texSizetexture;texAreapicks the region each slot uses (x, y, w, h). posis 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.offsetsduplicates 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
RateLimitand 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.