Refactor V2 animation, furniture, and GLTF rendering

Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.

Parsing and rendering
  - Shared GLB parsing helpers consolidated into GlbParserUtils
    (accessor reads, weight normalization, joint-index clamping,
    coordinate-space conversion, animation parse, primitive loop).
  - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
    GltfLiveBoneReader — removes per-frame joint-matrix allocation
    from the render hot path.
  - emitVertex helper dedups three parallel loops in GltfMeshRenderer.
  - TintColorResolver.resolve has a zero-alloc path when the item
    declares no tint channels.
  - itemAnimCache bounded to 256 entries (access-order LRU) with
    atomic get-or-compute under the map's monitor.

Animation correctness
  - First-in-joint-order wins when body and torso both map to the
    same PlayerAnimator slot; duplicate writes log a single WARN.
  - Multi-item composites honor the FullX / FullHeadX opt-in that
    the single-item path already recognized.
  - Seat transforms converted to Minecraft model-def space so
    asymmetric furniture renders passengers at the correct offset.
  - GlbValidator: IBM count / type / presence, JOINTS_0 presence,
    animation channel target validation, multi-skin support.

Furniture correctness and anti-exploit
  - Seat assignment synced via SynchedEntityData (server is
    authoritative; eliminates client-server divergence on multi-seat).
  - Force-mount authorization requires same dimension and a free
    seat; cross-dimension distance checks rejected.
  - Reconnection on login checks for seat takeover before re-mount
    and force-loads the target chunk for cross-dimension cases.
  - tiedup_furniture_lockpick_ctx carries a session UUID nonce so
    stale context can't misroute a body-item lockpick.
  - tiedup_locked_furniture survives death without keepInventory
    (Forge 1.20.1 does not auto-copy persistent data on respawn).

Lifecycle and memory
  - EntityCleanupHandler fans EntityLeaveLevelEvent out to every
    per-entity state map on the client.
  - DogPoseRenderHandler re-keyed by UUID (stable across dimension
    change; entity int ids are recycled).
  - PetBedRenderHandler, PlayerArmHideEventHandler, and
    HeldItemHideHandler use receiveCanceled + sentinel sets so
    Pre-time mutations are restored even when a downstream handler
    cancels the render.

Tests
  - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
    FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
NotEvil
2026-04-18 17:34:03 +02:00
parent 17815873ac
commit 355e2936c9
63 changed files with 4965 additions and 2226 deletions

View File

@@ -39,6 +39,11 @@ public class V2BondageEquipment implements IV2BondageEquipment {
private static final String NBT_ROOT_KEY = "V2BondageRegions";
private static final String NBT_ALSO_SUFFIX = "_also";
// Not thread-safe by itself. Safe in practice because Forge capabilities
// maintain separate instances for logical client and logical server; each
// side's instance is only touched by that side's main thread. If a future
// feature ever reads/writes this from a worker (async asset, network
// thread), the access pattern needs rethinking.
private final EnumMap<BodyRegionV2, ItemStack> regions;
// Pole leash persistence
@@ -107,20 +112,22 @@ public class V2BondageEquipment implements IV2BondageEquipment {
@Override
public boolean isRegionBlocked(BodyRegionV2 region) {
if (region == null) return false;
// Check if any equipped item's getBlockedRegions() includes this region
for (Map.Entry<
BodyRegionV2,
ItemStack
> entry : getAllEquipped().entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem item) {
if (
item.getBlockedRegions(stack).contains(region) &&
!item.getOccupiedRegions(stack).contains(region)
) {
// Blocked by another item (not self-blocking via occupation)
return true;
}
// Scan regions.values() directly and identity-dedup multi-region
// items inline. Avoids the 2-map allocation (IdentityHashMap +
// LinkedHashMap) of getAllEquipped() — this is called per-frame
// from FirstPersonHandHideHandler and would otherwise produce
// 4 map allocs/frame even when the local player wears nothing.
IdentityHashMap<ItemStack, Boolean> seen = null;
for (ItemStack stack : regions.values()) {
if (stack == null || stack.isEmpty()) continue;
if (!(stack.getItem() instanceof IV2BondageItem item)) continue;
if (seen == null) seen = new IdentityHashMap<>();
if (seen.put(stack, Boolean.TRUE) != null) continue;
if (
item.getBlockedRegions(stack).contains(region) &&
!item.getOccupiedRegions(stack).contains(region)
) {
return true;
}
}
return false;
@@ -130,13 +137,16 @@ public class V2BondageEquipment implements IV2BondageEquipment {
public int getEquippedCount() {
// Count unique non-empty stacks directly, avoiding the 2-map allocation
// of getAllEquipped(). Uses identity-based dedup for multi-region items.
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
// Fast-path: zero allocations when no item is equipped (hot per-tick
// call from hasAnyEquipment).
IdentityHashMap<ItemStack, Boolean> seen = null;
for (ItemStack stack : regions.values()) {
if (stack != null && !stack.isEmpty()) {
if (seen == null) seen = new IdentityHashMap<>();
seen.put(stack, Boolean.TRUE);
}
}
return seen.size();
return seen == null ? 0 : seen.size();
}
@Override

View File

@@ -30,27 +30,48 @@ public final class TintColorResolver {
/**
* Resolve tint colors for an ItemStack.
*
* <p>Hot-path: called once per rendered V2 item per frame. Skips the
* {@link LinkedHashMap} allocation when neither the definition nor the
* stack declares any tint channels — the common case for non-tintable
* items (audit P2-04, 06-v2-bondage-rendering.md HIGH-5).</p>
*
* @param stack the equipped bondage item
* @return channel-to-color map; empty if no tint channels defined or found
* @return channel-to-color map; empty (immutable {@link Map#of()}) if no
* tint channels defined or found
*/
public static Map<String, Integer> resolve(ItemStack stack) {
Map<String, Integer> result = new LinkedHashMap<>();
// 1. Load defaults from DataDrivenItemDefinition
// Hot-path shortcut: both sources empty → return the singleton empty
// map without allocating.
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null && def.tintChannels() != null) {
boolean hasDefChannels =
def != null &&
def.tintChannels() != null &&
!def.tintChannels().isEmpty();
CompoundTag tag = stack.getTag();
boolean hasNbtTints =
tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND);
if (!hasDefChannels && !hasNbtTints) {
return Map.of();
}
Map<String, Integer> result = new LinkedHashMap<>();
if (hasDefChannels) {
result.putAll(def.tintChannels());
}
// 2. Override with NBT "tint_colors" (player dye overrides)
CompoundTag tag = stack.getTag();
if (tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND)) {
if (hasNbtTints) {
CompoundTag tints = tag.getCompound("tint_colors");
for (String key : tints.getAllKeys()) {
result.put(key, tints.getInt(key));
// Type-check the NBT entry: a corrupt save or malicious client
// might store a non-int value; getInt would silently return 0
// and render pure black. Skip non-int entries.
if (tints.contains(key, Tag.TAG_INT)) {
// Clamp to 24-bit RGB (no alpha in tint channels).
result.put(key, tints.getInt(key) & 0xFFFFFF);
}
}
}
return result;
}
}

View File

@@ -14,8 +14,10 @@ import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
@@ -81,6 +83,19 @@ public class V2BondageRenderLayer<
float netHeadYaw,
float headPitch
) {
// Do NOT use entity.isInvisible() — that hides the local player's own
// bondage from their F5/F1 self-view under Invisibility. isInvisibleTo
// handles same-team visibility and spectator viewers correctly BUT
// returns true for self when teamless (MC default) + Invisibility —
// excluding `entity == mc.player` preserves the self-view.
Minecraft mc = Minecraft.getInstance();
if (mc.player != null && entity != mc.player && entity.isInvisibleTo(mc.player)) {
return;
}
if (entity instanceof Player p && p.isSpectator()) {
return;
}
// Get V2 equipment via capability (Players) or IV2EquipmentHolder (Damsels)
IV2BondageEquipment equipment = null;
if (entity instanceof Player player) {
@@ -119,14 +134,23 @@ public class V2BondageRenderLayer<
ItemStack stack = entry.getValue();
if (stack.isEmpty()) continue;
// Furniture blocks this region — skip rendering
if (furnitureBlocked.contains(entry.getKey())) continue;
// Check if the item implements IV2BondageItem
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) {
continue;
}
// Skip if the furniture blocks ANY region this item occupies. A
// multi-region item (armbinder on {ARMS, HANDS, TORSO}) can be
// keyed in the de-duplicated map under ARMS but must still skip
// when a seat blocks only HANDS — hence disjoint() on all regions.
Set<BodyRegionV2> itemRegions = bondageItem.getOccupiedRegions(stack);
if (
!furnitureBlocked.isEmpty() &&
!Collections.disjoint(itemRegions, furnitureBlocked)
) {
continue;
}
// Select slim model variant for Alex-style players or slim Damsels
boolean isSlim;
if (entity instanceof AbstractClientPlayer acp) {

View File

@@ -34,9 +34,6 @@ public record DataDrivenItemDefinition(
/** Optional slim (Alex-style) model variant. */
@Nullable ResourceLocation slimModelLocation,
/** Optional base texture path for color variant resolution. */
@Nullable ResourceLocation texturePath,
/** Optional separate GLB for animations (shared template). */
@Nullable ResourceLocation animationSource,

View File

@@ -38,7 +38,6 @@ import org.jetbrains.annotations.Nullable;
* "translation_key": "item.tiedup.leather_armbinder",
* "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
* "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
* "texture": "tiedup:textures/item/armbinder",
* "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
* "regions": ["ARMS", "HANDS", "TORSO"],
* "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
@@ -150,13 +149,6 @@ public final class DataDrivenItemParser {
fileId
);
// Optional: texture
ResourceLocation texturePath = parseOptionalResourceLocation(
root,
"texture",
fileId
);
// Optional: animation_source
ResourceLocation animationSource = parseOptionalResourceLocation(
root,
@@ -340,7 +332,6 @@ public final class DataDrivenItemParser {
translationKey,
modelLocation,
slimModelLocation,
texturePath,
animationSource,
occupiedRegions,
blockedRegions,

View File

@@ -48,6 +48,16 @@ public final class DataDrivenItemRegistry {
*/
private static volatile RegistrySnapshot SNAPSHOT = RegistrySnapshot.EMPTY;
/**
* Monotonically increasing revision counter, incremented on every
* {@link #reload} or {@link #mergeAll}. Client-side caches (e.g.,
* {@code DataDrivenIconOverrides}) observe this to invalidate themselves
* when the registry changes between bake passes — {@code /reload} updates
* the registry but does not re-fire {@code ModelEvent.ModifyBakingResult},
* so any cached resolution would otherwise go stale.
*/
private static volatile int revision = 0;
/** Guards read-then-write sequences in {@link #reload} and {@link #mergeAll}. */
private static final Object RELOAD_LOCK = new Object();
@@ -66,6 +76,7 @@ public final class DataDrivenItemRegistry {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(new HashMap<>(newDefs));
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
revision++;
}
}
@@ -91,9 +102,19 @@ public final class DataDrivenItemRegistry {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(merged);
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
revision++;
}
}
/**
* Current registry revision — incremented on every {@link #reload} or
* {@link #mergeAll}. Client-side caches compare their stored revision
* against this to detect staleness across {@code /reload} cycles.
*/
public static int getRevision() {
return revision;
}
/**
* Get a definition by its unique ID.
*

View File

@@ -75,11 +75,12 @@ public class PacketSyncV2Equipment {
Entity entity = level.getEntity(msg.entityId);
if (entity instanceof LivingEntity living) {
// IV2EquipmentHolder entities (e.g., Damsels) sync via SynchedEntityData,
// not via this packet. If we receive one for such an entity, deserialize
// directly into their internal equipment storage.
if (living instanceof IV2EquipmentHolder holder) {
holder.getV2Equipment().deserializeNBT(msg.data);
// IV2EquipmentHolder entities (Damsels, NPCs) use SynchedEntityData
// for equipment sync, not this packet — both V2EquipmentHelper.sync
// and syncTo short-circuit on holders. If a packet for a holder
// does arrive it's a protocol bug; drop it rather than race with
// the EntityData path.
if (living instanceof IV2EquipmentHolder) {
return;
}
living

View File

@@ -59,9 +59,30 @@ public class DataDrivenIconOverrides extends ItemOverrides {
private final Mode mode;
/**
* Sentinel meaning "no revision observed yet". Anything other than this
* value means the caches were populated under that revision.
*/
private static final int UNINITIALIZED = Integer.MIN_VALUE;
/**
* Last-observed revision of the backing registry (selected by {@link #mode}).
* If the registry mutates between bake passes (typically via {@code /reload}
* on the server, which does NOT re-fire {@code ModelEvent.ModifyBakingResult}),
* the next {@link #resolve} call detects the mismatch and flushes
* {@link #iconModelCache} / {@link #knownMissing} / {@link #warnedMissing}.
*
* <p>Lazily initialized on first {@link #resolve} to avoid a race where the
* client reload listener runs after {@code ModifyBakingResult} created
* this instance but before the first frame renders — early init here
* would then observe a mismatch and flush an already-warm cache.</p>
*/
private volatile int observedRevision = UNINITIALIZED;
/**
* Cache of resolved icon ResourceLocations to their BakedModels.
* Cleared on resource reload (when ModifyBakingResult fires again).
* Cleared on resource reload (when ModifyBakingResult fires again) and
* implicitly on registry revision change.
* Uses ConcurrentHashMap because resolve() is called from the render thread.
*
* <p>Values are never null (ConcurrentHashMap forbids nulls). Missing icons
@@ -97,6 +118,26 @@ public class DataDrivenIconOverrides extends ItemOverrides {
@Nullable LivingEntity entity,
int seed
) {
// Observe the registry revision; if it moved since we last populated
// our caches, flush them. This handles the /reload-without-rebake path
// where ModelEvent.ModifyBakingResult doesn't re-fire but the
// underlying definition set has changed.
//
// The revision source depends on mode: bondage items live in
// DataDrivenItemRegistry, furniture placers live in FurnitureRegistry.
// A stale check against the wrong registry would ignore /reload updates
// to the corresponding feature.
int currentRevision = currentRegistryRevision();
if (observedRevision == UNINITIALIZED) {
// Lazy init on first resolve. Baseline the current revision so the
// post-bake warm cache populated by the very first resolve call
// isn't spuriously flushed by a race with the reload listener.
observedRevision = currentRevision;
} else if (currentRevision != observedRevision) {
clearCache();
observedRevision = currentRevision;
}
ResourceLocation iconRL = getIconFromStack(stack);
if (iconRL == null) {
// No icon defined for this variant — use the default model
@@ -230,4 +271,21 @@ public class DataDrivenIconOverrides extends ItemOverrides {
knownMissing.clear();
warnedMissing.clear();
}
/**
* Select the registry revision source appropriate for this override's mode.
* Bondage items and furniture placers have independent registries and
* independent /reload cycles, so a bondage-item cache must not invalidate
* just because furniture reloaded (and vice versa).
*/
private int currentRegistryRevision() {
switch (mode) {
case BONDAGE_ITEM:
return DataDrivenItemRegistry.getRevision();
case FURNITURE_PLACER:
return com.tiedup.remake.v2.furniture.FurnitureRegistry.getRevision();
default:
return 0;
}
}
}

View File

@@ -92,6 +92,13 @@ public class ObjBlockRenderer implements BlockEntityRenderer<ObjBlockEntity> {
}
}
/**
* Always true. Furniture OBJ models routinely have visible geometry that
* extends past the block's 1×1×1 AABB (a 3-wide couch), so the default
* per-entity frustum cull produces visible pop-in at screen edges as the
* origin block leaves view. Extra cost: a few OBJ draws per frame for
* off-screen furniture.
*/
@Override
public boolean shouldRenderOffScreen(ObjBlockEntity blockEntity) {
return true;

View File

@@ -5,7 +5,13 @@ import com.tiedup.remake.client.model.CellCoreBakedModel;
import com.tiedup.remake.client.renderer.CellCoreRenderer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.client.resources.model.ModelResourceLocation;
import net.minecraft.resources.ResourceLocation;
@@ -14,6 +20,7 @@ import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.ModelEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.registries.ForgeRegistries;
/**
* V2 Client-side setup.
@@ -65,6 +72,81 @@ public class V2ClientSetup {
);
}
/**
* Register custom icon models for baking.
*
* <p>When a data-driven item or furniture JSON declares an {@code icon}
* ResourceLocation, the mod loads the corresponding model from
* {@code assets/<namespace>/models/item/<path>.json} (or
* {@code .../models/item/icons/<path>.json} by convention). Forge's
* model baker only auto-discovers models referenced by registered items;
* icons that aren't the model of a registered item are invisible unless
* explicitly registered here.</p>
*
* <p>We scan both registries at this phase and register every non-null
* icon. If an icon already matches a registered item's model, Forge
* deduplicates internally — safe to register unconditionally.</p>
*
* <p>Timing caveat: if {@code DataDrivenItemRegistry} hasn't finished
* loading JSON on first bootstrap, {@link #getAllIconLocations()}
* returns fewer entries than expected. The subsequent resource reload
* (triggered by {@code DataDrivenItemReloadListener}) refires this
* event, so icons register correctly on the second pass. In practice,
* inventory icons are visible from the first render after login.</p>
*/
@SubscribeEvent
public static void onRegisterAdditionalModels(
ModelEvent.RegisterAdditional event
) {
Set<ResourceLocation> icons = collectCustomIconLocations();
int registered = 0;
for (ResourceLocation icon : icons) {
event.register(icon);
registered++;
}
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Registered {} custom icon model(s) for baking",
registered
);
}
/**
* Gather every icon ResourceLocation declared by a data-driven item or
* furniture definition that doesn't correspond to an already-registered
* Forge item. Icons pointing at registered items are auto-loaded by the
* baker and don't need explicit registration.
*/
private static Set<ResourceLocation> collectCustomIconLocations() {
Set<ResourceLocation> icons = new HashSet<>();
for (DataDrivenItemDefinition def : DataDrivenItemRegistry.getAll()) {
if (def.icon() != null && !isRegisteredItemModel(def.icon())) {
icons.add(def.icon());
}
}
for (FurnitureDefinition def : FurnitureRegistry.getAll()) {
if (def.icon() != null && !isRegisteredItemModel(def.icon())) {
icons.add(def.icon());
}
}
return icons;
}
/**
* True when {@code modelLoc} follows the {@code <ns>:item/<path>} convention
* and {@code <ns>:<path>} is a registered Forge item. Those icons are
* auto-baked — re-registering is harmless but noisy.
*/
private static boolean isRegisteredItemModel(ResourceLocation modelLoc) {
String path = modelLoc.getPath();
if (!path.startsWith("item/")) return false;
String itemPath = path.substring("item/".length());
ResourceLocation itemRL = ResourceLocation.fromNamespaceAndPath(
modelLoc.getNamespace(),
itemPath
);
return ForgeRegistries.ITEMS.containsKey(itemRL);
}
@SubscribeEvent
public static void onModifyBakingResult(
ModelEvent.ModifyBakingResult event

View File

@@ -90,6 +90,23 @@ public class EntityFurniture
EntityDataSerializers.BYTE
);
/**
* Passenger UUID → seat id, serialized as
* {@code "uuid;seatId|uuid;seatId|..."} (empty string = no assignments).
* Seat id itself must not contain {@code |} or {@code ;} — furniture
* definitions use lowercase snake_case which is safe.
* <p>
* Server updates this string alongside every {@link #seatAssignments}
* mutation so clients see the authoritative mapping. Without this, each
* side independently ran {@code findNearestAvailableSeat} and could
* diverge on multi-seat furniture (wrong render offset, wrong anim).
*/
private static final EntityDataAccessor<String> SEAT_ASSIGNMENTS_SYNC =
SynchedEntityData.defineId(
EntityFurniture.class,
EntityDataSerializers.STRING
);
// ========== Animation State Constants ==========
public static final byte STATE_IDLE = 0;
@@ -140,6 +157,7 @@ public class EntityFurniture
this.entityData.define(FURNITURE_ID, "");
this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
this.entityData.define(ANIM_STATE, STATE_IDLE);
this.entityData.define(SEAT_ASSIGNMENTS_SYNC, "");
}
// ========== IEntityAdditionalSpawnData ==========
@@ -205,11 +223,51 @@ public class EntityFurniture
@Override
public void assignSeat(Entity passenger, String seatId) {
seatAssignments.put(passenger.getUUID(), seatId);
syncSeatAssignmentsIfServer();
}
@Override
public void releaseSeat(Entity passenger) {
seatAssignments.remove(passenger.getUUID());
syncSeatAssignmentsIfServer();
}
/**
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
* so tracking clients see the authoritative mapping. No-op on client.
*/
private void syncSeatAssignmentsIfServer() {
if (this.level().isClientSide) return;
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
boolean first = true;
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
if (!first) sb.append('|');
sb.append(entry.getKey()).append(';').append(entry.getValue());
first = false;
}
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
}
/**
* Parse {@link #SEAT_ASSIGNMENTS_SYNC} back into {@link #seatAssignments}.
* Called on the client when the server's broadcast arrives. Malformed
* entries (bad UUID, empty seat id) are skipped silently; we don't want
* to throw on a packet from a future protocol version.
*/
private void applySyncedSeatAssignments(String serialized) {
seatAssignments.clear();
if (serialized.isEmpty()) return;
for (String entry : serialized.split("\\|")) {
int sep = entry.indexOf(';');
if (sep <= 0 || sep == entry.length() - 1) continue;
try {
UUID uuid = UUID.fromString(entry.substring(0, sep));
String seatId = entry.substring(sep + 1);
seatAssignments.put(uuid, seatId);
} catch (IllegalArgumentException ignored) {
// Corrupt UUID — skip this entry, preserve the rest.
}
}
}
@Override
@@ -376,17 +434,14 @@ public class EntityFurniture
int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0;
if (seatIdx < 0) seatIdx = 0;
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
double offset =
seatCount == 1 ? 0.0 : (seatIdx - (seatCount - 1) / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
double offset = FurnitureSeatGeometry.seatOffset(seatIdx, seatCount);
moveFunction.accept(
passenger,
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
}
@@ -399,9 +454,16 @@ public class EntityFurniture
protected void addPassenger(Entity passenger) {
super.addPassenger(passenger);
SeatDefinition nearest = findNearestAvailableSeat(passenger);
if (nearest != null) {
assignSeat(passenger, nearest.id());
// Seat selection is server-authoritative. The client waits for the
// SEAT_ASSIGNMENTS_SYNC broadcast (~1 tick later) before knowing
// which seat this passenger is in. During that gap positionRider
// falls back to entity center — 1 frame of imperceptible glitch
// on mount, in exchange for deterministic multi-seat correctness.
if (!this.level().isClientSide) {
SeatDefinition nearest = findNearestAvailableSeat(passenger);
if (nearest != null) {
assignSeat(passenger, nearest.id());
}
}
// Play entering transition: furniture shows "Occupied" clip while player settles in.
@@ -411,6 +473,125 @@ public class EntityFurniture
this.transitionTicksLeft = 20;
this.transitionTargetState = STATE_OCCUPIED;
}
// Note: the previous client-side startFurnitureAnimationClient call is
// no longer needed here — the animation is kicked off by
// onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
// after mount). The cold-cache retry in AnimationTickHandler still
// covers the case where the GLB hasn't parsed yet.
}
/**
* Client-only: (re)start the seat pose animation for a player mounting this
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
* the seat has no authored clip, or the animation context rejects the setup.
*
* <p>Called from three sites:</p>
* <ul>
* <li>{@link #addPassenger} on mount</li>
* <li>{@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes</li>
* <li>The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)</li>
* </ul>
*
* <p>Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
* for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
* → first available clip. This lets artists author optional state-specific poses
* ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.</p>
*/
public static void startFurnitureAnimationClient(
EntityFurniture furniture,
Player player
) {
if (!furniture.level().isClientSide) return;
SeatDefinition seat = furniture.getSeatForPassenger(player);
if (seat == null) return;
FurnitureDefinition def = furniture.getDefinition();
if (def == null) return;
com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
def.modelLocation()
);
if (gltfData == null) return;
Map<
String,
com.tiedup.remake.client.gltf.GltfData.AnimationClip
> seatClips = gltfData.seatAnimations().get(seat.id());
if (seatClips == null || seatClips.isEmpty()) return;
com.tiedup.remake.client.gltf.GltfData seatSkeleton =
gltfData.seatSkeletons().get(seat.id());
// State-driven clip selection for the player seat armature. Names match
// the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
// author matching clips. The fallback chain handles missing clips
// (state-specific → "Occupied" → first available), so artists only need
// to author what they want to customize.
String stateClipName = switch (furniture.getAnimState()) {
case STATE_OCCUPIED -> "Occupied";
case STATE_STRUGGLE -> "Struggle";
case STATE_ENTERING -> "Enter";
case STATE_EXITING -> "Exit";
case STATE_LOCKING -> "LockClose";
case STATE_UNLOCKING -> "LockOpen";
default -> "Idle";
};
com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
seatClips.get(stateClipName);
if (clip == null) clip = seatClips.get("Occupied");
if (clip == null) clip = seatClips.values().iterator().next();
if (clip == null) return;
dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
clip,
seatSkeleton,
seat.blockedRegions()
);
if (anim != null) {
com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
player,
anim
);
}
}
/**
* Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
* pose for each seated player so the authored state-specific clip kicks in.
* Without this, a server-side transition (mount entering → occupied, lock
* close, struggle start) never propagates to the player's pose.
*/
@Override
public void onSyncedDataUpdated(
net.minecraft.network.syncher.EntityDataAccessor<?> key
) {
super.onSyncedDataUpdated(key);
if (!this.level().isClientSide) return;
if (ANIM_STATE.equals(key)) {
for (Entity passenger : this.getPassengers()) {
if (passenger instanceof Player player) {
startFurnitureAnimationClient(this, player);
}
}
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
applySyncedSeatAssignments(
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
);
// Re-play animations for passengers whose seat id just changed.
// Without this the client could keep rendering a passenger with
// the previous seat's blockedRegions until some other trigger.
for (Entity passenger : this.getPassengers()) {
if (passenger instanceof Player player) {
startFurnitureAnimationClient(this, player);
}
}
}
}
/**
@@ -432,11 +613,7 @@ public class EntityFurniture
Vec3 passengerPos = passenger.getEyePosition();
Vec3 lookDir = passenger.getLookAngle();
float yawRad = (float) Math.toRadians(this.getYRot());
// Entity-local right axis (perpendicular to facing direction in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -452,11 +629,11 @@ public class EntityFurniture
// Approximate seat world position: entity origin + offset along right axis.
// For a single seat, it's at center. For multiple, spread evenly.
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
// Score: angle between passenger look direction and direction to seat.
@@ -501,10 +678,7 @@ public class EntityFurniture
) {
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(this.getYRot());
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -520,11 +694,11 @@ public class EntityFurniture
!seat.lockable() || !seatAssignments.containsValue(seat.id())
) continue;
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
Vec3 seatWorldPos = new Vec3(
this.getX() + rightX * offset,
this.getX() + right.x * offset,
this.getY() + 0.5,
this.getZ() + rightZ * offset
this.getZ() + right.z * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);
@@ -698,27 +872,16 @@ public class EntityFurniture
for (IBondageState captive : captorManager.getCaptives()) {
LivingEntity captiveEntity = captive.asLivingEntity();
// Skip captives that are already riding something
if (captiveEntity.isPassenger()) continue;
// Must be tied (leashed) and alive
if (!captive.isTiedUp()) continue;
if (!captiveEntity.isAlive()) continue;
// Must be within 5 blocks of the furniture
if (captiveEntity.distanceTo(this) > 5.0) continue;
// Verify collar ownership
if (!captive.hasCollar()) continue;
ItemStack collarStack = captive.getEquipment(
BodyRegionV2.NECK
);
// Unified authorization via shared predicate.
if (
collarStack.isEmpty() ||
!CollarHelper.isCollar(collarStack)
) continue;
if (
!CollarHelper.isOwner(collarStack, serverPlayer) &&
!serverPlayer.hasPermissions(2)
) continue;
!FurnitureAuthPredicate.canForceMount(
serverPlayer,
this,
captiveEntity
)
) {
continue;
}
// Detach leash only (drop the lead, keep tied-up status)
captive.free(false);
@@ -761,13 +924,22 @@ public class EntityFurniture
// Priority 2: Key + occupied seat -> lock/unlock
// Use look direction to pick the nearest occupied, lockable seat.
// Authorization is enforced via FurnitureAuthPredicate so the in-world
// path cannot drift from the packet path.
ItemStack heldItem = player.getItemInHand(hand);
if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) {
if (
isKeyItem(heldItem) &&
!this.getPassengers().isEmpty() &&
player instanceof ServerPlayer sp
) {
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
player,
def
);
if (targetSeat != null) {
if (
targetSeat != null &&
FurnitureAuthPredicate.canLockUnlock(sp, this, targetSeat.id())
) {
boolean wasLocked = isSeatLocked(targetSeat.id());
setSeatLocked(targetSeat.id(), !wasLocked);
@@ -838,7 +1010,9 @@ public class EntityFurniture
/**
* Check if the given item is a key that can lock/unlock furniture seats.
* Currently only {@link ItemMasterKey} qualifies.
* Hardcoded to {@link ItemMasterKey}: the seat-lock mechanic is single-key
* by design (no per-seat keys). A future second key item would need an
* {@code ILockKey} interface; until then {@code instanceof} is cheapest.
*/
private boolean isKeyItem(ItemStack stack) {
return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
@@ -970,6 +1144,7 @@ public class EntityFurniture
"[EntityFurniture] Cleaned up stale seat assignments on {}",
getFurnitureId()
);
syncSeatAssignmentsIfServer();
updateAnimState();
}
}
@@ -1036,6 +1211,9 @@ public class EntityFurniture
}
}
}
// Push the restored map into the synced field so clients that start
// tracking the entity after load get the right assignments too.
syncSeatAssignmentsIfServer();
this.refreshDimensions();
}
@@ -1110,6 +1288,19 @@ public class EntityFurniture
this.entityData.set(ANIM_STATE, state);
}
/**
* Set a transient animation state that auto-reverts to {@code target}
* after {@code ticks} ticks. Callers of transitional states
* (LOCKING/UNLOCKING/ENTERING/EXITING) MUST use this rather than
* {@link #setAnimState} so the tick-decrement path has the right
* target to revert to.
*/
public void setTransitionState(byte state, int ticks, byte target) {
this.entityData.set(ANIM_STATE, state);
this.transitionTicksLeft = ticks;
this.transitionTargetState = target;
}
// ========== Entity Behavior Overrides ==========
/** Furniture can be targeted by the crosshair (for interaction and attack). */

View File

@@ -0,0 +1,317 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.CollarHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
/**
* Shared authorization predicate for furniture packets and in-world interactions.
*
* <p>The 2026-04-17 audit found three divergent code paths enforcing different
* subsets of the intended security model:</p>
* <ul>
* <li>{@code EntityFurniture.interact()} force-mount path — all checks present</li>
* <li>{@code PacketFurnitureForcemount.handleOnServer} — missing
* {@code isTiedUp} and {@code !isPassenger} checks</li>
* <li>{@code EntityFurniture.interact()} lock path and
* {@code PacketFurnitureLock.handleOnServer} — missing collar-ownership
* enforcement</li>
* </ul>
*
* <p>This class unifies the model. The security rule is:</p>
* <ol>
* <li><b>Lock/unlock</b>: sender holds a master key, seat exists and is
* lockable, seat is occupied, and the occupant wears a collar owned by
* the sender (or the sender has OP permissions). A seat whose occupant
* has no collar cannot be locked by anyone other than an OP.</li>
* <li><b>Force-mount</b>: captive is alive, within range, wears a collar
* owned by the sender (or sender is OP), is currently tied-up (leashed),
* and is not already a passenger of some other entity.</li>
* </ol>
*
* <p>The boolean core methods are package-private to allow pure-logic unit
* testing without a Minecraft runtime.</p>
*/
public final class FurnitureAuthPredicate {
/** Max interaction range from sender to furniture/captive (blocks). */
public static final double INTERACTION_RANGE = 5.0;
private FurnitureAuthPredicate() {}
// ============================================================
// Pure-logic core (unit-testable with booleans)
// ============================================================
/**
* Pure-logic authorization for a lock/unlock action. All parameters are
* pre-extracted from Minecraft entities by {@link #canLockUnlock}.
*/
static boolean isAuthorizedForLock(
boolean senderHasKey,
boolean seatExistsAndLockable,
boolean seatOccupied,
boolean occupantHasOwnedCollar
) {
return (
senderHasKey &&
seatExistsAndLockable &&
seatOccupied &&
occupantHasOwnedCollar
);
}
/**
* Pure-logic authorization for a force-mount action. All parameters are
* pre-extracted from Minecraft entities by {@link #canForceMount}.
*/
static boolean isAuthorizedForForceMount(
boolean captiveAlive,
boolean captiveWithinRange,
boolean captiveHasOwnedCollar,
boolean captiveIsTiedUp,
boolean captiveIsNotPassenger
) {
return (
captiveAlive &&
captiveWithinRange &&
captiveHasOwnedCollar &&
captiveIsTiedUp &&
captiveIsNotPassenger
);
}
// ============================================================
// MC-aware wrappers (call sites use these)
// ============================================================
/**
* Check whether {@code sender} may toggle the lock state of the given seat.
* Also runs proximity / liveness gates on the furniture entity itself.
* Logs a DEBUG reason on denial.
*/
public static boolean canLockUnlock(
ServerPlayer sender,
Entity furniture,
String seatId
) {
if (sender == null || furniture == null || seatId == null) {
return false;
}
if (!furniture.isAlive() || furniture.isRemoved()) {
debug(
"denied: furniture removed/dead (id={})",
furniture.getId()
);
return false;
}
if (sender.distanceTo(furniture) > INTERACTION_RANGE) {
debug(
"denied: out of range ({} blocks)",
sender.distanceTo(furniture)
);
return false;
}
if (!(furniture instanceof ISeatProvider provider)) {
debug("denied: entity is not a seat provider");
return false;
}
boolean senderHasKey = holdsMasterKey(sender);
boolean seatExistsAndLockable = seatIsLockable(provider, seatId);
LivingEntity occupant = findOccupant(furniture, seatId);
boolean seatOccupied = occupant != null;
boolean occupantHasOwnedCollar = occupantHasOwnedCollarFor(
occupant,
sender
);
boolean authorized = isAuthorizedForLock(
senderHasKey,
seatExistsAndLockable,
seatOccupied,
occupantHasOwnedCollar
);
if (!authorized) {
debug(
"lock denied: sender={}, key={}, lockable={}, occupied={}, ownedCollar={}",
sender.getName().getString(),
senderHasKey,
seatExistsAndLockable,
seatOccupied,
occupantHasOwnedCollar
);
}
return authorized;
}
/**
* Check whether {@code sender} may force-mount {@code captive} onto
* {@code furniture}. Logs a DEBUG reason on denial.
*/
public static boolean canForceMount(
ServerPlayer sender,
Entity furniture,
LivingEntity captive
) {
if (sender == null || furniture == null || captive == null) {
return false;
}
if (!furniture.isAlive() || furniture.isRemoved()) return false;
// Entity.distanceTo ignores dimension — require same level explicitly.
if (sender.level() != furniture.level()) return false;
if (sender.level() != captive.level()) return false;
if (sender.distanceTo(furniture) > INTERACTION_RANGE) return false;
// A full furniture must reject force-mount: assignSeat would find no
// free seat and the captive would become a passenger with no seat id.
if (furniture instanceof com.tiedup.remake.v2.furniture.EntityFurniture ef) {
FurnitureDefinition def = ef.getDefinition();
int seatCount = def != null ? def.seats().size() : 0;
if (ef.getPassengers().size() >= seatCount) return false;
}
boolean captiveAlive = captive.isAlive() && !captive.isRemoved();
boolean captiveWithinRange =
sender.distanceTo(captive) <= INTERACTION_RANGE &&
captive.distanceTo(furniture) <= INTERACTION_RANGE;
boolean captiveHasOwnedCollar = captiveHasCollarOwnedBy(
captive,
sender
);
boolean captiveIsTiedUp = captiveIsLeashed(captive);
boolean captiveIsNotPassenger =
!captive.isPassenger() || captive.getVehicle() == furniture;
boolean authorized = isAuthorizedForForceMount(
captiveAlive,
captiveWithinRange,
captiveHasOwnedCollar,
captiveIsTiedUp,
captiveIsNotPassenger
);
if (!authorized) {
debug(
"force-mount denied: sender={}, captive={}, alive={}, range={}, ownedCollar={}, tiedUp={}, notPassenger={}",
sender.getName().getString(),
captive.getName().getString(),
captiveAlive,
captiveWithinRange,
captiveHasOwnedCollar,
captiveIsTiedUp,
captiveIsNotPassenger
);
}
return authorized;
}
// ============================================================
// Extraction helpers (isolate MC-API touch points)
// ============================================================
private static boolean holdsMasterKey(Player sender) {
return (
sender.getMainHandItem().getItem() instanceof ItemMasterKey ||
sender.getOffhandItem().getItem() instanceof ItemMasterKey
);
}
private static boolean seatIsLockable(ISeatProvider provider, String seatId) {
for (SeatDefinition seat : provider.getSeats()) {
if (seat.id().equals(seatId)) {
return seat.lockable();
}
}
return false;
}
/**
* Find the passenger currently assigned to the given seat. Returns null if
* the seat is unoccupied or the occupant is not a LivingEntity.
*/
@Nullable
private static LivingEntity findOccupant(Entity furniture, String seatId) {
if (!(furniture instanceof EntityFurniture ef)) return null;
for (Entity passenger : ef.getPassengers()) {
SeatDefinition assigned = ef.getSeatForPassenger(passenger);
if (
assigned != null &&
assigned.id().equals(seatId) &&
passenger instanceof LivingEntity living
) {
return living;
}
}
return null;
}
/**
* True when {@code occupant} wears a collar and either the sender owns it
* or the sender has OP permissions.
*/
private static boolean occupantHasOwnedCollarFor(
@Nullable LivingEntity occupant,
ServerPlayer sender
) {
if (occupant == null) return sender.hasPermissions(2);
return captiveHasCollarOwnedBy(occupant, sender);
}
/**
* True when {@code captive} has a V2 collar in the NECK slot AND either
* the sender owns it or the sender has OP permissions.
*/
private static boolean captiveHasCollarOwnedBy(
LivingEntity captive,
ServerPlayer sender
) {
IBondageState state = resolveBondageState(captive);
if (state == null || !state.hasCollar()) {
return sender.hasPermissions(2);
}
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
if (collar.isEmpty() || !CollarHelper.isCollar(collar)) {
return sender.hasPermissions(2);
}
return (
CollarHelper.isOwner(collar, sender) || sender.hasPermissions(2)
);
}
/**
* Resolve an {@link IBondageState} for any entity that can wear bondage —
* player, MCA villager, damsel, etc. Delegates to {@link KidnappedHelper}
* which handles the multi-type dispatch.
*/
@Nullable
private static IBondageState resolveBondageState(LivingEntity entity) {
if (entity instanceof Player player) {
return com.tiedup.remake.state.PlayerBindState.getInstance(player);
}
return KidnappedHelper.getKidnappedState(entity);
}
/**
* True when the entity is currently leashed/tied-up (not just wearing
* restraints — actively held by a captor).
*/
private static boolean captiveIsLeashed(LivingEntity captive) {
IBondageState state = resolveBondageState(captive);
return state != null && state.isTiedUp();
}
private static void debug(String format, Object... args) {
TiedUpMod.LOGGER.debug("[FurnitureAuth] " + format, args);
}
}

View File

@@ -300,9 +300,17 @@ public final class FurnitureParser {
);
return null;
}
if (seatId.contains(":")) {
// Reject separators used by SEAT_ASSIGNMENTS_SYNC encoding
// ("uuid;seat|uuid;seat|…") — a seat id containing | or ; would
// corrupt the client map on parse. `:` is reserved for the
// ResourceLocation separator if this id is ever promoted to one.
if (
seatId.contains(":") ||
seatId.contains("|") ||
seatId.contains(";")
) {
LOGGER.error(
"{} Skipping {}: seats[{}] id '{}' must not contain ':'",
"{} Skipping {}: seats[{}] id '{}' must not contain ':', '|', or ';'",
TAG,
fileId,
index,

View File

@@ -35,6 +35,14 @@ public final class FurnitureRegistry {
FurnitureDefinition
> DEFINITIONS = Map.of();
/**
* Monotonically increasing revision counter, incremented on every
* {@link #reload}. Client-side caches (e.g.,
* {@code DataDrivenIconOverrides} in {@code FURNITURE_PLACER} mode)
* observe this to invalidate themselves when the registry changes.
*/
private static volatile int revision = 0;
private FurnitureRegistry() {}
/**
@@ -48,6 +56,16 @@ public final class FurnitureRegistry {
Map<ResourceLocation, FurnitureDefinition> newDefs
) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
revision++;
}
/**
* Current registry revision — incremented on every {@link #reload}.
* Client-side caches compare their stored revision against this to detect
* staleness across {@code /reload} cycles or sync-packet updates.
*/
public static int getRevision() {
return revision;
}
/**

View File

@@ -0,0 +1,51 @@
package com.tiedup.remake.v2.furniture;
import net.minecraft.world.phys.Vec3;
/**
* Side-neutral math helpers for the server-side fallback seat geometry used
* when GLB-baked {@code SeatTransform} data is unavailable.
*
* <p>Seats are placed evenly along the furniture entity's local right axis:
* for {@code n} seats the offsets are {@code -(n-1)/2, -(n-1)/2 + 1, …, (n-1)/2}
* (so 1 seat → center; 3 seats → -1, 0, 1). The right axis is derived from
* the entity's yaw.</p>
*
* <p>Previously inlined at three {@code EntityFurniture} sites (positionRider,
* findNearestAvailableSeat, findNearestOccupiedLockableSeat) with identical
* formulas. Centralised here so any future tweak (e.g. per-definition seat
* spacing) updates one place.</p>
*/
public final class FurnitureSeatGeometry {
private FurnitureSeatGeometry() {}
/**
* Entity-local right axis in world XZ coordinates for the given yaw.
* Y component is always 0.
*
* @param yawDegrees entity yaw in degrees (as {@code Entity.getYRot()})
* @return unit vector pointing to the entity's right in world space
*/
public static Vec3 rightAxis(float yawDegrees) {
double yawRad = Math.toRadians(yawDegrees);
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
return new Vec3(rightX, 0.0, rightZ);
}
/**
* Local offset along the right axis for {@code seatIndex} among
* {@code seatCount} evenly-spaced seats.
*
* <p>Examples: 1 seat → 0.0 (centered). 2 seats → -0.5, 0.5. 3 seats →
* -1.0, 0.0, 1.0.</p>
*
* @param seatIndex zero-based seat index
* @param seatCount total seats on the furniture (must be ≥ 1)
* @return signed distance from centre along the right axis
*/
public static double seatOffset(int seatIndex, int seatCount) {
return seatCount == 1 ? 0.0 : (seatIndex - (seatCount - 1) / 2.0);
}
}

View File

@@ -21,6 +21,15 @@ public interface ISeatProvider {
@Nullable
SeatDefinition getSeatForPassenger(Entity passenger);
/**
* Find the passenger entity currently assigned to {@code seatId}, or
* null if the seat is unoccupied. Used by reconnection / force-mount
* paths to refuse a seat-takeover that would orphan an existing
* occupant.
*/
@Nullable
Entity findPassengerInSeat(String seatId);
/** Assign a passenger to a specific seat. */
void assignSeat(Entity passenger, String seatId);

View File

@@ -15,11 +15,9 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
@@ -57,47 +55,30 @@ public final class FurnitureGlbParser {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private static final String PLAYER_PREFIX = "Player_";
private FurnitureGlbParser() {}
/**
* Parse a multi-armature .glb file into a {@link FurnitureGltfData}.
* Parse a multi-armature .glb file into a {@link FurnitureGltfData}. Validates
* header, version, and total length (capped at {@link GlbParserUtils#MAX_GLB_SIZE})
* before allocating chunk buffers.
*
* @param input the input stream (read fully, not closed by this method)
* @param debugName human-readable name for log messages
* @return parsed furniture data
* @throws IOException if the file is malformed or I/O fails
* @throws IOException if the file is malformed, oversized, or truncated
*/
public static FurnitureGltfData parse(InputStream input, String debugName)
throws IOException {
byte[] allBytes = input.readAllBytes();
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
ByteOrder.LITTLE_ENDIAN
);
// ---- GLB header ----
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
buf.getInt(); // total file length — not needed, we already have all bytes
ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);
// ---- JSON chunk ----
int jsonChunkLength = buf.getInt();
int jsonChunkLength = GlbParserUtils.readChunkLength(
buf,
"JSON",
debugName
);
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
if (jsonChunkType != GlbParserUtils.CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName);
}
byte[] jsonBytes = new byte[jsonChunkLength];
@@ -108,9 +89,13 @@ public final class FurnitureGlbParser {
// ---- BIN chunk ----
ByteBuffer binData = null;
if (buf.hasRemaining()) {
int binChunkLength = buf.getInt();
int binChunkLength = GlbParserUtils.readChunkLength(
buf,
"BIN",
debugName
);
int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) {
if (binChunkType != GlbParserUtils.CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName);
}
byte[] binBytes = new byte[binChunkLength];
@@ -127,138 +112,25 @@ public final class FurnitureGlbParser {
JsonArray meshes = root.getAsJsonArray("meshes");
JsonArray skins = root.getAsJsonArray("skins");
// ---- Identify Player_* armature root nodes ----
// A "Player_*" armature root is any node whose name starts with "Player_".
// Its name suffix is the seat ID.
Map<String, Integer> seatIdToRootNode = new LinkedHashMap<>(); // seatId -> node index
Set<Integer> playerRootNodes = new HashSet<>();
// ---- Identify Player_* armature roots, classify skins, extract seat transforms ----
// Delegated to PlayerArmatureScanner (extracted 2026-04-18) to keep this
// orchestration method small. Behavior preserved byte-for-byte.
PlayerArmatureScanner.ArmatureScan scan =
PlayerArmatureScanner.scan(nodes);
Map<String, Integer> seatIdToRootNode = scan.seatIdToRootNode;
if (nodes != null) {
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
String name = node.has("name")
? node.get("name").getAsString()
: "";
if (
name.startsWith(PLAYER_PREFIX) &&
name.length() > PLAYER_PREFIX.length()
) {
String seatId = name.substring(PLAYER_PREFIX.length());
seatIdToRootNode.put(seatId, ni);
playerRootNodes.add(ni);
LOGGER.debug(
"[FurnitureGltf] Found Player armature: '{}' -> seat '{}'",
name,
seatId
);
}
}
}
PlayerArmatureScanner.SkinClassification classification =
PlayerArmatureScanner.classifySkins(
skins,
nodes,
scan,
debugName
);
int furnitureSkinIdx = classification.furnitureSkinIdx;
Map<String, Integer> seatIdToSkinIdx = classification.seatIdToSkinIdx;
// ---- Classify skins ----
// For each skin, check if its skeleton node (or the first joint's parent) is a Player_* root.
// The "skeleton" field in a glTF skin points to the root bone node of that armature.
int furnitureSkinIdx = -1;
Map<String, Integer> seatIdToSkinIdx = new LinkedHashMap<>(); // seatId -> skin index
if (skins != null) {
for (int si = 0; si < skins.size(); si++) {
JsonObject skin = skins.get(si).getAsJsonObject();
int skeletonNode = skin.has("skeleton")
? skin.get("skeleton").getAsInt()
: -1;
// Check if the skeleton root node is a Player_* armature
String matchedSeatId = null;
if (
skeletonNode >= 0 && playerRootNodes.contains(skeletonNode)
) {
// Direct match: skeleton field points to a Player_* node
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (entry.getValue() == skeletonNode) {
matchedSeatId = entry.getKey();
break;
}
}
}
// Fallback: check if any joint in this skin is a child of a Player_* root
if (matchedSeatId == null && skin.has("joints")) {
matchedSeatId = matchSkinToPlayerArmature(
skin.getAsJsonArray("joints"),
nodes,
seatIdToRootNode
);
}
if (matchedSeatId != null) {
seatIdToSkinIdx.put(matchedSeatId, si);
LOGGER.debug(
"[FurnitureGltf] Skin {} -> seat '{}'",
si,
matchedSeatId
);
} else if (furnitureSkinIdx < 0) {
furnitureSkinIdx = si;
LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si);
} else {
LOGGER.warn(
"[FurnitureGltf] Extra non-Player skin {} ignored in '{}'",
si,
debugName
);
}
}
}
// ---- Extract seat transforms from Player_* root nodes ----
Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
new LinkedHashMap<>();
for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
String seatId = entry.getKey();
int nodeIdx = entry.getValue();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
Vector3f position = new Vector3f();
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
position.set(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
}
Quaternionf rotation = new Quaternionf();
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
rotation.set(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
}
seatTransforms.put(
seatId,
new FurnitureGltfData.SeatTransform(seatId, position, rotation)
);
LOGGER.debug(
"[FurnitureGltf] Seat '{}' transform: pos=({},{},{}), rot=({},{},{},{})",
seatId,
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
rotation.w
);
}
PlayerArmatureScanner.extractTransforms(nodes, seatIdToRootNode);
// ---- Parse furniture mesh (full GltfData from the furniture skin) ----
GltfData furnitureMesh = parseFurnitureSkin(
@@ -551,50 +423,6 @@ public final class FurnitureGlbParser {
);
}
// Skin classification helpers
/**
* Try to match a skin's joints to a Player_* armature by checking whether
* any joint node is a descendant of a Player_* root node.
*/
@Nullable
private static String matchSkinToPlayerArmature(
JsonArray skinJoints,
JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
for (JsonElement jointElem : skinJoints) {
int jointNodeIdx = jointElem.getAsInt();
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) {
return entry.getKey();
}
}
}
return null;
}
/**
* Check if nodeIdx is a descendant of ancestorIdx via the node children hierarchy.
* Also returns true if nodeIdx == ancestorIdx.
*/
private static boolean isDescendantOf(
int nodeIdx,
int ancestorIdx,
JsonArray nodes
) {
if (nodeIdx == ancestorIdx) return true;
JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject();
if (!ancestor.has("children")) return false;
for (JsonElement child : ancestor.getAsJsonArray("children")) {
if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true;
}
return false;
}
/**
* Detect the name of the furniture armature's skeleton root node.
*/
@@ -655,7 +483,6 @@ public final class FurnitureGlbParser {
// ---- Parse ALL joints from this skin (no bone filtering for furniture) ----
int jointCount = skinJoints.size();
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -670,56 +497,26 @@ public final class FurnitureGlbParser {
nodeToJoint[nodeIdx] = j;
}
// Read joint names and rest poses
java.util.Arrays.fill(parentJointIndices, -1);
// Strip the Blender armature prefix (e.g. "Armature|root" → "root")
// so names line up with parseSeatSkeleton and GlbParser.
for (int j = 0; j < jointCount; j++) {
int nodeIdx = jointNodeIndices.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
jointNames[j] = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawName);
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf();
}
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
restRotations[j] = GlbParserUtils.readRestRotation(node);
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
for (JsonElement child : node.getAsJsonArray("children")) {
int childNodeIdx = child.getAsInt();
if (childNodeIdx < nodeToJoint.length) {
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
}
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
jointCount
);
// ---- Inverse bind matrices ----
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
@@ -752,7 +549,7 @@ public final class FurnitureGlbParser {
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!meshName.startsWith("Player")) {
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
}
}
@@ -773,135 +570,25 @@ public final class FurnitureGlbParser {
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
List<int[]> allJoints = new ArrayList<>();
List<float[]> allWeights = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = GlbParserUtils.readFloatAccessor(
GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
mesh,
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
jointCount,
/* readSkinning */true,
materialNames,
debugName
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// Skinning (no remap needed -- furniture keeps all joints)
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// Material / tint channel
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
positions = r.positions;
normals = r.normals;
texCoords = r.texCoords;
indices = r.indices;
meshJoints = r.joints;
weights = r.weights;
vertexCount = r.vertexCount;
parsedPrimitives.addAll(r.primitives);
} else {
LOGGER.info(
"[FurnitureGltf] No furniture mesh found in '{}'",
@@ -923,13 +610,12 @@ public final class FurnitureGlbParser {
}
// ---- Convert to Minecraft space ----
convertToMinecraftSpace(
GlbParserUtils.convertMeshToMinecraftSpace(
positions,
normals,
restTranslations,
restRotations,
inverseBindMatrices,
jointCount
inverseBindMatrices
);
return new GltfData(
@@ -979,7 +665,7 @@ public final class FurnitureGlbParser {
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!meshName.startsWith("Player")) {
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
break;
}
@@ -994,108 +680,26 @@ public final class FurnitureGlbParser {
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = GlbParserUtils.readFloatAccessor(
GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
mesh,
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
/* jointCount */0,
/* readSkinning */false,
materialNames,
debugName
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
positions = r.positions;
normals = r.normals;
texCoords = r.texCoords;
indices = r.indices;
vertexCount = r.vertexCount;
parsedPrimitives.addAll(r.primitives);
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] =
i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
isTintable ? matName : null
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
cumulativeVertexCount += primVertexCount;
}
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
// Convert positions and normals to MC space
// Mesh-only path does not run full convertMeshToMinecraftSpace
// (no rest poses or IBMs to transform), just the vertex-space flip.
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
@@ -1213,13 +817,10 @@ public final class FurnitureGlbParser {
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix (e.g., "MyRig|body" -> "body")
if (name.contains("|")) {
name = name.substring(name.lastIndexOf('|') + 1);
}
String name = GlbParserUtils.stripArmaturePrefix(rawName);
if (GltfBoneMapper.isKnownBone(name)) {
skinJointRemap[j] = filteredJointNodes.size();
filteredJointNodes.add(nodeIdx);
@@ -1243,7 +844,6 @@ public final class FurnitureGlbParser {
}
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -1256,7 +856,6 @@ public final class FurnitureGlbParser {
}
// Read joint names and rest poses
java.util.Arrays.fill(parentJointIndices, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
@@ -1264,51 +863,17 @@ public final class FurnitureGlbParser {
String rawBoneName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix consistently
jointNames[j] = rawBoneName.contains("|")
? rawBoneName.substring(rawBoneName.lastIndexOf('|') + 1)
: rawBoneName;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawBoneName);
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf();
}
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
restRotations[j] = GlbParserUtils.readRestRotation(node);
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
for (JsonElement child : node.getAsJsonArray("children")) {
int childNodeIdx = child.getAsInt();
if (childNodeIdx < nodeToJoint.length) {
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
}
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
nodes,
nodeToJoint,
jointCount
);
// ---- Inverse bind matrices ----
// IBM accessor is indexed by original skin joint order, pick filtered entries
@@ -1344,13 +909,12 @@ public final class FurnitureGlbParser {
// Empty arrays for positions/normals (skeleton-only, no mesh)
float[] emptyPositions = new float[0];
float[] emptyNormals = new float[0];
convertToMinecraftSpace(
GlbParserUtils.convertMeshToMinecraftSpace(
emptyPositions,
emptyNormals,
restTranslations,
restRotations,
inverseBindMatrices,
jointCount
inverseBindMatrices
);
LOGGER.debug(
@@ -1437,13 +1001,10 @@ public final class FurnitureGlbParser {
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name")
String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
// Strip armature prefix (e.g., "MyRig|body" -> "body")
if (name.contains("|")) {
name = name.substring(name.lastIndexOf('|') + 1);
}
String name = GlbParserUtils.stripArmaturePrefix(rawName);
if (GltfBoneMapper.isKnownBone(name)) {
filteredJointNodes.add(nodeIdx);
}
@@ -1495,7 +1056,7 @@ public final class FurnitureGlbParser {
}
// Parse with the filtered joint mapping
GltfData.AnimationClip clip = parseAnimationWithMapping(
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
anim,
accessors,
bufferViews,
@@ -1686,8 +1247,8 @@ public final class FurnitureGlbParser {
nodeToJoint[nodeIdx] = j;
}
// Delegate to the standard animation parsing logic
return parseAnimationWithMapping(
// Delegate to the shared animation parsing logic
return GlbParserUtils.parseAnimation(
animation,
accessors,
bufferViews,
@@ -1697,170 +1258,4 @@ public final class FurnitureGlbParser {
);
}
/**
* Parse an animation clip using a provided node-to-joint index mapping.
* Mirrors GlbParser.parseAnimation exactly.
*/
@Nullable
private static GltfData.AnimationClip parseAnimationWithMapping(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
if (!target.has("node")) continue;
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (
nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0
) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = GlbParserUtils.readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// Coordinate conversion
/**
* Convert spatial data from glTF space to Minecraft model-def space.
* Same transform as GlbParser: 180 degrees around Z, negating X and Y.
*/
private static void convertToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180 deg Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
}
}

View File

@@ -2,6 +2,7 @@ package com.tiedup.remake.v2.furniture.client;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
@@ -18,6 +19,13 @@ import org.jetbrains.annotations.Nullable;
* <p>Loads .glb files via Minecraft's ResourceManager on first access and parses them
* with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}.
*
* <p>Cache values are {@link Optional}: empty means a previous load/parse failed
* and no retry should be attempted until {@link #clear()} is called. Previously
* a private {@code FAILED_SENTINEL} instance of {@code FurnitureGltfData} with
* a {@code null} furniture mesh was used for this purpose; identity-equality on
* that sentinel was brittle and let future callers observe a half-constructed
* data object.</p>
*
* <p>Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries.
*
* <p>This class is client-only and must never be referenced from server code.
@@ -27,15 +35,10 @@ public final class FurnitureGltfCache {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
/**
* Sentinel value stored in the cache when loading fails, to avoid retrying
* broken resources on every frame.
*/
private static final FurnitureGltfData FAILED_SENTINEL =
new FurnitureGltfData(null, Map.of(), Map.of(), Map.of());
private static final Map<ResourceLocation, FurnitureGltfData> CACHE =
new ConcurrentHashMap<>();
private static final Map<
ResourceLocation,
Optional<FurnitureGltfData>
> CACHE = new ConcurrentHashMap<>();
private FurnitureGltfCache() {}
@@ -45,22 +48,22 @@ public final class FurnitureGltfCache {
* @param modelLocation resource location of the .glb file
* (e.g., {@code tiedup:models/furniture/wooden_stocks.glb})
* @return parsed {@link FurnitureGltfData}, or {@code null} if loading/parsing failed
* (persistent until {@link #clear()} is called)
*/
@Nullable
public static FurnitureGltfData get(ResourceLocation modelLocation) {
FurnitureGltfData cached = CACHE.computeIfAbsent(
return CACHE.computeIfAbsent(
modelLocation,
FurnitureGltfCache::load
);
return cached == FAILED_SENTINEL ? null : cached;
).orElse(null);
}
/**
* Load and parse a furniture GLB from the resource manager.
*
* @return parsed data, or the {@link #FAILED_SENTINEL} on failure
* Load and parse a furniture GLB from the resource manager. Returns an
* empty {@link Optional} on any failure — the failure is cached so the
* parser isn't invoked again for the same resource until {@link #clear()}.
*/
private static FurnitureGltfData load(ResourceLocation loc) {
private static Optional<FurnitureGltfData> load(ResourceLocation loc) {
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
@@ -68,7 +71,7 @@ public final class FurnitureGltfCache {
.orElse(null);
if (resource == null) {
LOGGER.error("[FurnitureGltf] Resource not found: {}", loc);
return FAILED_SENTINEL;
return Optional.empty();
}
try (InputStream is = resource.open()) {
@@ -77,7 +80,7 @@ public final class FurnitureGltfCache {
loc.toString()
);
LOGGER.debug("[FurnitureGltf] Cached: {}", loc);
return data;
return Optional.of(data);
}
} catch (Exception e) {
LOGGER.error(
@@ -85,7 +88,7 @@ public final class FurnitureGltfCache {
loc,
e
);
return FAILED_SENTINEL;
return Optional.empty();
}
}

View File

@@ -44,9 +44,14 @@ public record FurnitureGltfData(
* Root transform of a Player_* armature, defining where a seated player is
* positioned and oriented relative to the furniture origin.
*
* <p>Values are in Minecraft model-def space: X/Y negated from raw glTF
* space to match the 180°-around-Z rotation applied to the furniture mesh
* by {@link com.tiedup.remake.client.gltf.GlbParserUtils#convertMeshToMinecraftSpace}.
* The conversion happens in {@code PlayerArmatureScanner.extractTransforms}.</p>
*
* @param seatId seat identifier (e.g., "main", "left")
* @param position translation offset in glTF space (meters, Y-up)
* @param rotation orientation quaternion in glTF space
* @param position translation offset in MC model-def space (meters, Y-up, X/Y negated from glTF)
* @param rotation orientation quaternion in MC model-def space (qx/qy negated from glTF)
*/
public record SeatTransform(
String seatId,

View File

@@ -0,0 +1,279 @@
package com.tiedup.remake.v2.furniture.client;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.tiedup.remake.client.gltf.GlbParserUtils;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* First pass of {@link FurnitureGlbParser}: discover {@code Player_*} armature
* roots, classify skins (furniture vs. per-seat), and extract per-seat root
* transforms.
*
* <p>Extracted from {@code FurnitureGlbParser.parse} to limit the god-class
* size and keep the scan/classify/extract logic testable in isolation.
* Pure-functional — no state on the class, every method returns a fresh
* result.</p>
*/
@OnlyIn(Dist.CLIENT)
final class PlayerArmatureScanner {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
private static final String PLAYER_PREFIX = "Player_";
private PlayerArmatureScanner() {}
/**
* Result of {@link #scan}: maps seat ID → node index for every
* {@code Player_<seatId>} root node, plus the set of root node indices
* (used later to classify skins).
*/
static final class ArmatureScan {
final Map<String, Integer> seatIdToRootNode;
final Set<Integer> playerRootNodes;
ArmatureScan(
Map<String, Integer> seatIdToRootNode,
Set<Integer> playerRootNodes
) {
this.seatIdToRootNode = seatIdToRootNode;
this.playerRootNodes = playerRootNodes;
}
}
/**
* Walk every node, match names against {@code "Player_<seatId>"}, and
* return the (seatId → nodeIdx) map plus the set of those node indices.
* A node named exactly {@code "Player_"} (empty seat ID) is skipped.
*/
static ArmatureScan scan(@Nullable JsonArray nodes) {
Map<String, Integer> seatIdToRootNode = new LinkedHashMap<>();
Set<Integer> playerRootNodes = new HashSet<>();
if (nodes == null) return new ArmatureScan(
seatIdToRootNode,
playerRootNodes
);
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
String name = node.has("name")
? node.get("name").getAsString()
: "";
if (
name.startsWith(PLAYER_PREFIX) &&
name.length() > PLAYER_PREFIX.length()
) {
String seatId = name.substring(PLAYER_PREFIX.length());
seatIdToRootNode.put(seatId, ni);
playerRootNodes.add(ni);
LOGGER.debug(
"[FurnitureGltf] Found Player armature: '{}' -> seat '{}'",
name,
seatId
);
}
}
return new ArmatureScan(seatIdToRootNode, playerRootNodes);
}
/**
* Result of {@link #classifySkins}: the first non-seat skin index (the
* "furniture" skin, or -1 if none) and a {@code seatId → skinIdx} map.
*/
static final class SkinClassification {
final int furnitureSkinIdx;
final Map<String, Integer> seatIdToSkinIdx;
SkinClassification(
int furnitureSkinIdx,
Map<String, Integer> seatIdToSkinIdx
) {
this.furnitureSkinIdx = furnitureSkinIdx;
this.seatIdToSkinIdx = seatIdToSkinIdx;
}
}
/**
* Classify each skin as either "furniture" or "seat N":
* <ol>
* <li>If the skin's {@code skeleton} field points at a known Player_*
* root → it's that seat.</li>
* <li>Fallback: if any joint in the skin is a descendant of a Player_*
* root → that seat.</li>
* <li>Otherwise: the first such skin is the "furniture" skin.</li>
* </ol>
* Extra non-matching skins are logged and ignored.
*/
static SkinClassification classifySkins(
@Nullable JsonArray skins,
@Nullable JsonArray nodes,
ArmatureScan scan,
String debugName
) {
int furnitureSkinIdx = -1;
Map<String, Integer> seatIdToSkinIdx = new LinkedHashMap<>();
if (skins == null || nodes == null) return new SkinClassification(
furnitureSkinIdx,
seatIdToSkinIdx
);
for (int si = 0; si < skins.size(); si++) {
JsonObject skin = skins.get(si).getAsJsonObject();
int skeletonNode = skin.has("skeleton")
? skin.get("skeleton").getAsInt()
: -1;
String matchedSeatId = null;
if (
skeletonNode >= 0 &&
scan.playerRootNodes.contains(skeletonNode)
) {
for (Map.Entry<
String,
Integer
> entry : scan.seatIdToRootNode.entrySet()) {
if (entry.getValue() == skeletonNode) {
matchedSeatId = entry.getKey();
break;
}
}
}
if (matchedSeatId == null && skin.has("joints")) {
matchedSeatId = matchSkinToPlayerArmature(
skin.getAsJsonArray("joints"),
nodes,
scan.seatIdToRootNode
);
}
if (matchedSeatId != null) {
seatIdToSkinIdx.put(matchedSeatId, si);
LOGGER.debug(
"[FurnitureGltf] Skin {} -> seat '{}'",
si,
matchedSeatId
);
} else if (furnitureSkinIdx < 0) {
furnitureSkinIdx = si;
LOGGER.debug("[FurnitureGltf] Skin {} -> furniture", si);
} else {
LOGGER.warn(
"[FurnitureGltf] Extra non-Player skin {} ignored in '{}'",
si,
debugName
);
}
}
return new SkinClassification(furnitureSkinIdx, seatIdToSkinIdx);
}
/**
* Build per-seat root transforms from the {@code Player_*} node rest pose.
* Position and rotation are read from the node's {@code translation} /
* {@code rotation} fields (identity defaults if absent) and then converted
* from raw glTF space to Minecraft model-def space by negating X/Y on the
* translation and qx/qy on the quaternion — matching the 180°-around-Z
* rotation applied to the furniture mesh by
* {@link com.tiedup.remake.client.gltf.GlbParserUtils#convertMeshToMinecraftSpace}.
*/
static Map<String, FurnitureGltfData.SeatTransform> extractTransforms(
@Nullable JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
Map<String, FurnitureGltfData.SeatTransform> seatTransforms =
new LinkedHashMap<>();
if (nodes == null) return seatTransforms;
for (Map.Entry<String, Integer> entry : seatIdToRootNode.entrySet()) {
String seatId = entry.getKey();
int nodeIdx = entry.getValue();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
Vector3f position = GlbParserUtils.readRestTranslation(node);
Quaternionf rotation = GlbParserUtils.readRestRotation(node);
// glTF space → MC model-def space (180° around Z), matching the
// same transform GlbParserUtils.convertMeshToMinecraftSpace applies
// to the furniture mesh. Must stay consistent with the mesh or the
// passenger renders on the opposite side of asymmetric furniture.
position.x = -position.x;
position.y = -position.y;
rotation.x = -rotation.x;
rotation.y = -rotation.y;
seatTransforms.put(
seatId,
new FurnitureGltfData.SeatTransform(seatId, position, rotation)
);
LOGGER.debug(
"[FurnitureGltf] Seat '{}' transform (MC space): pos=({},{},{}), rot=({},{},{},{})",
seatId,
position.x,
position.y,
position.z,
rotation.x,
rotation.y,
rotation.z,
rotation.w
);
}
return seatTransforms;
}
/**
* Walk a skin's joint list to see if any joint is a descendant of one of
* the {@code Player_*} root nodes. Used as the fallback classification
* when the skin's {@code skeleton} field isn't set.
*/
@Nullable
private static String matchSkinToPlayerArmature(
JsonArray skinJoints,
JsonArray nodes,
Map<String, Integer> seatIdToRootNode
) {
for (JsonElement jointElem : skinJoints) {
int jointNodeIdx = jointElem.getAsInt();
for (Map.Entry<
String,
Integer
> entry : seatIdToRootNode.entrySet()) {
if (isDescendantOf(jointNodeIdx, entry.getValue(), nodes)) {
return entry.getKey();
}
}
}
return null;
}
/**
* Check if {@code nodeIdx} is {@code ancestorIdx} itself or any descendant
* of {@code ancestorIdx} via the node children hierarchy.
*/
private static boolean isDescendantOf(
int nodeIdx,
int ancestorIdx,
JsonArray nodes
) {
if (nodeIdx == ancestorIdx) return true;
JsonObject ancestor = nodes.get(ancestorIdx).getAsJsonObject();
if (!ancestor.has("children")) return false;
for (JsonElement child : ancestor.getAsJsonArray("children")) {
if (isDescendantOf(nodeIdx, child.getAsInt(), nodes)) return true;
}
return false;
}
}

View File

@@ -166,12 +166,15 @@ public class PacketFurnitureEscape {
return;
}
// Compute difficulty
// Compute difficulty. Clamp to [0, MAX] so a misconfigured
// IV2BondageItem returning a negative bonus can't underflow the
// instant-escape check below and slip into the minigame with a
// negative resistance state.
int baseDifficulty = provider.getLockedDifficulty(seat.id());
int itemBonus = computeItemDifficultyBonus(sender, provider, seat);
int totalDifficulty = Math.min(
baseDifficulty + itemBonus,
MAX_DIFFICULTY
int totalDifficulty = Math.max(
0,
Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY)
);
TiedUpMod.LOGGER.debug(
@@ -306,9 +309,13 @@ public class PacketFurnitureEscape {
targetSeat
);
}
int totalDifficulty = Math.min(
baseDifficulty + itemBonus,
MAX_DIFFICULTY
// Clamp to [0, MAX] — same rationale as handleStruggle: a misconfigured
// item returning negative getEscapeDifficulty could push totalDifficulty
// below 0, skipping the == 0 instant-success branch below and starting
// a minigame with negative difficulty (sweet spot wider than intended).
int totalDifficulty = Math.max(
0,
Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY)
);
TiedUpMod.LOGGER.debug(
@@ -387,6 +394,10 @@ public class PacketFurnitureEscape {
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
ctx.putInt("furniture_id", furnitureEntity.getId());
ctx.putString("seat_id", targetSeat.id());
// Nonce: the handler accepts this ctx only when session_id matches
// the active session. Prevents stale ctx from mis-routing a later
// body-item lockpick.
ctx.putUUID("session_id", session.getSessionId());
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
// Send initial lockpick state to open the minigame GUI on the client
@@ -530,11 +541,9 @@ public class PacketFurnitureEscape {
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(furnitureEntity.getYRot());
// Entity-local right axis (perpendicular to facing in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
Vec3 right = com.tiedup.remake.v2.furniture.FurnitureSeatGeometry.rightAxis(
furnitureEntity.getYRot()
);
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
@@ -556,11 +565,13 @@ public class PacketFurnitureEscape {
if (!hasPassenger) continue;
// Approximate seat world position along the right axis
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
double offset = com.tiedup.remake.v2.furniture.FurnitureSeatGeometry.seatOffset(
i, seatCount
);
Vec3 seatWorldPos = new Vec3(
furnitureEntity.getX() + rightX * offset,
furnitureEntity.getX() + right.x * offset,
furnitureEntity.getY() + 0.5,
furnitureEntity.getZ() + rightZ * offset
furnitureEntity.getZ() + right.z * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);

View File

@@ -98,54 +98,16 @@ public class PacketFurnitureForcemount {
return;
}
// Captive must be alive
if (!captive.isAlive() || captive.isRemoved()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive is not alive: {}",
captive.getName().getString()
);
return;
}
// Captive must be within 5 blocks of both sender and furniture
if (sender.distanceTo(captive) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from sender"
);
return;
}
if (captive.distanceTo(entity) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from furniture"
);
return;
}
// Verify collar ownership: captive must have a collar owned by sender
IBondageState captiveState = KidnappedHelper.getKidnappedState(captive);
if (captiveState == null || !captiveState.hasCollar()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive has no collar: {}",
captive.getName().getString()
);
return;
}
ItemStack collarStack = captiveState.getEquipment(BodyRegionV2.NECK);
if (!CollarHelper.isCollar(collarStack)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Invalid collar item on captive"
);
return;
}
// Collar must be owned by sender (or sender has admin permission)
if (!CollarHelper.isOwner(collarStack, sender) && !sender.hasPermissions(2)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} is not the collar owner of {}",
sender.getName().getString(),
captive.getName().getString()
);
// Unified authorization: range, liveness, collar ownership, leashed
// status, not-already-passenger. Shared with EntityFurniture.interact()
// via FurnitureAuthPredicate to prevent security drift between paths.
if (
!com.tiedup.remake.v2.furniture.FurnitureAuthPredicate.canForceMount(
sender,
entity,
captive
)
) {
return;
}

View File

@@ -77,47 +77,23 @@ public class PacketFurnitureLock {
Entity entity = sender.level().getEntity(msg.entityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Sender must hold a key item in either hand
boolean hasKey =
(sender.getMainHandItem().getItem() instanceof ItemMasterKey) ||
(sender.getOffhandItem().getItem() instanceof ItemMasterKey);
if (!hasKey) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} does not hold a key item in either hand",
sender.getName().getString()
);
// Unified authorization: range, master key, seat state, collar ownership.
// Shared with EntityFurniture.interact() via FurnitureAuthPredicate to
// prevent the security drift found in the 2026-04-17 audit.
if (
!com.tiedup.remake.v2.furniture.FurnitureAuthPredicate.canLockUnlock(
sender,
entity,
msg.seatId
)
) {
return;
}
// Validate the seat exists and is lockable
// Retrieve seat metadata for the downstream feedback/animation logic
SeatDefinition seat = findSeatById(provider, msg.seatId);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' not found on entity {}",
msg.seatId,
msg.entityId
);
return;
}
if (!seat.lockable()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not lockable",
msg.seatId
);
return;
}
// Seat must be occupied (someone sitting in it)
if (!isSeatOccupied(provider, entity, msg.seatId)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not occupied",
msg.seatId
);
return;
}
if (seat == null) return;
// Toggle the lock state
boolean wasLocked = provider.isSeatLocked(msg.seatId);
@@ -158,13 +134,17 @@ public class PacketFurnitureLock {
}
}
// Set lock/unlock animation state. The next updateAnimState() call
// (from tick or passenger change) will reset it to OCCUPIED/IDLE.
// Use setTransitionState (not setAnimState) so the LOCKING /
// UNLOCKING pose survives past one tick — otherwise the tick-
// decrement path in EntityFurniture reverts to transitionTarget
// before any client observes the transient state.
boolean nowLocked = !wasLocked;
furniture.setAnimState(
furniture.setTransitionState(
nowLocked
? EntityFurniture.STATE_LOCKING
: EntityFurniture.STATE_UNLOCKING
: EntityFurniture.STATE_UNLOCKING,
20,
EntityFurniture.STATE_OCCUPIED
);
// Broadcast updated state to all tracking clients