Skip to content

Contained entities culled with an undersized box: rope pulley elevators (and other tall/wide contraptions) vanish from certain view angles #1595

Open

Description

@XDD426

Description

Summary

EntityRendererMixin.sable$shouldRender (in dev.ryanhcode.sable.mixin.entity.entity_rendering) decides whether to render an entity that's "contained" inside a Sable ship by building a small box around its transformed position and testing that box against the camera frustum. That box does not account for anything the entity visually extends into beyond its own block structure — specifically, a Create rope pulley elevator's rope, which reaches from the moving cab up to a stationary pulley block. When the box (not the elevator itself) leaves the frustum, the whole elevator — cab, rope, and its block entities (e.g. chests) — stops rendering, even though it's still visually in front of the camera. It reappears as soon as the box re-enters the frustum.

Reproduction
Build a Sable ship (tested on a large stationary airship).
Build a Create rope pulley elevator on the ship, with the pulley mounted well above the cab's own block structure.
Ride the elevator and look toward the horizon / away from the pulley's position at certain angles.
The elevator (and any block entities riding on it) disappears entirely. Turning back toward the pulley makes it reappear.
This reproduces with a completely stationary ship — movement is not required.

video demonstraiting the issue: (https://github.com/user-attachments/assets/9549ccb0-363a-47d0-87d0-e7b599199a3e)

Log from the same session: latest.log.

Root cause (confirmed by reading the compiled bytecode of sable-neoforge-1.21.1-2.0.4.jar)

In EntityRendererMixin.sable$shouldRender, the "contained" branch (when Sable.HELPER.getContainingClient(entity) returns non-null) builds a fixed ±2-block AABB around the transformed entity position, with no reference to the entity's actual size:

java
Vec3 globalPos = subLevel.renderPose().transformPosition(entity.position());
AABB aabb = new AABB(globalPos.x - 2.0, globalPos.y - 2.0, globalPos.z - 2.0,
globalPos.x + 2.0, globalPos.y + 2.0, globalPos.z + 2.0);
cir.setReturnValue(frustum.isVisible(aabb));

The "tracking" branch just below it, by contrast, correctly uses entity.getBoundingBoxForCulling(). The contained branch never did.

Even after replacing the ±2 box with the entity's real getBoundingBoxForCulling() (translated into ship-space the same way globalPos is), the box is still too small for a rope pulley elevator: getBoundingBoxForCulling() only covers the moving contraption's own blocks, not the rope reaching to the fixed pulley position, since the rope isn't part of the contraption's block structure.

Suggested fix

For AbstractContraptionEntity, extend the culling box to also cover the contraption's Contraption.anchor (a public BlockPos already stored on the Contraption object — the fixed point the contraption started moving from, e.g. the pulley block for a rope pulley elevator), transformed into the same ship-space as the entity's own position, e.g. via AABB.expandTowards(...). This scales correctly with elevator/shaft length instead of relying on any fixed padding.

What I've verified myself

I patched a local copy of sable-neoforge-1.21.1-2.0.4.jar along these exact lines (real bounding box + expand-towards-anchor for AbstractContraptionEntity, built and verified with ASM to get correct StackMapTable frames, not hand-spliced bytecode) and tested in-game: rode the same elevator from y=272 down to y=80 (~192 blocks), watching closely at the same angles that previously triggered the bug, with no vanishing at any point. Previously, a version of this fix that used a flat inflate amount instead of the real anchor position worked only up to that fixed distance and would be expected to fail again beyond it — the anchor-based version has no such limit since it's derived from the contraption's actual data rather than a constant. Tried to also upload the final fix i made and tested but failed

Happy to share the patched class / diff if useful.

Environment
Minecraft 1.21.1, NeoForge
Sable 2.0.4
Create Aeronautics 1.3.2 (bundled)

Game Log

https://mclo.gs/UQeQqsr

Activity

  1. XDD426 commented on Sep 29, 2026

    @XDD426
    Author
    Image

    (where the pulleys were located)

  2. juanbaiing commented on Sep 30, 2026

    @juanbaiing

    (Note: English isn't my first language — I used a translation tool to write this up, but every observation below is from my own server.) : )

    I'd like to add a possibly related but distinct symptom: an actual server-side unload, not just a client-side render cull.

    Environment: Minecraft 1.21.1 / NeoForge 21.1.248, Sable 2.0.5, Create Aeronautics 1.3.1. Extra mods of note: sableviewdistance 1.0.0 and fuck-sable 1.7.24.

    Symptom: on a large ship (172,161 blocks, bounds 141x37x33, UUID 02f0a9a3-5b00-4b57-863e-24a0a7d7e973), if I stand on or near the crane (rope pulley) area and drop down about 1 block to underneath the ship, the ship disappears completely for me. Walking back up, or approaching a different structure, makes it reappear. Standing elsewhere on the ship and dropping down does NOT trigger it — only the crane area does.

    This looks like the same undersized-AABB family of bug, but hitting the server-side containment/lookup path rather than only the client frustum check.

    Evidence from sableviewdistance (verbose mode). When it happens, it logs "released 02f0a9a3-5b00-4b57-863e-24a0a7d7e973" followed by "forgot vessel 02f0a9a3-... — held its chunks open and Sable never produced it". So SableAccess.byId() -> SubLevelContainer.getAllSubLevels() is returning nothing for a structure that sableviewdistance believes it is holding. The structure also shows as (unloaded) in /sablecleanup.

    A second, possibly related observation: during this I saw heavy oscillation, with a structure held/released every 1-2 seconds. In particular 425bc7ea-4f21-436a-9117-d4d15f8caa20 — a 3-block contraption (real bounds 3x1x1, mass 3.0) — was reported as holding anywhere from 2 to 1444 chunks, changing every tick: "holding 425bc7ea over 2 chunks", then "over 1225 chunks", then "over 1444 chunks", then "over 72 chunks". My reading is that its culling/containment box was being computed from a much larger nearby structure, not from its own 3x1x1 bounds.

    I removed that 3-block contraption via /sablecleanup, which stopped the oscillation for it — but the "drop down under the ship near the crane" disappearance still reproduces.

    Question: is the "never produced it" path the same root cause as the undersized AABB in EntityRendererMixin.sable$shouldRender, or is there a separate server-side unload that happens when a contained entity leaves the computed box? Happy to provide full logs or the vessel UUID list if useful.

  3. juanbaiing commented on Oct 1, 2026

    @juanbaiing

    (Note: English isn't my first language — I used a translation tool to write this up, but all observations below are from my own server.) :)

    I have more data now that I think pinpoints the actual trigger. It is NOT the water and NOT the structure's size — it is whether the structure is MOVING.

    I ran a controlled comparison on the same structures.

    Case 1 — ship floating on water, not frozen: vanishes repeatedly, server lags.
    Case 2 — same ship, lifted out of the water and frozen: completely normal.
    Case 3 — airship parked in mid-air, frozen: completely normal.
    Case 4 — same airship, unfrozen so it falls: heavy lag.

    Since case 3 and 4 are the same airship, in the same place, with no water involved, this rules out water/buoyancy. The only variable that changed was moving vs. not moving. So it looks like the undersized/incorrect culling box gets recomputed while the sub-level is moving, and the recomputed box is wildly wrong.

    The strongest evidence is the chunk count sableviewdistance reports for one structure (2d8976af, an airship with 359 block entities). Same structure, same session:

    While frozen (stationary):
    holding 2d8976af over 56 chunks
    holding 2d8976af over 63 chunks

    While falling (moving):
    holding 2d8976af over 441 chunks
    holding 2d8976af over 930 chunks
    holding 2d8976af over 1443 chunks

    That is a 25x swing in the reported chunk footprint for a structure that never changed size. Server log at the same time:
    Can't keep up! Running 2026ms
    Can't keep up! Running 1449ms
    Can't keep up! Running 9833ms
    Can't keep up! Running 2885ms

    So while the structure is moving, its computed box balloons, which then seems to eat the whole visibility budget (visibilityMaxChunks was 1500) and starve every other structure — including a large ship that then never loads at all.

    Related: I also saw a 3-block contraption (bounds 3x1x1) reported as holding between 2 and 1444 chunks, changing every tick. I removed it with /sablecleanup, which stopped that oscillation, but the motion-triggered behaviour still reproduces.

    Is the box used for the server-side containment/holding check derived from the same undersized-AABB logic as EntityRendererMixin.sable$shouldRender? And if so, would extending it to cover the contraption's anchor also fix the server-side "never produced it" path, or does the moving case need something else?

    Workaround for anyone hitting this: freeze your large structures (sablecleanup has a Freeze button) or park them out of water so buoyancy doesn't keep them drifting. A frozen structure behaves completely normally.

  4. XDD426 commented on Oct 3, 2026

    @XDD426
    Author

    Sorry for the latency, thanks for the detailed writeup (and the follow-up narrowing it to motion) — this is a different bug from the one in the original report, not a variant of it.

    Short answer: no, these are separate code paths. The elevator fix in this issue only touches EntityRendererMixin.sable$shouldRender, which decides whether something gets drawn client-side. What you're describing is happening in PhysicsChunkTicketManager on the server, which decides which chunks stay loaded at all. The two never call into each other, so extending the anchor-based fix wouldn't help here — this needs its own fix.

    I went and read PhysicsChunkTicketManager.update() directly to check your "it's motion" theory against the actual code, and it holds up. When a sub-level's lastPose() and logicalPose() differ by more than a tiny epsilon (i.e. it's moving), the code does this:

    system.getPipeline().getLinearVelocity(subLevel, velocity.zero()).mul(timeStep);
    b2.move(0.0, Mth.clamp(velocity.y, -20.0, 20.0), 0.0);
    b.expandTo(b2);

    So yes — motion specifically triggers a different branch that isn't taken when the structure is frozen, exactly matching your Case 1–4 comparison.

    One thing I noticed that isn't in your writeup: that prediction is Y-axis only. There's no equivalent handling for horizontal velocity at all — X/Z motion doesn't expand the box in this code path. That's a real gap, but on its own it's clamped to ±20 blocks, which doesn't come close to explaining a 25x jump or a 3-block object oscillating into four digits of chunks every tick. A bounded ±20 block miscalculation would cost you a handful of extra chunks, not over a thousand.

    My read (and this part is inference on my side, not something I've confirmed by tracing further): the base subLevel.boundingBox() itself is probably what's going wrong, not just the predictive expansion on top of it. The oscillation pattern you described for the 3-block contraption — 2, then 1225, then 1444, then 72 chunks, tick to tick, for an object that isn't changing size — looks less like "missing clamp" and more like the box computation briefly producing bad values during the physics step itself (e.g. a velocity or pose read that spikes or glitches for a frame while the body is actively simulating, rather than being queried while at rest). That would explain both your oscillation case and the 25x swing on the falling airship as the same underlying issue, with the Y-only prediction just adding a separate, smaller inaccuracy on top of it.

    If you're able to get it, the most useful next piece of evidence would be logging the raw linearVelocity vector and subLevel.boundingBox() dimensions for one struct on the ticks right before/during/after a chunk-count spike — that would show directly whether it's the velocity read, the pose read, or the box itself that's producing the bad numbers, rather than us inferring it from the downstream chunk counts.

  5. juanbaiing commented on Oct 4, 2026

    @juanbaiing

    Thanks — that was a clearer answer than I expected, and you were right that it is a different bug. I filed it separately as you suggested:

    — Moving sub-levels report wildly incorrect chunk footprints (1 → 2862 chunks on a 1-block contraption), starving the visibility budget

    Short version of what is in there, since you already did the hard part of finding the right code path:

    I re-decompiled PhysicsChunkTicketManager.update() to check your reading, and it is exact. One detail not in your writeup: after expandTo(b2) there is an additional b1.expand(1.0) that inflates the final box by 1 block in every direction. It does not change your conclusion.

    Two things that push your inference further than "not enough to explain it":

    1. You said the Y-only prediction is "clamped to ±20 blocks, which doesn't come close to explaining it." It is stronger than that — the reported footprint comes from chunkBoundsFrom(), which uses only the X/Z extents, and b2.move(0.0, Δy, 0.0) touches Y only. So that branch changes the reported chunk count by exactly zero, not by a handful. And the missing X/Z handling you spotted is harmless for the same reason: no equivalent code means X/Z motion never calls expandTo at all. So neither side of the prediction branch can produce the symptom, which points at subLevel.boundingBox() itself — your original read.

    2. I have a second, independent reproduction with the same signature, on a different server and a different structure. A ring island reported a stable 360 chunks across every sample while stationary; while I was rotating it, it went 399 → 868 → 754 → 1558 → 3248 → 624 → 1482. That is 9.0x, and note 624 → 1482 in four seconds — a 2.4x jump that continuous rotation cannot produce. Same character as your 3-block contraption and your 25x airship.

    On the evidence you asked for — I cannot get it, and I would rather say so than send you something weaker dressed up as the same thing. The world is on a managed game panel: no JMX, no agent, no debugger attach, and patching the jar means re-signing and losing the patch on the next update. A profiler gives time attribution, not a velocity vector or a box dimension.

    So: if you are willing to produce a build with a temporary debug line that prints linearVelocity and boundingBox() per tick, I will run it on this server and send back the log. That seems like the shortest path to separating "bad velocity read" vs "bad pose read" vs "bad box computation".

    One question from my earlier comment that I do not think was covered, and I want to check rather than assume: is there a separate server-side unload that fires when a contained entity leaves its computed box, distinct from the ticketing decision? In our logs these always appear together —

    released 91228f62-...
    forgot vessel 91228f62-... — held its chunks open and Sable never produced it
    

    — and /sablecleanup then lists the structure as (unloaded). If the ballooning footprint is what breaks the subsequent lookup, that is one mechanism. If forgot vessel can fire for a structure that was never oversized, it is a second one. I have noted it in the new issue so the two are not conflated, but I did not want to assume they are the same thing.

    Workaround for anyone reading this thread: freezing large structures removes the symptom completely, and so does parking them out of water so buoyancy stops drifting them. Anything that stops the structure moving stops the problem.

  6. juanbaiing commented on Oct 4, 2026

    @juanbaiing

    Correction: the link in my comment above didn't come through. The separate issue I filed is here:

    #1607

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions