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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user