Skip to content

Compatibility VS Legacy

Brennan Hatton edited this page May 2, 2026 · 1 revision

Compatibility (VS legacy — pre-v0.83.0)

Archived. This page captures the Valkyrien Skies notes from the Forge 1.20.1 era. Phase 2 of the 1.21.1 migration replaced VS with Sable because VS upstream has not shipped a 1.21.1 build. Nothing on this page applies to current builds; see Compatibility for the live page.

The content below is preserved verbatim from when VS was the live physics dependency.


Notes on interactions with Valkyrien Skies 2 and any quirks we hit during development. This page was a running log — every entry below applies to Forge 1.20.1 + VS 2.4.11 builds.

Valkyrien Skies 2.4.11

All entries below apply to valkyrienskies-120-forge:2.4.11 with org.valkyrienskies.core:api:1.1.0+cf208d8b56.

Attachment registration is mandatory

Symptom: IllegalArgumentException: setAttachment: attempted to set an attachment with unregistered class <YourClass>. Please register the attachment class. — server tick loop crashes when trying to attach anything implementing ShipPhysicsListener / ShipForcesInducer.

Cause: VS 2.4+ requires every attachment class to be registered via VsCoreApi.registerAttachment(...) before LoadedServerShip.setAttachment is called. The attachment registry is new in the 2.4 migration (see the VS 2.4 migration guide).

Fix: register during FMLCommonSetupEvent.enqueueWork(...):

VsCoreApi core = ValkyrienSkiesMod.getVsCore();
AttachmentRegistration<TrainForcesInducer> reg = core
    .newAttachmentRegistrationBuilder(TrainForcesInducer.class)
    .useTransientSerializer()   // or useJacksonSerializer() / useLegacySerializer()
    .build();
core.registerAttachment(reg);

Transient attachments are dropped on world reload; Jackson/Legacy serializers persist them. MVP uses transient.

ShipAssembler.assembleToShip returns ShipData, not LoadedServerShip

Symptom: (LoadedServerShip) ship throws ClassCastException right after assembly, and ship instanceof LoadedServerShip returns false.

Cause: ShipAssembler.assembleToShip(level, blocks, scale) returns the cold-storage form (org.valkyrienskies.core.impl.game.ships.ShipData). The live LoadedServerShip only becomes available after VS finishes the async chunk load at the ship's shipyard coordinates — typically on the next server tick, but potentially later.

Fix: defer the attachment. Queue the attach by ship id and resolve it from a TickEvent.ServerTickEvent handler that retries each tick until the ship shows up in getLoadedShips():

LoadedServerShip loaded = VSGameUtilsKt.getShipObjectWorld(level)
    .getLoadedShips()
    .getById(shipId);
if (loaded != null) {
    loaded.setAttachment(MyAttachment.class, new MyAttachment(...));
}

applyWorldForce(force, pos) from Java applies force at world origin by default

Symptom: Ship spins violently and flies away after physics starts driving it.

Cause: In Kotlin, PhysShip.applyWorldForce(force) is 1-arg because pos has a default of null meaning "center of mass." From Java, only the 2-arg form applyWorldForce(force, pos) is visible — passing new Vector3d() for pos is NOT "zero offset from COM"; it's literally world origin (0, 0, 0). With the ship typically tens of thousands of blocks away at its shipyard coordinates, the lever arm is enormous and every tick injects massive torque.

Fix: use one of:

  • applyInvariantForce(force) — 1-arg, applies at COM. Marked @Deprecated in VS 2.4 but still works and is the simplest option.
  • applyWorldForceToBodyPos(force, new Vector3d()) — explicit "at body origin (COM in body frame)."

Same pitfall applies to applyWorldForceToModelPos, applyBodyForce, etc. when given a zero Vector3d.

ServerShip.saveAttachment(...) is not callable from Java

Symptom: error: cannot find symbol method saveAttachment(Class<X>, X) even though javap -p shows the method exists on ServerShip.

Cause: The method is marked ACC_SYNTHETIC and @Deprecated with the message "Since VS 2.5, attachments can only be saved to a LoadedServerShip." Kotlin generated it as a synthetic bridge, so the Java compiler refuses to bind to it.

Fix: cast to LoadedServerShip (after timing the attach correctly per the previous entry) and call setAttachment instead.

Force-based drive silently gates above mass ~3–6M kg

Symptom: A VS ship built from ShipAssembler.assembleToShip with ~1600+ blocks (e.g. a 20-carriage Dungeon Train, mass 6.25 M kg) sits at vel=(0, 0, 0) on every physics tick despite forces being applied from ShipPhysicsListener.physTick via applyInvariantForce. Smaller ships of the same shape (1, 2, 5, 10 carriages — mass up to ~3.1 M kg) accelerate to target velocity within a second.

Rules out:

  • PhysShip.isStatic() is false.
  • PhysShip.isSleeping() is false.
  • PhysShipImpl.canApplyWrenches(...) returns true (forces do get queued, not rejected at the API boundary).
  • The ship is not terrain-wedged — the behaviour persists with a fully-cleared bounding volume.

Cause: The gating happens inside the Bullet integration that VS layers on top of applyQueuedForces. Exact threshold unknown; observed: works at 3.125 M kg, fails at 6.25 M kg.

Fix: Don't drive heavy ships with forces. Use ServerShip.setTransformProvider(ServerShipTransformProvider) and prescribe the next transform directly. The provider returns a NextTransformAndVelocityData(nextTransform, nextVel, nextOmega) each physics tick; VS uses the returned nextVel to carry ride-along entities correctly, so passengers still work.

ServerShip ship = ShipAssembler.assembleToShip(level, blocks, 1.0);
ship.setTransformProvider(new MyTransformProvider(velocity));

Trade-off: kinematic ships don't respond to external impulses (TNT pushes, rammed by a mob). For a "train on rails" this is desired; for free-moving vehicles it is not.

Also useful: ShipAssembler.INSTANCE.deleteShip(level, ship, true, false) removes an assembled ship (mirrors what the in-game Ship Remover item does).

Kinematic ServerShipTransformProvider still drifts if you only prescribe position

Symptom: A train driven by a ServerShipTransformProvider that prescribes only the next position (current.toBuilder().position(nextPos).build()) visibly tips and falls when its blocks are not supported by terrain — e.g. spawned mid-air or crossing a pit. Angular velocity is set to zero on every returned NextTransformAndVelocityData, yet the ship still rotates over time.

Cause: Two compounding leaks when the provider only overrides position on the builder:

  1. current.getRotation() is passed through the builder unchanged. Any rotation VS's physics introduced between ticks (from collision impulses against uneven block support, or from gravity acting on mass elements of the compound body) is carried forward and compounds each frame — zero angular velocity on the return value does not retroactively undo a rotation that was already applied.
  2. nextPos = current.getPosition() + targetVelocity·dt re-reads VS's current position on every call, so any tiny Y-drift from gravity between ticks is baked into the next target and the train sinks.

The kinematic provider is only authoritative for the fields you explicitly set on the builder. Everything else is whatever VS's physics left behind.

Fix: prescribe the full transform, and track the canonical position internally instead of reading it from VS each tick. Lazily capture the spawn-time rotation and position on the first call:

public final class TrainTransformProvider implements ServerShipTransformProvider {
    private static final double PHYSICS_DT = 1.0 / 60.0;
    private static final Vector3dc ZERO_OMEGA = new Vector3d();

    private final Vector3d targetVelocity;
    private Quaterniondc lockedRotation;   // captured on first tick
    private Vector3d canonicalPos;         // advanced by velocity·dt each tick

    @Override
    public NextTransformAndVelocityData provideNextTransformAndVelocity(
        ShipTransform prev, ShipTransform current
    ) {
        if (lockedRotation == null) {
            lockedRotation = new Quaterniond(current.getRotation());
            canonicalPos = new Vector3d(current.getPosition());
        }
        canonicalPos.add(
            targetVelocity.x() * PHYSICS_DT,
            targetVelocity.y() * PHYSICS_DT,
            targetVelocity.z() * PHYSICS_DT
        );
        BodyTransform next = current.toBuilder()
            .position(canonicalPos)
            .rotation(lockedRotation)
            .build();
        return new NextTransformAndVelocityData(next, targetVelocity, ZERO_OMEGA);
    }
}

BodyTransform.Builder.rotation(Quaterniondc) is the correct API (confirmed present in VS 2.4.11). Entity ride-along continues to work because we still return targetVelocity as nextVel.

Update (v0.9.0): setStatic(true) is not an alternative to locking rotation in the provider — it is a complement to it. It can be applied alongside the kinematic ServerShipTransformProvider: VS still calls provideNextTransformAndVelocity on the ship each physics tick, so translation and ride-along keep working, but VS skips the dynamics pipeline (no Bullet integration, no COM/inertia recompute). See the next entry for why we now set it on every spawned train.

Trade-off: the train is now fully gravity-immune and orientation-locked, matching "train on rails" semantics. If you ever want the train to respond to terrain (tilt into a turn, sink on one side if the track is uneven), remove the rotation lock and let VS's physics drive orientation.

Shipyard blocks can be added and removed at runtime

Symptom (non-issue): You want to mutate an assembled ship's block composition without destroying and re-creating the ship — for example, to extend or shrink a train as a player walks along it.

Finding: level.setBlock(shipyardPos, state, 3) at a ship's shipyard coordinates Just Works in VS 2.4.11. Blocks added this way become part of the ship immediately — they render attached, collide with entities riding the ship, and move with the ship's transform on every physics tick. Blocks set to air are removed from the ship the same way. No explicit "refresh AABB" call is required; VS observes the chunk mutations and recomputes what it needs.

How to find the shipyard coordinate of a given world position:

Vector3d shipyardPos = new Vector3d(world.x, world.y, world.z);
ship.getTransform().getWorldToShip().transformPosition(shipyardPos);
// shipyardPos is now in ship-local space (= shipyard coords in the world)

Conversely, getShipToWorld() maps the other way. For a newly assembled ship, you can capture the shipyard origin by transforming the world origin you passed to ShipAssembler.assembleToShip through worldToShip immediately after assembly — VS moves the blocks to a shipyard chunk claim during assembly, so the shipyard coordinates are not the world coordinates you placed them at.

Used by: TrainWindowManager (v0.6.0+) — the rolling-window driver that keeps a constant carriage count around each player by adding carriages ahead and erasing them behind. Adds/erases happen in place on the existing ship; the kinematic TrainTransformProvider and entity carry-along are untouched.

Caveat: If you erase the carriage a player is currently standing inside, they will fall through. The manager guards against this by never erasing a carriage whose ship-local X bracket contains any player on that tick — a stale carriage may hang around one extra tick until the player moves off it, which is the right trade-off.

Rolling-window block churn moves the COM every tick — mark the ship static

Symptom: A kinematically-driven train (ServerShipTransformProvider, rotation and position locked per the entry above) visibly jitters during motion once the rolling-window manager is active. The jitter is not in the prescribed position — it is a sub-block oscillation around it.

Cause: level.setBlock(shipyardPos, ...) at ship coordinates causes VS to recompute the ship's ShipInertiaData — mass, center of mass, and inertia tensor — every time blocks are added or removed. The rolling-window manager places and erases carriage blocks at the ship's ends every time a player crosses a carriage boundary. For a long, asymmetric body like a 10-carriage train, the COM shifts by several blocks between ticks. VS's client-side interpolation between tick snapshots then fights the moving body origin and the result is visible jitter.

This happens even though the transform provider explicitly prescribes rotation and velocity — the drift is not in the provider's output, it's in the inertia/COM data VS uses downstream of the provider for collision resolution and client sync.

Fix: Mark the ship as static right after attaching the transform provider:

ServerShip ship = ShipAssembler.assembleToShip(level, blocks, 1.0);
ship.setTransformProvider(new TrainTransformProvider(velocity, ...));
ship.setStatic(true);

Empirical findings in VS 2.4.11:

  • ServerShip.setStatic(true) does not suppress the ServerShipTransformProvider callback. provideNextTransformAndVelocity is still invoked every physics tick, so the train still translates.
  • Entity ride-along (passengers being carried forward) still works — VS uses the nextVel returned by the provider, not the body's integrated velocity.
  • Rolling-window block add/erase still works — level.setBlock on shipyard coords still mutates the ship's blockstates and re-renders them.
  • VS skips the Bullet dynamics step for the body, which means no COM/inertia recompute cost and no downstream interpolation fight.

Trade-off: the ship now acts as an infinite-mass collider. For a train that tunnels through terrain via TrainTickEvents.clearBlocksAhead and deletes entities in its AABB each tick, this matches the desired "train cannot be stopped" semantics. Free-moving vehicles that should respond to impulses should leave isStatic=false.

Used by: TrainAssembler (v0.9.0+).

Update (v0.10.0): setStatic(true) is necessary but not sufficient — see the next entry. It suppresses the Bullet dynamics step, but VS still re-derives ShipTransform.positionInModel from the voxel COM when blocks change, which still teleports the voxel-to-world mapping. The compensation entry below is needed on top of this one.

ShipTransform.positionInModel silently re-centers on voxel COM — compensate in the provider

Symptom: With a kinematic ServerShipTransformProvider and setStatic(true) both active (per the two entries above), a train with a rolling-window manager that mutates voxels every server tick still exhibits a whole-ship teleport every time a carriage is added or erased. It feels like occasional jumps during normal walking, and becomes continuous if the player stands still in certain spots. Worse, the teleport shifts the player's ship-local X, which changes the window's pIdx, which triggers another mutation — a feedback loop.

Cause: BodyTransform.positionInModel — the ship-space pivot that VS uses to map voxel coordinates to world coordinates via world = position + rot · (S − positionInModel) — is re-derived from the ship's centre of mass every time the voxel set changes. This happens even when setStatic(true) is set, and even when the transform provider explicitly passes builder.positionInModel(lockedPositionInModel) — VS ignores or re-overrides it. The result is that between the tick where voxels change and the tick where the provider writes a new transform, the position stays at its last value but the positionInModel has shifted to a different ship-space point, so every voxel renders at a different world position.

For a ten-carriage train where carriage types alternate STANDARD/WINDOWED/SOLID_ROOF/FLATBED (flatbeds have ~45 blocks vs ~200 for the others), a single window shift swings the COM by ~7 blocks for like-for-like carriage swaps and ~11 blocks when a flatbed crosses the window boundary. That 11-block swing is what the user sees as the "teleport."

Fix: Compensate for the positionInModel drift on the provider's side every physics tick, so the voxel-to-world mapping stays anchored to the originally-captured pivot regardless of what VS does with positionInModel:

private Vector3dc lockedPositionInModel;  // captured on first tick

public BodyTransform computeCompensatedTransform(ShipTransform current) {
    Vector3d comDelta = new Vector3d(current.getPositionInModel()).sub(lockedPositionInModel);
    lockedRotation.transform(comDelta);                          // rotate into world frame
    Vector3d effectivePos = new Vector3d(canonicalPos).add(comDelta);
    return current.toBuilder()
        .position(effectivePos)
        .rotation(lockedRotation)
        .build();
}

The math: with world(S) = position + rot · (S − currentPIM) and our position = canonicalPos + rot · (currentPIM − lockedPIM), the currentPIM terms cancel and world(S) = canonicalPos + rot · (S − lockedPIM) — independent of whatever VS chose for positionInModel this tick.

This alone is not quite enough — two additional fixes are required for the teleports to be fully gone:

  1. Push the compensated transform synchronously with voxel mutations. The provider callback runs on the physics thread (60 Hz), while voxel mutations from the rolling-window manager run on the server thread (20 Hz). There is a gap of up to one server tick where the client can receive the new voxel data against the old transform. After any level.setBlock in the rolling-window manager, call ship.unsafeSetTransform(provider.computeCompensatedTransform(ship.getTransform())) so the block change and the corrective transform leave the server in the same tick. Most visible with flatbeds because of their larger COM swing.

  2. Debounce pIdx reversals in the window manager. Even with the above, a single-tick oscillation in the player's pIdx (caused by the block-change/transform-sync race, or by the player hovering right at a carriage boundary) triggers a "shift forward, then shift back" pattern that costs an add + erase per tick in each direction. Track the last committed shift direction and its tick; suppress reversals that arrive within ~6 ticks of the last shift. Steady directional walking is unaffected.

Used by: TrainTransformProvider and TrainWindowManager (v0.10.0+).

Empirical findings in VS 2.4.11:

  • Setting builder.positionInModel(lockedValue) in the transform the provider returns does not stick — the next tick's current.getPositionInModel() still reflects the new COM.
  • ShipInertiaData.getCenterOfMassInShipSpace() tracks COM explicitly but we don't need it; current.getPositionInModel() is already the effective pivot VS uses and is the right signal to compensate against.
  • The drift accumulates without bound as the rolling window moves through ship-local space. After ~50 window shifts our observed compensation delta was ~430 blocks in ship-local X. Voxel-to-world is still correct because canonicalPos − lockedPIM is stable modulo rotation; only the reported components are large.

Update (v0.14.0): The compensation above keeps voxels world-stable but shifts the ship's rendered origin by rotation·Δ on every mutation. On the client this presents as a visible whole-train hop — the two outcomes (voxel-stable vs origin-smooth) are conjugate and both cannot hold while VS is moving the pivot. The real fix is to stop the pivot moving in the first place; see the next entry.

ShipInertiaDataImpl.onSetBlock moves the COM on every mutation — pin the inertia

Symptom: Even with the positionInModel compensation above active, a rolling-window train still exhibits a visible forwards/backwards hop of the entire ship every time the window adds or erases a carriage. The hop is most visible around flatbed carriages (flatbeds have ~5× less mass than full-walled carriages, so a flatbed entering/leaving the window swings the COM by ~11 blocks versus ~3–5 blocks for like-for-like swaps) but it happens for every carriage type. Direction of the hop tracks the direction the player is walking.

Cause: VS 2.4.11's org.valkyrienskies.core.impl.game.ships.ShipInertiaDataImpl.onSetBlock mutates three private fields on every level.setBlock call at ship coordinates: _centerOfMassInShip, _mass, and _momentOfInertiaTensor. On the next physics tick VS's rigid-body step calls getCenterOfMass() and writes the result into the ship's positionInModel. For a rolling-window driver that adds one carriage and erases another per player-boundary-cross, this produces ~10–11 blocks of positionInModel drift per mutation. The existing rotation-based compensation translates that drift into an equal-and-opposite shift of the ship's world position, which the client renders as a hop. Voxels stay world-stable mathematically, but the ship's anchor point and AABB visibly jump.

setStatic(true) does not suppress this path. The inertia recompute runs regardless — empirically observed by logging positionInModel.x() at full precision (the default Vector3d.toString() uses scientific notation and hid 10-block drifts because the magnitudes sit at ~1×10⁷).

Fix: After every rolling-window mutation, overwrite ShipInertiaDataImpl._centerOfMassInShip, _mass, and _momentOfInertiaTensor with the snapshot taken at ship spawn, before the next physics tick reads them. Because the private fields are final references to mutable Vector3d / Matrix3d instances, reflection only needs to mutate the contained objects in place — no Field.set(..., newInstance) required.

A small reflection helper (ShipInertiaLocker) handles the class lookup once at class init and caches the Field handles. On first window tick for a given ship, call ShipInertiaLocker.capture(ship) to take the snapshot. After every mutation in TrainWindowManager.updateWindow, call ShipInertiaLocker.restore(ship, snapshot) before leaving the server tick. Graceful degradation: if VS renames any of ShipInertiaDataImpl, _centerOfMassInShip, _mass, or _momentOfInertiaTensor, class init logs an ERROR and every capture/restore call is a no-op — the train hops again exactly as it did without the helper, but nothing else breaks.

With the pin in place, positionInModel stays constant and the compensation in the previous entry becomes effectively effectivePos = canonicalPos. Voxels and the ship's rendered origin both stay smooth; no hop.

Related fix — NEAR_RADIUS check must use the ship's AABB, not ship.getTransform().getPosition(): The original window-manager distance guard compared the player's world position to ship.getTransform().getPosition(), which sits at one end of a 180-block-long carriage set. Before the pin this was masked because the compensation yanked the ship's rendered origin forward by ~11 blocks on every mutation, effectively tracking the player — players were never filtered out. With the pin, the origin only advances at train velocity (~2 m/s) while a walking player advances at train + walk (~6 m/s), so a player walking toward the front of a long train can exceed the 128-block NEAR_RADIUS while still standing on a carriage. The window manager then silently stops adding new carriages because no player passes the near-filter. Switch the guard to "distance from player to the nearest face of ship.getWorldAABB()": zero if inside, otherwise the shortest face-normal distance. The aabbPreMutate snapshot already captured for the jitter probe can be reused.

double cdx = Math.max(0, Math.max(aabb.minX() - px, px - aabb.maxX()));
double cdy = Math.max(0, Math.max(aabb.minY() - py, py - aabb.maxY()));
double cdz = Math.max(0, Math.max(aabb.minZ() - pz, pz - aabb.maxZ()));
if (cdx * cdx + cdy * cdy + cdz * cdz > NEAR_RADIUS_SQ) continue;

Debugging methodology (how we actually found this):

The fix above is one line of reflection; the hard part was knowing where to aim. A six-stage diagnostic workflow built up as a branch-local pattern:

  1. Hypotheses first. Before writing any instrumentation, enumerate the plausible failure modes (server-thread read of a stale pivot; cross-thread race on canonicalPos; drifting worldToShip matrix used for pIdx; entity-carry timing under the player). Each becomes a specific signal to look for.
  2. One log namespace. Every probe goes under games.brennan.dungeontrain.jitter and is raised to DEBUG programmatically via Configurator.setLevel(...) in the mod constructor so Forge's default INFO root doesn't swallow them.
  3. Full-precision formatting matters. VS ships sit at shipyard coordinates around (−2.867×10⁷, 128, 1.229×10⁷). Vector3d.toString() prints (-2.867e7, 1.278e2, 1.229e7) — a 10-block drift looks identical to a 0-block drift. Use String.format("(%.6f, %.6f, %.6f)", v.x(), v.y(), v.z()) or the bug will hide inside your own log lines.
  4. Separate server-side and client-side probes. A server-internal oscillation may or may not reach the client — never assume. Log the server's "next transform to send" on the physics thread and a client-tick event that reads ClientShip.getRenderTransform() with the same tick number so grep [physics] and grep [client] let you diff the two views.
  5. Every-change probes, not every-N-ticks. A rate-limited [physics] sampler misses the moment the pivot moves. Track lastPivotXYZ as a field and emit [pivotMoved] whenever the delta exceeds machine epsilon. Pair each with msSinceLastMutation (timestamp via System.nanoTime() recorded on the server thread, read on the physics thread) so you can tell "mutation-driven" (small ms) from "spontaneous VS recalc" (large ms).
  6. A WARN tripwire as a regression alarm. The probes themselves are chatty; a loud single-line WARN that fires only when a physics-tick position delta exceeds |velocity|·dt·2, with a 10-tick cooldown, is a cheap forever-on regression detector. Once the fix landed and tripwires went silent, the probes were demoted from DEBUG to TRACE but the WARN stayed — any future VS update that reintroduces the hop will fire it immediately.

The decisive datum, once the full-precision [pivotMoved] probe was in place, was seeing rawComDeltaX jump by -11.156502 at physics tick 92 — exactly 7ms after the server-thread [windowManager] line showed pivotPre == pivotPost (VS had not re-centered yet on the server tick). That 7ms gap told us VS does its COM recompute on its own clock, not synchronously with level.setBlock. From there it was a straight line: find what changed the COM (ShipInertiaDataImpl.onSetBlock), pin the fields it mutates, done.

Used by: ShipInertiaLocker, TrainWindowManager, TrainTransformProvider (v0.14.0+, PR #18).

Empirical findings in VS 2.4.11:

  • ShipInertiaDataImpl is at org.valkyrienskies.core.impl.game.ships.ShipInertiaDataImpl — impl package, not API.
  • The three mutable fields are named _centerOfMassInShip (Vector3d), _mass (double), _momentOfInertiaTensor (Matrix3d). All private, non-final references but mutable contents.
  • LoadedServerShip.getInertiaData() returns the live ShipInertiaDataImpl instance. Accessible by reflection without needing to compile against the impl class.
  • Setting builder.positionInModel(lockedValue) on the returned BodyTransform causes VS to oscillate: honored on some physics ticks, re-derived on others, giving a ~11-block hop every 1–2 ticks — strictly worse than the pre-fix behaviour. Do not try to override at the builder level; pin the inertia instead.

Each ship's shipyard allocation is fixed at ~128 chunks (2048 blocks)

Symptom: A rolling-window Dungeon Train freezes the player at exactly carriage pIdx ≈ ±228 (≈ ±2052 blocks from the spawn-time shipyard origin). The player can break blocks and look around, but can't move or throw entities. The server slowly (~1 block per 32 ticks) drags the player backwards at train velocity while the ship keeps translating forward.

Cause (confirmed empirically): Each VS 2.4.11 ship gets a fixed shipyard-chunk allocation at creation time — 128 chunks (= 2048 blocks) in each direction from the ship's spawn-time shipyard origin. Beyond that bound the ship's voxel data cannot be loaded, rendered, or collided with. setBlock at a shipyard position past the limit silently succeeds (the chunk is created), but VS refuses to register the block with the ship, so the server-side collision trace sees the player standing on air and rejects every forward-movement packet.

Rules out:

  • "Ship's world-position distance from player" — confirmed by ShipyardShifter (v0.39.0): advancing canonicalPos forward 1355 blocks in world reduced the player↔ship world distance to 691 blocks (well under 2048) and the freeze still triggered at the same shipyard-X.
  • "Ship's pivot distance" — same ShipyardShifter iteration also advanced lockedPositionInModel by 1355 blocks in shipyard space; freeze still triggered at the same shipyard-X from the original shipyardOrigin.
  • "Our code is slow" — CarriageFootprint (v0.38.0) replaced ship.getWorldAABB() with a bounded active-carriage AABB; server lag dropped from 728 ticks behind to ≤81, but the freeze boundary was unchanged.

Fix: Don't try to stretch one ship past the limit. Spawn a second ship (a successor) just ahead of the current one with its own fresh 128-chunk allocation; cap the predecessor's rolling-window forward extent at TRIGGER_PIDX + halfFront so it stops painting past the seam; set the successor's backward extent to 0 so it doesn't overlap. The player walks from A onto B seamlessly — both ships run in parallel, move at the same velocity, and the seam stays one carriage wide indefinitely. See Feature-Train-Chain for the user-facing description and TrainChainManager.java for the trigger logic.

Diagnostic methodology: adding a [stuck.pIdx] log gated to |pIdx| ≥ 200 and printing the full-precision player world-x, ship world-x, shipyard world-x (player), and difference between them — plus the reference vectors before and after the ShipyardShifter kick — was decisive. The shift trivially moves canonicalPos.x and lockedPositionInModel.x by the requested amount, visible in the log, yet the freeze trigger x (both in world and in shipyard) stayed at the same value. That narrowed the limit to something referenced against the ORIGINAL shipyardOrigin at ship creation time — which matches VS's known pattern of allocating shipyard chunks on ShipAssembler.assembleToShip.

Used by / affects: TrainChainManager, TrainAssembler.spawnSuccessor, CarriageFootprint, CarriagePersistenceStore. v0.40.0+ (PR #53).

Round-to-nearest, not floor, when computing shipyard origin

Symptom: Every initial carriage of a Dungeon Train retained a 1-block-wide vertical wall of leftover blocks along its +Z face after the rolling-window manager erased it. The wall travelled with the train (ship-attached, not world-attached) and only appeared on carriages placed at initial assembly — rolling-window-added carriages looked clean on that axis. /dungeontrain debug scan (v0.13.0+) reported strays at offset [+dx, +dy, +5] for every initial carriage, i.e. 1 block past shipyardOrigin.z + WIDTH.

Cause: TrainAssembler computed shipyardOrigin via BlockPos.containing(shipyardOriginVec) which uses Mth.floor on each axis. VS's ShipAssembler.assembleToShip internally stores each block at the integer shipyard position closest to its transformed coordinate — round-to-nearest, not floor. When the world→shipyard translation landed a block's fractional shipyard coord just below an integer (observed: z = 12290044.9999978, fractional −3·10⁻⁵), floor gave 12290044 while VS stored the block at 12290045. The rolling-window eraseAt loop then swept [shipyardOrigin.z, shipyardOrigin.z + WIDTH) and missed the +Z face of every initial carriage.

The mismatch only becomes visible when the transform's fractional component on an axis is very close to (but not past) 0.5 — floor and round-half-up disagree by 1 there. For Dungeon Train this happened on Z reliably because the ship's Y and Z velocity are zero, so the initial fractional values VS picks during assembly persist for the ship's lifetime.

Fix: Use Math.round (matching VS's convention) on all three axes when computing a ship-local integer block position that will later be used for level.setBlock or eraseAt:

Vector3d shipyardOriginVec = new Vector3d(worldOrigin.getX(), worldOrigin.getY(), worldOrigin.getZ());
ship.getTransform().getWorldToShip().transformPosition(shipyardOriginVec);
BlockPos shipyardOrigin = new BlockPos(
    (int) Math.round(shipyardOriginVec.x),
    (int) Math.round(shipyardOriginVec.y),
    (int) Math.round(shipyardOriginVec.z));

Debug tool: /dungeontrain debug scan (see Features § /dungeontrain debug scan) enumerates non-air blocks sitting outside a carriage's expected footprint — the fastest way to spot residual 1-block offsets after any future shipyard-coord math change.

Used by: TrainAssembler (v0.13.0+).

ShipForcesInducer is an empty marker interface in VS 2.4

Symptom: You implement org.valkyrienskies.core.api.ships.ShipForcesInducer, override applyForces(PhysShip), and the override is never called — but no error either.

Cause: In VS 2.4 the public ShipForcesInducer interface was demoted to an empty marker (no methods). The actual per-tick callback now lives on ShipPhysicsListener.physTick(PhysShip, PhysLevel). applyForces only works on older VS versions (or on the internal org.valkyrienskies.core.impl.api.ShipForcesInducer, which may not be present in this version).

Fix: implement org.valkyrienskies.core.api.ships.ShipPhysicsListener instead and override physTick.

Deferring chunk modification off the ChunkEvent.Load tick

Symptom: A Forge mod that listens for ChunkEvent.Load and writes blocks synchronously (level.setBlock) into newly-loaded chunks near a freshly-spawned VS ship wedges the server thread for 10–20 seconds. Observed: ChunkEvent.Load painting tracks: chunk=[-11, 0] followed by 17 seconds of no server-thread log output, then Too many physics frames in the physics frame queue warnings from VS. Player client shows Dev moved while colliding with unloaded ships! snap-backs — every movement packet rejected, movement effectively locked.

Cause: The server thread's chunk-load path is already busy registering the chunk in ChunkMap, firing SeamlessChunksManager packet deferral, and running the train's own TrainTickEvents.clearBlocksAhead pass on the same tick. Adding a few hundred extra setBlock calls pushes the tick so far over budget that VS's physics frame queue backs up; VS's chunk-watch tasks can't drain; the ship never fully registers on the client; every client movement collides with the "unloaded" ship's AABB.

Fix: ChunkEvent.Load listeners that need to write blocks must enqueue the work (chunk-pos long) and drain it on a later tick at a bounded budget. Three structural requirements:

  1. FIFO ordering, not hash order. A HashSet-backed queue iterates in hash order, producing a visually patchwork bed — random chunks painted in random order, with huge gaps between them. Use ConcurrentLinkedDeque so poll() returns chunks in insertion order (≈ spatial proximity as chunks stream outward from players).
  2. Off-phase from clearBlocksAhead. The train's forward-clear pass also runs on the server tick. Schedule the drain at a different phase offset (e.g. tickCounter % 10 == 5 when clearBlocksAhead runs at % 10 == 0) so the two heavy block-write ops never collide.
  3. Bootstrap for chunks loaded before the ship existed. ChunkEvent.Load fires once per chunk per load cycle; chunks loaded during login (before PlayerJoinEvents spawns the train) already fired Load with no train present and will never re-fire. A one-time bootstrapPendingChunks(level, ship, provider) at train-spawn time walks level.getChunkSource().hasChunk(cx, cz) over the target corridor and enqueues every currently-loaded chunk. Without it, world-spawn chunks remain permanent gaps — the view-distance scan fallback gets starved when ChunkEvent.Load traffic keeps pending non-empty.

Block-update flag matters too. Use Block.UPDATE_CLIENTS (flag 2) alone — adding UPDATE_NEIGHBORS causes rails to cascade through RailBlock.neighborChanged → RailState.create → O(N²) shape rewrites across the bed, stalling the tick on its own.

Used by: TrackChunkEvents (enqueue only), TrackGenerator.fillRenderDistance (rate-limited drain + bootstrap), TrainTickEvents (drain caller, off-phase from clearBlocksAhead). v0.20.0+, PR #26.

VS stores ship voxels at |x|, |z| > ~10M in the same dimension

Symptom: A ChunkEvent.Load handler that doesn't coordinate-filter sees chunk positions like [-1791873, 768129] fire alongside normal world chunks like [-11, 0]. Passing these to a corridor-intersection check spends CPU computing Z-range overlaps with world Z ranges on the order of 12,290,000, which always miss by 12M — correct but wasteful, and any buggy filter would cause visible ship-voxel corruption.

Cause: VS stores every ship's voxel data in the same ServerLevel as the world it belongs to, but at very large shipyard-space coordinates (|x|, |z| on the order of 10⁷). Those chunks fire the same ChunkEvent.Load. Mutating them from outside the ship assembly path corrupts the ship's block storage.

Fix: Hard coordinate cutoff before any world-chunk logic. VS shipyard chunks live at |worldX| > 10M or |worldZ| > 10M; normal gameplay never reaches that far, so Math.abs(chunkX << 4) > 10_000_000L || Math.abs(chunkZ << 4) > 10_000_000L is a safe filter. Never mutate a chunk that fails the test.

Used by: TrackGenerator.isShipyardChunk, checked at every entry point into the generation pipeline. v0.20.0+.

MOTION_BLOCKING_NO_LEAVES treats water surface as "ground"

Symptom: A feature that asks MC where the ground is, then draws a pillar from ground up to some target Y, produces pillars that terminate 13 blocks above the actual seafloor over ocean biomes — a stub hanging above the water surface, visually disconnected from the terrain below.

Cause: Heightmap.Types.MOTION_BLOCKING_NO_LEAVES predicate is (state.blocksMotion() || !state.getFluidState().isEmpty()) && !state.is(BlockTags.LEAVES) — water has a non-empty fluid state, so it counts as "ground" for this heightmap. OCEAN_FLOOR doesn't have this bug but counts leaves as ground instead, so forest biomes then sit pillars on tree canopies.

Fix: If you need "real terrain that a pillar can stand on" across all biomes, don't use a heightmap. Scan down manually from the target Y, treating air, any fluid, leaves (BlockTags.LEAVES), and vines (Blocks.VINE) as passable; stop at the first block that fails all four. One loop handles ocean, forest, and mountain correctly.

Used by: TrackGenerator.placePillar (v0.20.0+).

Vanilla Minecraft

Notes on mixin and screen-API interactions with vanilla Minecraft 1.20.1 (separate from VS).

CreateWorldScreen$WorldTab inner-class mixin via LocalCapture

Use case: Add a custom button as a row in the World tab of vanilla's Create New World screen, matching the visual style of the surrounding "Generate Structures" / "Bonus Chest" rows. The button must sit inside the tab's GridLayout (not floating over the screen) so it lays out correctly across resizes and tab switches.

Constraint: The World tab is a package-private inner class — net.minecraft.client.gui.screens.worldselection.CreateWorldScreen$WorldTab — built on top of GridLayoutTab. The GridLayout.RowHelper that vanilla uses to add its own widgets is a local variable in the constructor, not a field, so there's no public accessor.

Approach that works:

  1. Mixin into the inner class via targets = "..." (the Class<?> form can't reference package-private inner classes):

    @Mixin(targets = "net.minecraft.client.gui.screens.worldselection.CreateWorldScreen$WorldTab")
    public abstract class CreateWorldScreenMixin { ... }
  2. @Inject at TAIL of <init> and capture the RowHelper local using SpongePowered Mixin's LocalCapture:

    @Inject(
        method = "<init>",
        at = @At("TAIL"),
        locals = LocalCapture.CAPTURE_FAILEXCEPTION
    )
    private void dungeontrain$addOptionsRow(
        CreateWorldScreen createWorldScreen,
        CallbackInfo ci,
        GridLayout.RowHelper rowHelper
    ) {
        Button button = Button.builder(...).width(308).build();
        rowHelper.addChild(button, 2);
    }

    The first parameter (CreateWorldScreen createWorldScreen) is the explicit outer-class reference passed to the inner-class constructor (stored as field f_267438_ after the super(...) call) — not a synthetic this$0. The constructor signature in bytecode is WorldTab(LCreateWorldScreen;)V.

  3. rowHelper.addChild(button, 2) adds the button as a new full-width row using vanilla's own auto-incrementing row index. arrangeElements() runs automatically via GridLayoutTab.doLayout — no need to call it explicitly.

Why not hardcode a row index? Calling layout.createRowHelper(2) from the mixin creates a new RowHelper with index=0, which would collide with vanilla's existing rows (0..N). Calling layout.addChild(button, row, col, rowSpan, colSpan) directly requires knowing the next free row, which is not exposed and depends on the exact mix of addChild(..., colSpan) calls vanilla made. Capturing the live RowHelper sidesteps both problems and is the only approach that survives a rearrangement of vanilla's tab layout in a future MC patch (as long as the local is still a RowHelper).

Why not float a button over the whole screen? Initial implementation used @Inject into CreateWorldScreen.init() and added a button at bounds(width - 160, 6, 150, 20). Visually it overlapped the "More" tab title in the top-right corner (tabs occupy that area in 1.20.1). Moving the button into the World tab's grid keeps it tab-scoped (only visible when the World tab is active) and respects vanilla layout.

LocalCapture.CAPTURE_FAILEXCEPTION over CAPTURE_FAILSOFT: failsoft silently no-ops the inject if locals don't match — bad, because the button just disappears. Failexception throws loudly so the issue surfaces immediately (build verifies at compile time; runtime mismatch is a hard error rather than silent failure).

Used by: CreateWorldScreenMixin (v0.17.0+, PR #23).

Alternative used by the Floor-Y dropdown (PR #24, v0.18.0): CreateWorldScreenWorldTabMixin targets the same inner class but skips LocalCapture in favor of calling this.layout.addChild(button, row=5, col=0, rowspan=1, colspan=2, settings.alignHorizontallyCenter()) directly. This works in 1.20.1 because vanilla's WorldTab constructor fills rows 0..4 via its RowHelper (World Type + Customize, Seed label, Seed edit, Generate Structures, Bonus Chest) and then stops — a known layout. The upside is no dependency on LocalCapture's fragility (which silently no-ops on local-table mismatch); the downside is the row index is a 1.20.1-specific magic number and would need revisiting if vanilla adds or removes a row on future MC versions. If PR #23's row is ever removed, renumber or migrate to LocalCapture. Tracked as tech-debt.

Coexistence note: Both PR #23's mixin/client/CreateWorldScreenMixin (Options button) and PR #24's mixin/CreateWorldScreenWorldTabMixin (Floor-Y dropdown) are active at the same time. Both @Inject at <init> TAIL of WorldTab. Ordering is default priority (1000) — whichever mixin the mixin framework applies first gets the lower row (no hard dependency on order). Verified in-game at v0.18.0: Options button and Floor-Y dropdown render on consecutive rows beneath Bonus Chest without overlap.

Client-side world-creation choices reach the server via ServerStartedEvent

Use case: Capture per-world choices on the Create World screen (a client-side UI) and persist them to a server-side Forge SavedData file in the new world's directory.

Approach: A static client-only holder class (PendingWorldChoices) stores the values when the user clicks Done on the sub-screen. On ServerStartedEvent for the integrated server, a Dist.CLIENT-gated event subscriber reads the holder, writes to DungeonTrainWorldData.get(server.overworld()).apply(...), and clears the holder.

Why this is race-safe: ServerStartedEvent fires after the integrated server has fully started and is accepting connections, but before any player can log in. The PlayerLoggedInEvent handler that reads the SavedData therefore always sees the committed value, never the pre-commit defaults.

Dedicated server fallback: the WorldLifecycleEvents class is @Mod.EventBusSubscriber(value = Dist.CLIENT), so it is never loaded on a dedicated server. The SavedData loader's defaults (sourced from the legacy TOML config) are used instead, preserving the dedicated-server admin workflow.

Trade-off: stale values from a cancelled-out world creation can persist in the static holder until the JVM exits. Mitigation: re-opening the sub-screen pre-fills with the previous choice, so the user sees what's there and can change it. We deliberately don't clear on ScreenEvent.Closing for CreateWorldScreen because that event also fires when our sub-screen opens (CreateWorldScreen "closes" in favor of the sub-screen) — clearing there would wipe the values mid-flow.

Used by: PendingWorldChoices and WorldLifecycleEvents (v0.17.0+, PR #23).

Non-registered entity types don't render with ship transforms (VS 2.4.11)

Symptom: Decorative entities placed inside a moving Dungeon Train carriage (armor stands, paintings, item frames, glow item frames) render stuck in world space — they spawn at the correct shipyard coordinates, sync to clients, and are queryable via level.getEntitiesOfClass(...) at the expected shipyard AABB, but the render pass does not apply the ship's shipToWorld matrix. Manually placing an armor stand in a moving carriage reproduces the same behaviour — not a mod bug.

Cause: VSEntityManager.determineDefaultHandler(Entity) maps most vanilla entity types to WorldEntityHandler, which treats entities as world entities and does not transform them through the ship. Only entities paired with AbstractShipyardEntityHandler (or a subclass) get applyRenderTransform(ClientShip, T, ...) called with ship.getRenderTransform().getShipToWorld() applied to the pose stack — see org.valkyrienskies.mod.mixin.feature.shipyard_entities.MixinEntityRenderDispatcher + AbstractShipyardEntityHandler.applyRenderTransform. DefaultShipyardEntityHandler (the parent class's default) does implement the transform, but vanilla decorative entities aren't paired with it out of the box.

Attempted fix that didn't work: ship data/minecraft/vs_entities/<entity>.json resource files for armor_stand / painting / item_frame / glow_item_frame with {"handler": "valkyrienskies:shipyard"} — the format VSEntityHandlerDataLoader consumes, parallel to how VS itself registers its own entities (e.g. data/valkyrienskies/vs_entities/ship_mounting_entity.json). The data files load, the handler pair goes through VSEntityManager.pair(EntityType<?>, VSEntityHandler), and the server reports the pairings correctly — but the render transform still doesn't apply. Either the client-side handler sync (VSEntityManager.syncHandlers) isn't picking up the data-loader pairings, or the AbstractShipyardEntityHandler.applyRenderTransform default implementation has an unstated prerequisite beyond registration (e.g. an explicit freshEntityInShipyard call with a specific timing). Unresolved as of VS 2.4.11.

Workaround: block-only contents work perfectly — pressure plates, chests (incl. inventory), carpets, beds, furnaces, signs (incl. text) all ride the ship correctly. For decorative interiors, prefer placed blocks over entity-based décor until VS extends shipyard entity handling. See Feature-Carriage-Contents § Partial entity support for the full context on which entity types are affected.

Note on the shipped data files: the four data/minecraft/vs_entities/*.json files still ship with the mod (v0.36.0) — they're cheap, declarative, and will light up the intended behaviour the day VS fixes or documents the render-transform prerequisite. Removing them would revert behaviour silently when VS is updated; leaving them in place is forward-compatible.

Used by / affects: CarriageContentsTemplate.placeAt (spawns entities from saved NBT on every rolling-window carriage add), CarriageContentsEditor.enter (editor plots work correctly because they're static world-space locations at y=250, not on a ship).

General testing guidance

Per project CLAUDE.md: any change touching block physics, entity collision, or world interaction must be verified while the player is standing on a moving VS ship. VS rewrites collision via mixins and many vanilla assumptions break on ships. Record the result in every Gate 2 report.

Clone this wiki locally