Files
TiedUp-/src/main/java/com/tiedup/remake/v2/furniture/FurnitureParser.java
NotEvil 11188bc621 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.
2026-04-18 17:34:03 +02:00

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));
}
}