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.
596 lines
18 KiB
Java
596 lines
18 KiB
Java
package com.tiedup.remake.v2.furniture;
|
|
|
|
import com.google.gson.JsonArray;
|
|
import com.google.gson.JsonElement;
|
|
import com.google.gson.JsonObject;
|
|
import com.google.gson.JsonParser;
|
|
import com.tiedup.remake.v2.BodyRegionV2;
|
|
import java.io.InputStream;
|
|
import java.io.InputStreamReader;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.EnumSet;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.regex.Pattern;
|
|
import net.minecraft.resources.ResourceLocation;
|
|
import org.apache.logging.log4j.LogManager;
|
|
import org.apache.logging.log4j.Logger;
|
|
import org.jetbrains.annotations.Nullable;
|
|
|
|
/**
|
|
* Parses JSON files into {@link FurnitureDefinition} instances.
|
|
*
|
|
* <p>Uses manual field extraction (not Gson deserialization) for strict
|
|
* validation control. Invalid required fields cause the entire definition
|
|
* to be rejected; optional fields use safe defaults.</p>
|
|
*
|
|
* <p>Expected JSON files in {@code data/<namespace>/tiedup_furniture/}.</p>
|
|
*/
|
|
public final class FurnitureParser {
|
|
|
|
private static final Logger LOGGER = LogManager.getLogger(
|
|
"TiedUpFurniture"
|
|
);
|
|
private static final String TAG = "[FurnitureParser]";
|
|
|
|
/** Strict hex color pattern: # followed by exactly 6 hex digits. */
|
|
private static final Pattern HEX_COLOR = Pattern.compile(
|
|
"^#[0-9A-Fa-f]{6}$"
|
|
);
|
|
|
|
/** Maximum number of seats per furniture (bitmask limit: 8 bits). */
|
|
private static final int MAX_SEATS = 8;
|
|
|
|
private FurnitureParser() {}
|
|
|
|
/**
|
|
* Parse a JSON input stream into a FurnitureDefinition.
|
|
*
|
|
* @param input the JSON input stream
|
|
* @param fileId the resource location of the source file (for error messages)
|
|
* @return the parsed definition, or null if the file is invalid
|
|
*/
|
|
@Nullable
|
|
public static FurnitureDefinition parse(
|
|
InputStream input,
|
|
ResourceLocation fileId
|
|
) {
|
|
try {
|
|
JsonObject root = JsonParser.parseReader(
|
|
new InputStreamReader(input, StandardCharsets.UTF_8)
|
|
).getAsJsonObject();
|
|
|
|
return parseObject(root, fileId);
|
|
} catch (Exception e) {
|
|
LOGGER.error(
|
|
"{} Failed to parse JSON {}: {}",
|
|
TAG,
|
|
fileId,
|
|
e.getMessage()
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a JsonObject into a FurnitureDefinition.
|
|
*
|
|
* @param root the parsed JSON object
|
|
* @param fileId the resource location of the source file (for error messages)
|
|
* @return the parsed definition, or null if validation fails
|
|
*/
|
|
@Nullable
|
|
public static FurnitureDefinition parseObject(
|
|
JsonObject root,
|
|
ResourceLocation fileId
|
|
) {
|
|
// --- Required: id ---
|
|
String idStr = getStringOrNull(root, "id");
|
|
if (idStr == null || idStr.isEmpty()) {
|
|
LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId);
|
|
return null;
|
|
}
|
|
ResourceLocation id = ResourceLocation.tryParse(idStr);
|
|
if (id == null) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: invalid id ResourceLocation '{}'",
|
|
TAG,
|
|
fileId,
|
|
idStr
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// --- Required: display_name ---
|
|
String displayName = getStringOrNull(root, "display_name");
|
|
if (displayName == null || displayName.isEmpty()) {
|
|
LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, fileId);
|
|
return null;
|
|
}
|
|
|
|
// --- Optional: translation_key ---
|
|
String translationKey = getStringOrNull(root, "translation_key");
|
|
|
|
// --- Required: model ---
|
|
String modelStr = getStringOrNull(root, "model");
|
|
if (modelStr == null || modelStr.isEmpty()) {
|
|
LOGGER.error("{} Skipping {}: missing 'model'", TAG, fileId);
|
|
return null;
|
|
}
|
|
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
|
|
if (modelLocation == null) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: invalid model ResourceLocation '{}'",
|
|
TAG,
|
|
fileId,
|
|
modelStr
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// --- Optional: tint_channels (strict hex validation) ---
|
|
Map<String, Integer> tintChannels = parseTintChannels(root, fileId);
|
|
if (tintChannels == null) {
|
|
// parseTintChannels returns null on invalid hex -> reject entire furniture
|
|
return null;
|
|
}
|
|
|
|
// --- Optional: supports_color (default false) ---
|
|
boolean supportsColor = getBooleanOrDefault(
|
|
root,
|
|
"supports_color",
|
|
false
|
|
);
|
|
|
|
// --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) ---
|
|
float hitboxWidth = 1.0f;
|
|
float hitboxHeight = 1.0f;
|
|
if (root.has("hitbox") && root.get("hitbox").isJsonObject()) {
|
|
JsonObject hitbox = root.getAsJsonObject("hitbox");
|
|
hitboxWidth = clamp(
|
|
getFloatOrDefault(hitbox, "width", 1.0f),
|
|
0.1f,
|
|
5.0f
|
|
);
|
|
hitboxHeight = clamp(
|
|
getFloatOrDefault(hitbox, "height", 1.0f),
|
|
0.1f,
|
|
5.0f
|
|
);
|
|
}
|
|
|
|
// --- Optional: placement ---
|
|
boolean snapToWall = false;
|
|
boolean floorOnly = true;
|
|
if (root.has("placement") && root.get("placement").isJsonObject()) {
|
|
JsonObject placement = root.getAsJsonObject("placement");
|
|
snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false);
|
|
floorOnly = getBooleanOrDefault(placement, "floor_only", true);
|
|
}
|
|
|
|
// --- Optional: lockable (default false) ---
|
|
boolean lockable = getBooleanOrDefault(root, "lockable", false);
|
|
|
|
// --- Optional: break_resistance (default 100, clamped [1, 10000]) ---
|
|
float breakResistance = clamp(
|
|
getFloatOrDefault(root, "break_resistance", 100.0f),
|
|
1.0f,
|
|
10000.0f
|
|
);
|
|
|
|
// --- Optional: drop_on_break (default true) ---
|
|
boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true);
|
|
|
|
// --- Required: seats (non-empty array, size [1, 8]) ---
|
|
if (!root.has("seats") || !root.get("seats").isJsonArray()) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: missing or invalid 'seats' array",
|
|
TAG,
|
|
fileId
|
|
);
|
|
return null;
|
|
}
|
|
JsonArray seatsArray = root.getAsJsonArray("seats");
|
|
if (seatsArray.isEmpty()) {
|
|
LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId);
|
|
return null;
|
|
}
|
|
if (seatsArray.size() > MAX_SEATS) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: 'seats' array has {} entries (max {})",
|
|
TAG,
|
|
fileId,
|
|
seatsArray.size(),
|
|
MAX_SEATS
|
|
);
|
|
return null;
|
|
}
|
|
|
|
List<SeatDefinition> seats = new ArrayList<>(seatsArray.size());
|
|
for (int i = 0; i < seatsArray.size(); i++) {
|
|
if (!seatsArray.get(i).isJsonObject()) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: seats[{}] is not a JSON object",
|
|
TAG,
|
|
fileId,
|
|
i
|
|
);
|
|
return null;
|
|
}
|
|
SeatDefinition seat = parseSeat(
|
|
seatsArray.get(i).getAsJsonObject(),
|
|
i,
|
|
lockable,
|
|
fileId
|
|
);
|
|
if (seat == null) {
|
|
// parseSeat already logged the error
|
|
return null;
|
|
}
|
|
seats.add(seat);
|
|
}
|
|
|
|
// --- Optional: feedback ---
|
|
FurnitureFeedback feedback = FurnitureFeedback.EMPTY;
|
|
if (root.has("feedback") && root.get("feedback").isJsonObject()) {
|
|
feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId);
|
|
}
|
|
|
|
// --- Optional: category (default "furniture") ---
|
|
String category = getStringOrDefault(root, "category", "furniture");
|
|
|
|
// --- Optional: icon (item model ResourceLocation for inventory sprite) ---
|
|
ResourceLocation icon = parseOptionalResourceLocation(
|
|
root,
|
|
"icon",
|
|
fileId
|
|
);
|
|
|
|
return new FurnitureDefinition(
|
|
id,
|
|
displayName,
|
|
translationKey,
|
|
modelLocation,
|
|
tintChannels,
|
|
supportsColor,
|
|
hitboxWidth,
|
|
hitboxHeight,
|
|
snapToWall,
|
|
floorOnly,
|
|
lockable,
|
|
breakResistance,
|
|
dropOnBreak,
|
|
seats,
|
|
feedback,
|
|
category,
|
|
icon
|
|
);
|
|
}
|
|
|
|
// ===== Seat Parsing =====
|
|
|
|
/**
|
|
* Parse a single seat JSON object.
|
|
*
|
|
* @param obj the seat JSON object
|
|
* @param index the seat index (for error messages)
|
|
* @param parentLockable the top-level lockable value (used as default)
|
|
* @param fileId the source file (for error messages)
|
|
* @return the parsed seat, or null on validation failure
|
|
*/
|
|
@Nullable
|
|
private static SeatDefinition parseSeat(
|
|
JsonObject obj,
|
|
int index,
|
|
boolean parentLockable,
|
|
ResourceLocation fileId
|
|
) {
|
|
// Required: id (must not contain ':')
|
|
String seatId = getStringOrNull(obj, "id");
|
|
if (seatId == null || seatId.isEmpty()) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: seats[{}] missing 'id'",
|
|
TAG,
|
|
fileId,
|
|
index
|
|
);
|
|
return null;
|
|
}
|
|
// 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 ':', '|', or ';'",
|
|
TAG,
|
|
fileId,
|
|
index,
|
|
seatId
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Required: armature
|
|
String armature = getStringOrNull(obj, "armature");
|
|
if (armature == null || armature.isEmpty()) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: seats[{}] missing 'armature'",
|
|
TAG,
|
|
fileId,
|
|
index
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Optional: blocked_regions (unknown region = fatal for entire furniture)
|
|
Set<BodyRegionV2> blockedRegions = parseBlockedRegions(
|
|
obj,
|
|
index,
|
|
fileId
|
|
);
|
|
if (blockedRegions == null) {
|
|
// parseBlockedRegions returns null ONLY on unknown region name (fatal)
|
|
return null;
|
|
}
|
|
|
|
// Optional: lockable (inherits from top-level)
|
|
boolean seatLockable = getBooleanOrDefault(
|
|
obj,
|
|
"lockable",
|
|
parentLockable
|
|
);
|
|
|
|
// Optional: locked_difficulty (clamped [1, 10000], default 1)
|
|
int lockedDifficulty = clampInt(
|
|
getIntOrDefault(obj, "locked_difficulty", 1),
|
|
1,
|
|
10000
|
|
);
|
|
|
|
// Optional: item_difficulty_bonus (default false)
|
|
boolean itemDifficultyBonus = getBooleanOrDefault(
|
|
obj,
|
|
"item_difficulty_bonus",
|
|
false
|
|
);
|
|
|
|
return new SeatDefinition(
|
|
seatId,
|
|
armature,
|
|
blockedRegions,
|
|
seatLockable,
|
|
lockedDifficulty,
|
|
itemDifficultyBonus
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse blocked_regions for a seat. Returns empty set if field is absent.
|
|
* Returns null (fatal) if any region name is unknown.
|
|
*/
|
|
@Nullable
|
|
private static Set<BodyRegionV2> parseBlockedRegions(
|
|
JsonObject obj,
|
|
int seatIndex,
|
|
ResourceLocation fileId
|
|
) {
|
|
if (
|
|
!obj.has("blocked_regions") ||
|
|
!obj.get("blocked_regions").isJsonArray()
|
|
) {
|
|
return Collections.unmodifiableSet(
|
|
EnumSet.noneOf(BodyRegionV2.class)
|
|
);
|
|
}
|
|
|
|
JsonArray arr = obj.getAsJsonArray("blocked_regions");
|
|
if (arr.isEmpty()) {
|
|
return Collections.unmodifiableSet(
|
|
EnumSet.noneOf(BodyRegionV2.class)
|
|
);
|
|
}
|
|
|
|
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
|
|
for (JsonElement elem : arr) {
|
|
String name;
|
|
try {
|
|
name = elem.getAsString().toUpperCase();
|
|
} catch (Exception e) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}",
|
|
TAG,
|
|
fileId,
|
|
seatIndex,
|
|
e.getMessage()
|
|
);
|
|
return null;
|
|
}
|
|
|
|
BodyRegionV2 region = BodyRegionV2.fromName(name);
|
|
if (region == null) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: seats[{}] unknown body region '{}'",
|
|
TAG,
|
|
fileId,
|
|
seatIndex,
|
|
name
|
|
);
|
|
return null;
|
|
}
|
|
regions.add(region);
|
|
}
|
|
|
|
return Collections.unmodifiableSet(regions);
|
|
}
|
|
|
|
// ===== Feedback Parsing =====
|
|
|
|
private static FurnitureFeedback parseFeedback(
|
|
JsonObject obj,
|
|
ResourceLocation fileId
|
|
) {
|
|
return new FurnitureFeedback(
|
|
parseOptionalResourceLocation(obj, "mount_sound", fileId),
|
|
parseOptionalResourceLocation(obj, "lock_sound", fileId),
|
|
parseOptionalResourceLocation(obj, "unlock_sound", fileId),
|
|
parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId),
|
|
parseOptionalResourceLocation(obj, "escape_sound", fileId),
|
|
parseOptionalResourceLocation(obj, "denied_sound", fileId)
|
|
);
|
|
}
|
|
|
|
// ===== Tint Channel Parsing =====
|
|
|
|
/**
|
|
* Parse tint_channels with strict hex validation.
|
|
* Returns empty map if field is absent. Returns null if any value is invalid hex.
|
|
*/
|
|
@Nullable
|
|
private static Map<String, Integer> parseTintChannels(
|
|
JsonObject root,
|
|
ResourceLocation fileId
|
|
) {
|
|
if (
|
|
!root.has("tint_channels") ||
|
|
!root.get("tint_channels").isJsonObject()
|
|
) {
|
|
return Map.of();
|
|
}
|
|
|
|
JsonObject channels = root.getAsJsonObject("tint_channels");
|
|
Map<String, Integer> result = new LinkedHashMap<>();
|
|
|
|
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
|
|
String hex;
|
|
try {
|
|
hex = entry.getValue().getAsString();
|
|
} catch (Exception e) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: tint_channels '{}' value is not a string",
|
|
TAG,
|
|
fileId,
|
|
entry.getKey()
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!HEX_COLOR.matcher(hex).matches()) {
|
|
LOGGER.error(
|
|
"{} Skipping {}: tint_channels '{}' has invalid hex color '{}' " +
|
|
"(expected '#' followed by 6 hex digits)",
|
|
TAG,
|
|
fileId,
|
|
entry.getKey(),
|
|
hex
|
|
);
|
|
return null;
|
|
}
|
|
|
|
int color = Integer.parseInt(hex.substring(1), 16);
|
|
result.put(entry.getKey(), color);
|
|
}
|
|
|
|
return Collections.unmodifiableMap(result);
|
|
}
|
|
|
|
// ===== Primitive Helpers =====
|
|
|
|
@Nullable
|
|
private static String getStringOrNull(JsonObject obj, String key) {
|
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
|
try {
|
|
return obj.get(key).getAsString();
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static String getStringOrDefault(
|
|
JsonObject obj,
|
|
String key,
|
|
String defaultValue
|
|
) {
|
|
String value = getStringOrNull(obj, key);
|
|
return (value != null && !value.isEmpty()) ? value : defaultValue;
|
|
}
|
|
|
|
private static int getIntOrDefault(
|
|
JsonObject obj,
|
|
String key,
|
|
int defaultValue
|
|
) {
|
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
|
try {
|
|
return obj.get(key).getAsInt();
|
|
} catch (Exception e) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
private static float getFloatOrDefault(
|
|
JsonObject obj,
|
|
String key,
|
|
float defaultValue
|
|
) {
|
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
|
try {
|
|
return obj.get(key).getAsFloat();
|
|
} catch (Exception e) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
private static boolean getBooleanOrDefault(
|
|
JsonObject obj,
|
|
String key,
|
|
boolean defaultValue
|
|
) {
|
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
|
try {
|
|
return obj.get(key).getAsBoolean();
|
|
} catch (Exception e) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
private static ResourceLocation parseOptionalResourceLocation(
|
|
JsonObject obj,
|
|
String key,
|
|
ResourceLocation fileId
|
|
) {
|
|
String value = getStringOrNull(obj, key);
|
|
if (value == null || value.isEmpty()) return null;
|
|
ResourceLocation loc = ResourceLocation.tryParse(value);
|
|
if (loc == null) {
|
|
LOGGER.warn(
|
|
"{} In {}: invalid ResourceLocation for '{}': '{}'",
|
|
TAG,
|
|
fileId,
|
|
key,
|
|
value
|
|
);
|
|
}
|
|
return loc;
|
|
}
|
|
|
|
// ===== Clamping Helpers =====
|
|
|
|
private static float clamp(float value, float min, float max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
private static int clampInt(int value, int min, int max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
}
|