add wardrobe variant catalog with virtual pack and naturalUv bake, cosmetic + skin flipbook with hold-frame field, raise raw/deflated caps for flipbook strips, drop user folder cosmetic loader

main
MomokoKoigakubo 3 weeks ago
parent 640dcae487
commit 9779a140db

@ -17,7 +17,6 @@ import com.razz.dfashion.client.CosmeticRenderLayer;
import com.razz.dfashion.client.SafeImageLoader;
import com.razz.dfashion.cosmetic.CosmeticCatalog;
import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.cosmetic.UserCosmeticLoader;
import com.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.Minecraft;
@ -36,15 +35,12 @@ import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent;
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -52,11 +48,6 @@ import java.util.Optional;
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public class DecoFashionClient {
/** Upper bound on a user-authored .bbmodel we'll even attempt to parse. Real cosmetics
* are well under 1 MB; 16 MB is slack for giant authored pieces while still blocking
* a malicious GB-scale file from being slurped into memory via {@code Files.newBufferedReader}. */
private static final long MAX_BBMODEL_FILE_BYTES = 16L * 1024 * 1024;
@SubscribeEvent
static void onClientSetup(FMLClientSetupEvent event) {
ClosetPreferences.load();
@ -71,6 +62,11 @@ public class DecoFashionClient {
);
}
@SubscribeEvent
static void onAddPackFinders(net.neoforged.neoforge.event.AddPackFindersEvent event) {
com.razz.dfashion.block.ClosetVirtualPack.onAddPackFinders(event);
}
@SubscribeEvent
static void onAddLayers(EntityRenderersEvent.AddLayers event) {
for (PlayerModelType type : event.getSkins()) {
@ -86,13 +82,27 @@ public class DecoFashionClient {
event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new);
}
/** Texture ids this class registered into {@link TextureManager} on the previous reload
* needed so we can release/unregister them before re-registering on the next reload.
* Used by built-in cosmetics that opted into flipbook (which need a single-frame
* {@link DynamicTexture} + a {@link com.razz.dfashion.client.FlipbookTicker} entry). */
private static final java.util.Set<Identifier> OWNED_COSMETIC_TEXTURES = java.util.concurrent.ConcurrentHashMap.newKeySet();
public static void load(ResourceManager rm) {
// Release the previous reload's owned cosmetic textures (flipbook strips + their live
// DynamicTextures) before rebuilding.
TextureManager tm0 = Minecraft.getInstance().getTextureManager();
for (Identifier prev : OWNED_COSMETIC_TEXTURES) {
com.razz.dfashion.client.FlipbookTicker.unregister(prev);
tm0.release(prev);
}
OWNED_COSMETIC_TEXTURES.clear();
Map<Identifier, CosmeticDefinition> catalog = CosmeticCatalog.loadAll(rm);
Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>();
for (Map.Entry<Identifier, CosmeticDefinition> entry : catalog.entrySet()) {
loadOne(rm, entry.getKey(), entry.getValue(), baked);
}
loadUserCosmetics(catalog, baked);
CosmeticCache.cosmetics = baked;
CosmeticCache.catalog = catalog;
DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size());
@ -105,66 +115,6 @@ public class DecoFashionClient {
ClientSharedCosmeticCache.scanDisk();
}
/**
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} and folds user-dropped
* bbmodels + textures into the already-built catalog/baked maps. Textures are registered
* into the client's {@link TextureManager} under synthetic {@code decofashion_user:...} ids.
*/
private static void loadUserCosmetics(
Map<Identifier, CosmeticDefinition> catalog,
Map<Identifier, CosmeticCache.Baked> baked
) {
List<UserCosmeticLoader.Entry> entries = UserCosmeticLoader.scan(FMLPaths.GAMEDIR.get());
if (entries.isEmpty()) return;
TextureManager tm = Minecraft.getInstance().getTextureManager();
Map<java.nio.file.Path, Bbmodel> modelCache = new HashMap<>();
for (UserCosmeticLoader.Entry entry : entries) {
Bbmodel model = modelCache.get(entry.modelFile());
if (model == null) {
try {
long size = Files.size(entry.modelFile());
if (size > MAX_BBMODEL_FILE_BYTES) {
DecoFashion.LOGGER.error("User cosmetic {}: bbmodel too large ({} bytes); skipping {}",
entry.id(), size, entry.modelFile());
continue;
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("User cosmetic {}: size check failed for {}",
entry.id(), entry.modelFile(), ex);
continue;
}
try (Reader reader = Files.newBufferedReader(entry.modelFile())) {
model = BbModelParser.parse(reader);
modelCache.put(entry.modelFile(), model);
} catch (Exception ex) {
DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {} ({})",
entry.id(), entry.modelFile(), ex.getMessage());
continue;
}
}
NativeImage image;
try {
image = SafeImageLoader.loadNativeImage(entry.textureFile());
} catch (IOException ex) {
DecoFashion.LOGGER.error("User cosmetic {}: rejected texture {} ({})",
entry.id(), entry.textureFile(), ex.getMessage());
continue;
}
Identifier texId = entry.def().texture();
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true);
baked.put(entry.id(), new CosmeticCache.Baked(parts, entry.def().texture()));
catalog.put(entry.id(), entry.def());
DecoFashion.LOGGER.info("Loaded user cosmetic {} [{}]",
entry.id(), entry.def().displayName());
}
}
/**
* Resolve each locator's absolute bbmodel-space position by walking its parent chain.
* Locator {@code position} is already absolute bbmodel coords; the walk exists to apply
@ -244,21 +194,90 @@ public class DecoFashionClient {
v.x = x; v.y = y;
}
private static final String CLOSET_TEXTURE_NAMESPACE = "decofashion_closet";
private static void loadCloset(ResourceManager rm) {
Identifier closedModel = Identifier.fromNamespaceAndPath(
DecoFashion.MODID, "closet/closet_7.bbmodel");
Identifier openModel = Identifier.fromNamespaceAndPath(
DecoFashion.MODID, "closet/closet_7_open.bbmodel");
Identifier texture = Identifier.fromNamespaceAndPath(
DecoFashion.MODID, "textures/closet/closet_base_spruce.png");
ClosetModelCache.closed = bakeCloset(rm, closedModel, texture);
ClosetModelCache.open = bakeCloset(rm, openModel, texture);
// Release any DynamicTextures registered on the previous reload so we don't leak.
// Per-id unregister (not FlipbookTicker.clear()) because the ticker is shared with
// the cosmetic subsystem — clearing all entries would nuke cosmetics' flipbooks.
for (ClosetModelCache.Variant prev : ClosetModelCache.byVariant.values()) {
com.razz.dfashion.client.FlipbookTicker.unregister(prev.texture());
Minecraft.getInstance().getTextureManager().release(prev.texture());
}
ClosetModelCache.byVariant.clear();
for (com.razz.dfashion.block.ClosetCatalog.Entry entry : ClosetRegistry.catalog().values()) {
ClosetModelCache.State closed = bakeClosetState(rm, entry.closedModel());
ClosetModelCache.State open = bakeClosetState(rm, entry.openModel());
if (closed == null && open == null) continue;
Identifier syntheticTex = registerClosetTexture(rm, entry);
if (syntheticTex == null) continue;
ClosetModelCache.byVariant.put(
entry.variantId(),
new ClosetModelCache.Variant(closed, open, syntheticTex)
);
}
}
private static ClosetModelCache.Baked bakeCloset(
ResourceManager rm, Identifier modelPath, Identifier texturePath
/**
* Loads the variant's PNG, registers it as a {@link DynamicTexture} under
* {@code decofashion_closet:<variant>}, and (for animated variants) hands the source
* strip to {@link com.razz.dfashion.client.FlipbookTicker} so frames get blitted
* each tick. Returns the synthetic texture id, or null on failure.
*/
private static @org.jspecify.annotations.Nullable Identifier registerClosetTexture(
ResourceManager rm, com.razz.dfashion.block.ClosetCatalog.Entry entry
) {
Optional<Resource> res = rm.getResource(entry.texture());
if (res.isEmpty()) {
DecoFashion.LOGGER.warn("Closet {}: no texture at {}", entry.variantId(), entry.texture());
return null;
}
NativeImage source;
try (java.io.InputStream in = res.get().open()) {
source = SafeImageLoader.loadNativeImage(in.readAllBytes());
} catch (IOException ex) {
DecoFashion.LOGGER.error("Closet {}: failed to read texture {}",
entry.variantId(), entry.texture(), ex);
return null;
}
TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier syntheticId = Identifier.fromNamespaceAndPath(
CLOSET_TEXTURE_NAMESPACE, entry.variantId().getPath());
com.razz.dfashion.block.ClosetCatalog.Flipbook fb = entry.flipbook();
if (fb == null) {
// Static texture — register the full image directly.
tm.register(syntheticId, new DynamicTexture(() -> "decofashion closet " + syntheticId, source));
return syntheticId;
}
// Animated: the renderer always sees a single-frame texture sized to one frame.
// FlipbookTicker swaps in new pixels per frametime.
int frameH = source.getHeight() / fb.frames();
if (frameH <= 0 || source.getHeight() % fb.frames() != 0) {
DecoFashion.LOGGER.error(
"Closet {}: flipbook frames={} doesn't divide texture height {}",
entry.variantId(), fb.frames(), source.getHeight());
source.close();
return null;
}
NativeImage frame = new NativeImage(source.getWidth(), frameH, false);
source.copyRect(frame, 0, 0, 0, 0, source.getWidth(), frameH, false, false);
DynamicTexture liveTex = new DynamicTexture(() -> "decofashion closet " + syntheticId, frame);
tm.register(syntheticId, liveTex);
com.razz.dfashion.client.FlipbookTicker.register(
syntheticId,
new com.razz.dfashion.client.FlipbookTicker.Entry(
source, liveTex, fb.frametime(), fb.frames(), source.getWidth(), frameH)
);
return syntheticId;
}
private static ClosetModelCache.State bakeClosetState(ResourceManager rm, Identifier modelPath) {
Optional<Resource> res = rm.getResource(modelPath);
if (res.isEmpty()) {
DecoFashion.LOGGER.warn("Closet: no bbmodel at {}", modelPath);
@ -266,15 +285,20 @@ public class DecoFashionClient {
}
try (Reader reader = res.get().openAsReader()) {
Bbmodel model = BbModelParser.parse(reader);
// BEs don't go through the player avatar renderer's X/Y pre-flip pipeline, so
// we use naturalUv=true: polygons get a per-face vertex order that maps each
// face's bbmodel [u0,v0,u1,v1] rect 1:1 onto the cube (matches decocraft).
// This avoids both the rotated-cube X-mirror breakage AND the upside-down V
// mapping that vanilla's vertex order produces without an avatar pre-flip.
Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true);
model, model.resolutionWidth(), model.resolutionHeight(), false, true);
Map<String, org.joml.Vector3f> locators = resolveLocators(model);
DecoFashion.LOGGER.info(
"Loaded closet model {} (res {}x{}): parts={}, locators={}",
modelPath, model.resolutionWidth(), model.resolutionHeight(),
parts.keySet(), locators
);
return new ClosetModelCache.Baked(parts, locators, texturePath);
return new ClosetModelCache.State(parts, locators);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed to read closet model {}", modelPath, ex);
return null;
@ -297,15 +321,66 @@ public class DecoFashionClient {
Bbmodel model = BbModelParser.parse(reader);
Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true);
out.put(cosmeticId, new CosmeticCache.Baked(parts, def.texture()));
Identifier renderTex = def.flipbook() != null
? registerCosmeticFlipbookTexture(rm, cosmeticId, def, def.flipbook())
: def.texture();
if (renderTex == null) return; // load failure logged inside helper
out.put(cosmeticId, new CosmeticCache.Baked(parts, renderTex));
DecoFashion.LOGGER.info(
"Loaded cosmetic {} [{}] (res {}x{}): parts={}",
"Loaded cosmetic {} [{}] (res {}x{}): parts={}{}",
cosmeticId, def.displayName(),
model.resolutionWidth(), model.resolutionHeight(), parts.keySet()
model.resolutionWidth(), model.resolutionHeight(), parts.keySet(),
def.flipbook() != null ? " (flipbook " + def.flipbook().frames()
+ "f @" + def.flipbook().frametime() + "t)" : ""
);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed to read {}", def.model(), ex);
}
}
/** Built-in cosmetic flipbook path: load the source PNG via the resource manager, register
* a single-frame {@link DynamicTexture} under {@code decofashion_cosmetic:<id>}, and hand
* the source strip to {@link com.razz.dfashion.client.FlipbookTicker}. */
private static @org.jspecify.annotations.Nullable Identifier registerCosmeticFlipbookTexture(
ResourceManager rm, Identifier cosmeticId, CosmeticDefinition def,
CosmeticDefinition.Flipbook fb
) {
Optional<Resource> res = rm.getResource(def.texture());
if (res.isEmpty()) {
DecoFashion.LOGGER.warn("Cosmetic {}: no texture at {}", cosmeticId, def.texture());
return null;
}
NativeImage source;
try (java.io.InputStream in = res.get().open()) {
source = SafeImageLoader.loadNativeImage(in.readAllBytes());
} catch (IOException ex) {
DecoFashion.LOGGER.error("Cosmetic {}: failed to read texture {}",
cosmeticId, def.texture(), ex);
return null;
}
int frameH = source.getHeight() / fb.frames();
if (frameH <= 0 || source.getHeight() % fb.frames() != 0) {
DecoFashion.LOGGER.error(
"Cosmetic {}: flipbook frames={} doesn't divide texture height {}",
cosmeticId, fb.frames(), source.getHeight());
source.close();
return null;
}
NativeImage frame = new NativeImage(source.getWidth(), frameH, false);
source.copyRect(frame, 0, 0, 0, 0, source.getWidth(), frameH, false, false);
Identifier syntheticId = Identifier.fromNamespaceAndPath(
"decofashion_cosmetic", cosmeticId.getPath());
DynamicTexture liveTex = new DynamicTexture(
() -> "decofashion cosmetic " + syntheticId, frame);
Minecraft.getInstance().getTextureManager().register(syntheticId, liveTex);
com.razz.dfashion.client.FlipbookTicker.register(
syntheticId,
new com.razz.dfashion.client.FlipbookTicker.Entry(
source, liveTex, fb.frametime(), fb.frames(), source.getWidth(), frameH)
);
OWNED_COSMETIC_TEXTURES.add(syntheticId);
return syntheticId;
}
}

@ -8,7 +8,21 @@ import java.util.*;
public class BbmodelBaker {
/** Backward-compat: cosmetic pipeline call site. {@code naturalUv=false} keeps vanilla's
* cube vertex order which is calibrated for the player avatar's pre-flip transforms. */
public static Map<String, ModelPart> bake(Bbmodel model, int texWidth, int texHeight, boolean mirrorX) {
return bake(model, texWidth, texHeight, mirrorX, false);
}
/**
* @param naturalUv when true, polygons are constructed with a per-face vertex order that
* produces a 1:1 UVcube mapping (Blockbench's authoring convention),
* matching decocraft's renderer. Use this for BlockEntity rendering, which
* doesn't go through the avatar pre-flip pipeline. {@code mirrorX} should
* be false in this mode.
*/
public static Map<String, ModelPart> bake(Bbmodel model, int texWidth, int texHeight,
boolean mirrorX, boolean naturalUv) {
Map<String, BbCube> cubeIndex = new HashMap<>();
for (BbCube c : model.elements()) cubeIndex.put(c.uuid(), c);
@ -24,7 +38,7 @@ public class BbmodelBaker {
// layer already places us at the bone, so the ModelPart itself doesn't
// need to translate again.
result.put(g.name(),
buildPart(gref, g, g.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX));
buildPart(gref, g, g.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX, naturalUv));
}
// ElementRef at root = loose cube, skip (no bone to attach to)
}
@ -38,7 +52,8 @@ public class BbmodelBaker {
Map<String, BbCube> cubeIndex,
Map<String, BbGroup> groupIndex,
int texWidth, int texHeight,
boolean mirrorX
boolean mirrorX,
boolean naturalUv
) {
List<ModelPart.Cube> cubes = new ArrayList<>();
Map<String, ModelPart> children = new LinkedHashMap<>();
@ -53,7 +68,7 @@ public class BbmodelBaker {
// ModelPart.Cube is axis-aligned, so per-cube rotation is done
// by wrapping the cube in its own one-cube ModelPart whose pose
// carries the rotation.
ModelPart.Cube inner = bbCubeToCube(bb, bb.origin(), texWidth, texHeight, mirrorX);
ModelPart.Cube inner = bbCubeToCube(bb, bb.origin(), texWidth, texHeight, mirrorX, naturalUv);
ModelPart wrapper = new ModelPart(List.of(inner), Map.of());
wrapper.setPos(
bb.origin().x - group.origin().x,
@ -67,13 +82,13 @@ public class BbmodelBaker {
);
children.put("cube_" + bb.uuid(), wrapper);
} else {
cubes.add(bbCubeToCube(bb, group.origin(), texWidth, texHeight, mirrorX));
cubes.add(bbCubeToCube(bb, group.origin(), texWidth, texHeight, mirrorX, naturalUv));
}
}
case BbOutlinerNode.GroupRef nested -> {
BbGroup ng = groupIndex.get(nested.uuid());
children.put(ng.name(),
buildPart(nested, ng, group.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX));
buildPart(nested, ng, group.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX, naturalUv));
}
}
}
@ -98,7 +113,8 @@ public class BbmodelBaker {
}
private static ModelPart.Cube bbCubeToCube(
BbCube bb, Vector3f referenceOrigin, int texWidth, int texHeight, boolean mirrorX
BbCube bb, Vector3f referenceOrigin, int texWidth, int texHeight,
boolean mirrorX, boolean naturalUv
) {
float x = bb.from().x - referenceOrigin.x;
float y = bb.from().y - referenceOrigin.y;
@ -133,13 +149,28 @@ public class BbmodelBaker {
Direction.NORTH, Direction.EAST, Direction.SOUTH
};
// The 8 cube vertices, named by their world-space corner. Used by the natural-UV
// path to reorder polygon vertices so each face's UV maps Blockbench's [u0,v0,u1,v1]
// rectangle 1:1 onto the cube — top-left of UV → top-left of face, etc.
ModelPart.Vertex nwBot = naturalUv ? new ModelPart.Vertex(x, y, z, 0, 0) : null;
ModelPart.Vertex neBot = naturalUv ? new ModelPart.Vertex(x + w, y, z, 0, 0) : null;
ModelPart.Vertex neTop = naturalUv ? new ModelPart.Vertex(x + w, y + h, z, 0, 0) : null;
ModelPart.Vertex nwTop = naturalUv ? new ModelPart.Vertex(x, y + h, z, 0, 0) : null;
ModelPart.Vertex swBot = naturalUv ? new ModelPart.Vertex(x, y, z + d, 0, 0) : null;
ModelPart.Vertex seBot = naturalUv ? new ModelPart.Vertex(x + w, y, z + d, 0, 0) : null;
ModelPart.Vertex seTop = naturalUv ? new ModelPart.Vertex(x + w, y + h, z + d, 0, 0) : null;
ModelPart.Vertex swTop = naturalUv ? new ModelPart.Vertex(x, y + h, z + d, 0, 0) : null;
int idx = 0;
for (Direction dir : order) {
if (!visibleFaces.contains(dir)) continue;
BbFace bbFace = bb.faces().get(nameOf(dir));
if (bbFace != null && bbFace.uv() != null && bbFace.uv().length >= 4) {
ModelPart.Vertex[] verts = naturalUv
? naturalVertsFor(dir, nwBot, neBot, neTop, nwTop, swBot, seBot, seTop, swTop)
: cube.polygons[idx].vertices();
cube.polygons[idx] = new ModelPart.Polygon(
cube.polygons[idx].vertices(),
verts,
bbFace.uv()[0], bbFace.uv()[1],
bbFace.uv()[2], bbFace.uv()[3],
texWidth, texHeight,
@ -153,6 +184,35 @@ public class BbmodelBaker {
return cube;
}
/**
* Returns the four cube vertices for {@code dir} in (TR, TL, BL, BR) visual order, where
* "visual" is the orientation the bbmodel author sees in Blockbench's per-face UV editor.
* Vanilla's {@link ModelPart.Polygon} ctor remaps {@code vertices[0..3]} to UV
* {@code (u1,v0)(u0,v0)(u0,v1)(u1,v1)} pairing this with (TR, TL, BL, BR) gives a
* 1:1 mapping. UP/DOWN use a north-up convention (UV top = +Z = south? see decocraft).
*
* <p>UP face: vertex[0]=NE-top texture top-right. So texture's "north" (top) = cube's
* north edge, texture's "east" (right) = cube's east edge Blockbench's standard preview
* orientation when looking down at the top face.
*
* <p>DOWN face: vertex[0]=SE-bot texture top-right. Texture's "south" (top) = cube's
* south edge viewing from below as if you tilted your head back, with south "above" you.
*/
private static ModelPart.Vertex[] naturalVertsFor(
Direction dir,
ModelPart.Vertex nwBot, ModelPart.Vertex neBot, ModelPart.Vertex neTop, ModelPart.Vertex nwTop,
ModelPart.Vertex swBot, ModelPart.Vertex seBot, ModelPart.Vertex seTop, ModelPart.Vertex swTop
) {
return switch (dir) {
case NORTH -> new ModelPart.Vertex[]{nwTop, neTop, neBot, nwBot};
case SOUTH -> new ModelPart.Vertex[]{seTop, swTop, swBot, seBot};
case EAST -> new ModelPart.Vertex[]{neTop, seTop, seBot, neBot};
case WEST -> new ModelPart.Vertex[]{swTop, nwTop, nwBot, swBot};
case UP -> new ModelPart.Vertex[]{neTop, nwTop, swTop, seTop};
case DOWN -> new ModelPart.Vertex[]{seBot, swBot, nwBot, neBot};
};
}
private static String nameOf(Direction d) {
return d.getName();
}

@ -4,6 +4,7 @@ import com.mojang.serialization.MapCodec;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
@ -22,14 +23,29 @@ import net.minecraft.world.phys.BlockHitResult;
public class ClosetBlock extends BaseEntityBlock {
// simpleCodec is required by BaseEntityBlock but in practice never instantiates closets at
// runtime — every variant is registered ahead of time, looked up by registry id, and
// dispatched through the registry. The Properties-only constructor exists solely to satisfy
// this codec; the registry always uses the (Properties, variantId) one.
public static final MapCodec<ClosetBlock> CODEC = simpleCodec(ClosetBlock::new);
public static final EnumProperty<Direction> FACING = HorizontalDirectionalBlock.FACING;
private final Identifier variantId;
public ClosetBlock(Properties properties) {
this(properties, null);
}
public ClosetBlock(Properties properties, Identifier variantId) {
super(properties);
this.variantId = variantId;
registerDefaultState(defaultBlockState().setValue(FACING, Direction.NORTH));
}
public Identifier variantId() {
return variantId;
}
@Override
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;

@ -0,0 +1,84 @@
package com.razz.dfashion.block;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.razz.dfashion.DecoFashion;
import net.minecraft.resources.Identifier;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Closet variants are read from {@code decofashion/closets.json} on the mod's classpath
* at mod-construction time vanilla's {@link net.minecraft.server.packs.resources.ResourceManager}
* isn't built yet when {@link net.neoforged.neoforge.registries.DeferredRegister} fires, so we
* read directly from the jar instead.
*
* <p>Each catalog entry registers its own {@link ClosetBlock}; adding a new wardrobe means
* dropping bbmodels + texture, adding a JSON entry, and restarting the game.
*/
public final class ClosetCatalog {
private static final String CLASSPATH_FILE = "/decofashion/closets.json";
/** Author-supplied flipbook spec. {@code frames} is the number of vertically-stacked
* frames in the source PNG; {@code frametime} is ticks per frame (vanilla mcmeta-style).
* No interpolate field flicker between frames, no blending. */
public record Flipbook(int frametime, int frames) {}
public record Entry(
Identifier variantId,
Identifier closedModel,
Identifier openModel,
Identifier texture,
@Nullable Flipbook flipbook
) {}
private ClosetCatalog() {}
public static Map<Identifier, Entry> loadFromClasspath() {
Map<Identifier, Entry> result = new LinkedHashMap<>();
try (InputStream in = ClosetCatalog.class.getResourceAsStream(CLASSPATH_FILE)) {
if (in == null) {
DecoFashion.LOGGER.warn("Closet catalog not found at {}", CLASSPATH_FILE);
return result;
}
JsonObject root = JsonParser.parseReader(
new InputStreamReader(in, StandardCharsets.UTF_8)
).getAsJsonObject();
for (Map.Entry<String, com.google.gson.JsonElement> e : root.entrySet()) {
String idPath = e.getKey();
JsonObject entry = e.getValue().getAsJsonObject();
Identifier variantId = Identifier.fromNamespaceAndPath(DecoFashion.MODID, idPath);
Identifier closed = Identifier.parse(entry.get("closed").getAsString());
Identifier open = Identifier.parse(entry.get("open").getAsString());
Identifier tex = Identifier.parse(entry.get("texture").getAsString());
Flipbook flipbook = parseFlipbook(entry);
result.put(variantId, new Entry(variantId, closed, open, tex, flipbook));
}
} catch (IOException | JsonParseException | IllegalStateException ex) {
DecoFashion.LOGGER.error("Failed to parse closet catalog {}", CLASSPATH_FILE, ex);
}
return result;
}
private static @Nullable Flipbook parseFlipbook(JsonObject entry) {
if (!entry.has("flipbook")) return null;
JsonObject fb = entry.getAsJsonObject("flipbook");
int frametime = fb.has("frametime") ? fb.get("frametime").getAsInt() : 1;
int frames = fb.has("frames") ? fb.get("frames").getAsInt() : 1;
if (frametime <= 0 || frames <= 1) return null;
return new Flipbook(frametime, frames);
}
}

@ -3,8 +3,7 @@ package com.razz.dfashion.block;
import com.razz.dfashion.DecoFashion;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
@ -15,6 +14,10 @@ import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public final class ClosetRegistry {
public static final DeferredRegister.Blocks BLOCKS =
@ -26,24 +29,49 @@ public final class ClosetRegistry {
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, DecoFashion.MODID);
@SuppressWarnings("removal")
public static final DeferredBlock<ClosetBlock> CLOSET = BLOCKS.registerBlock(
"closet",
ClosetBlock::new,
BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(2.0f)
.noOcclusion()
);
private static final Map<Identifier, ClosetCatalog.Entry> CATALOG = ClosetCatalog.loadFromClasspath();
private static final Map<Identifier, DeferredBlock<ClosetBlock>> BLOCKS_BY_VARIANT = new LinkedHashMap<>();
public static final DeferredItem<BlockItem> CLOSET_ITEM = ITEMS.registerSimpleBlockItem(CLOSET);
static {
for (ClosetCatalog.Entry entry : CATALOG.values()) {
registerVariant(entry);
}
}
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<ClosetBlockEntity>> CLOSET_BE =
BLOCK_ENTITIES.register(
"closet",
() -> new BlockEntityType<>(ClosetBlockEntity::new, CLOSET.get())
() -> new BlockEntityType<>(
ClosetBlockEntity::new,
BLOCKS_BY_VARIANT.values().stream()
.map(DeferredBlock::get)
.toArray(Block[]::new)
)
);
@SuppressWarnings("removal")
private static void registerVariant(ClosetCatalog.Entry entry) {
Identifier variantId = entry.variantId();
DeferredBlock<ClosetBlock> block = BLOCKS.registerBlock(
variantId.getPath(),
props -> new ClosetBlock(props, variantId),
BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(2.0f)
.noOcclusion()
);
BLOCKS_BY_VARIANT.put(variantId, block);
ITEMS.registerSimpleBlockItem(block);
}
public static Map<Identifier, ClosetCatalog.Entry> catalog() {
return Collections.unmodifiableMap(CATALOG);
}
public static Map<Identifier, DeferredBlock<ClosetBlock>> blocks() {
return Collections.unmodifiableMap(BLOCKS_BY_VARIANT);
}
public static void register(IEventBus bus) {
BLOCKS.register(bus);
ITEMS.register(bus);

@ -0,0 +1,249 @@
package com.razz.dfashion.block;
import com.razz.dfashion.DecoFashion;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.PackLocationInfo;
import net.minecraft.server.packs.PackResources;
import net.minecraft.server.packs.PackSelectionConfig;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.metadata.MetadataSectionType;
import net.minecraft.server.packs.repository.Pack;
import net.minecraft.server.packs.repository.PackCompatibility;
import net.minecraft.server.packs.repository.PackSource;
import net.minecraft.server.packs.resources.IoSupplier;
import net.minecraft.world.flag.FeatureFlagSet;
import net.neoforged.neoforge.event.AddPackFindersEvent;
import org.jspecify.annotations.Nullable;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Synthesizes blockstate/block-model/item-model/item-definition resources for every
* closet catalog entry, so adding a new wardrobe is one entry in {@code closets.json}
* + the bbmodels and texture no boilerplate JSON per variant.
*
* <p>Pattern adapted from decocraft's DynamicDecocraftPack. The `.png.mcmeta` proxy +
* standalone-texture flipbook used elsewhere in that file is deliberately not mirrored
* here: closet textures are rendered via {@code RenderTypes.entityTranslucent(textureId)}
* (not the block atlas), so vanilla's mcmeta animation never fires for them. Flipbook
* animation is handled by {@link com.razz.dfashion.client.FlipbookTicker} it
* blits frames into a {@link net.minecraft.client.renderer.texture.DynamicTexture} each
* tick and re-uploads. Keeping that in code instead of mcmeta avoids needing a separate
* atlas pipeline just for closets.
*/
public final class ClosetVirtualPack implements PackResources {
private static final String PACK_ID = DecoFashion.MODID + "_dynamic_closets";
private static final byte[] PACK_MCMETA =
"{\"pack\":{\"pack_format\":34,\"description\":\"DecoFashion dynamic closet assets\"}}"
.getBytes(StandardCharsets.UTF_8);
private final PackLocationInfo locationInfo;
public ClosetVirtualPack() {
this.locationInfo = new PackLocationInfo(
PACK_ID,
Component.literal("DecoFashion Dynamic Closets"),
PackSource.BUILT_IN,
Optional.empty()
);
}
public static void onAddPackFinders(AddPackFindersEvent event) {
event.addRepositorySource(consumer -> {
PackLocationInfo info = new ClosetVirtualPack().location();
Pack.ResourcesSupplier supplier = new Pack.ResourcesSupplier() {
@Override
public PackResources openPrimary(PackLocationInfo info) {
return new ClosetVirtualPack();
}
@Override
public PackResources openFull(PackLocationInfo info, Pack.Metadata metadata) {
return new ClosetVirtualPack();
}
};
Pack.Metadata metadata = new Pack.Metadata(
Component.literal("DecoFashion dynamic closet assets"),
PackCompatibility.COMPATIBLE,
FeatureFlagSet.of(),
List.of()
);
consumer.accept(new Pack(
info,
supplier,
metadata,
new PackSelectionConfig(true, Pack.Position.TOP, false)
));
});
}
// --- Synthesizers ---
private static String generateBlockstateJson(String id) {
String model = DecoFashion.MODID + ":block/" + id;
return "{\"variants\":{"
+ "\"facing=south\":{\"model\":\"" + model + "\",\"y\":0},"
+ "\"facing=west\":{\"model\":\"" + model + "\",\"y\":90},"
+ "\"facing=north\":{\"model\":\"" + model + "\",\"y\":180},"
+ "\"facing=east\":{\"model\":\"" + model + "\",\"y\":270}"
+ "}}";
}
/** Particle texture only — block geometry is INVISIBLE; the BlockEntityRenderer draws it. */
private static String generateBlockModelJson(Identifier texture) {
return "{\"textures\":{\"particle\":\""
+ texture.getNamespace() + ":"
+ stripTexturesPrefixAndExt(texture.getPath())
+ "\"}}";
}
private static String generateItemDefinitionJson(String id) {
return "{\"model\":{\"type\":\"minecraft:model\",\"model\":\""
+ DecoFashion.MODID + ":item/" + id + "\"}}";
}
private static String generateItemModelJson(String id) {
return "{\"parent\":\"" + DecoFashion.MODID + ":block/" + id + "\"}";
}
/** Catalog stores the texture as a full asset path like {@code decofashion:textures/closet/wardrobe_1.png};
* model JSONs reference textures as {@code <ns>:<path>} where {@code <path>} is the texture path
* without the leading {@code textures/} or trailing {@code .png}. */
private static String stripTexturesPrefixAndExt(String path) {
String p = path;
if (p.startsWith("textures/")) p = p.substring("textures/".length());
if (p.endsWith(".png")) p = p.substring(0, p.length() - ".png".length());
return p;
}
// --- PackResources implementation ---
@Override
public @Nullable IoSupplier<InputStream> getRootResource(String... path) {
if (path.length == 1 && "pack.mcmeta".equals(path[0])) {
return () -> new ByteArrayInputStream(PACK_MCMETA);
}
return null;
}
@Override
public @Nullable IoSupplier<InputStream> getResource(PackType packType, Identifier location) {
if (packType != PackType.CLIENT_RESOURCES) return null;
if (!DecoFashion.MODID.equals(location.getNamespace())) return null;
String path = location.getPath();
Map<Identifier, ClosetCatalog.Entry> catalog = ClosetRegistry.catalog();
if (path.startsWith("blockstates/") && path.endsWith(".json")) {
String id = extract(path, "blockstates/");
if (id != null && catalog.containsKey(idOf(id))) {
return supplier(generateBlockstateJson(id));
}
}
if (path.startsWith("models/block/") && path.endsWith(".json")) {
String id = extract(path, "models/block/");
ClosetCatalog.Entry entry = id == null ? null : catalog.get(idOf(id));
if (entry != null) {
return supplier(generateBlockModelJson(entry.texture()));
}
}
if (path.startsWith("models/item/") && path.endsWith(".json")) {
String id = extract(path, "models/item/");
if (id != null && catalog.containsKey(idOf(id))) {
return supplier(generateItemModelJson(id));
}
}
if (path.startsWith("items/") && path.endsWith(".json")) {
String id = extract(path, "items/");
if (id != null && catalog.containsKey(idOf(id))) {
return supplier(generateItemDefinitionJson(id));
}
}
return null;
}
@Override
public void listResources(PackType packType, String namespace, String path,
PackResources.ResourceOutput output) {
if (packType != PackType.CLIENT_RESOURCES) return;
if (!DecoFashion.MODID.equals(namespace)) return;
Map<Identifier, ClosetCatalog.Entry> catalog = ClosetRegistry.catalog();
if ("blockstates".equals(path) || path.startsWith("blockstates/")) {
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
emit(output, "blockstates/" + e.getKey().getPath() + ".json",
generateBlockstateJson(e.getKey().getPath()));
}
}
if ("models".equals(path) || "models/block".equals(path) || path.startsWith("models/block/")) {
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
emit(output, "models/block/" + e.getKey().getPath() + ".json",
generateBlockModelJson(e.getValue().texture()));
}
}
if ("models".equals(path) || "models/item".equals(path) || path.startsWith("models/item/")) {
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
emit(output, "models/item/" + e.getKey().getPath() + ".json",
generateItemModelJson(e.getKey().getPath()));
}
}
if ("items".equals(path) || path.startsWith("items/")) {
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
emit(output, "items/" + e.getKey().getPath() + ".json",
generateItemDefinitionJson(e.getKey().getPath()));
}
}
}
@Override
public Set<String> getNamespaces(PackType packType) {
return packType == PackType.CLIENT_RESOURCES ? Set.of(DecoFashion.MODID) : Set.of();
}
@Override
public @Nullable <T> T getMetadataSection(MetadataSectionType<T> type) throws IOException {
return null;
}
@Override
public PackLocationInfo location() {
return locationInfo;
}
@Override
public void close() {}
// --- Helpers ---
private static @Nullable String extract(String path, String prefix) {
String name = path.substring(prefix.length(), path.length() - ".json".length());
return name.isEmpty() ? null : name;
}
private static Identifier idOf(String path) {
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, path);
}
private static IoSupplier<InputStream> supplier(String json) {
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
return () -> new ByteArrayInputStream(bytes);
}
private static void emit(PackResources.ResourceOutput output, String path, String json) {
Identifier id = Identifier.fromNamespaceAndPath(DecoFashion.MODID, path);
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
output.accept(id, () -> new ByteArrayInputStream(bytes));
}
}

@ -63,6 +63,8 @@ public final class ClientSharedCosmeticCache {
int bbmodelLen;
int width;
int height;
int frametime;
int frames;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
}
@ -105,7 +107,7 @@ public final class ClientSharedCosmeticCache {
* state; mismatches clear in-flight state.
*/
public static void onChunk(String hash, int index, int total, int bbmodelLen,
int width, int height, byte[] data) {
int width, int height, int frametime, int frames, byte[] data) {
if (!SharedCosmeticCache.isValidHash(hash)) {
DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash");
return;
@ -127,6 +129,16 @@ public final class ClientSharedCosmeticCache {
IN_FLIGHT.remove(hash);
return;
}
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0 && frametime <= SharedCosmeticCache.MAX_FLIPBOOK_FRAMETIME
&& frames > 1 && frames <= SharedCosmeticCache.MAX_FLIPBOOK_FRAMES
&& height % frames == 0);
if (!staticFb && !animatedFb) {
DecoFashion.LOGGER.warn("shared cosmetic {}: bad flipbook {}t × {}f (h={})",
hash, frametime, frames, height);
IN_FLIGHT.remove(hash);
return;
}
if (data == null || data.length > SharedCosmeticCache.MAX_CHUNK_BYTES) {
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash);
IN_FLIGHT.remove(hash);
@ -144,9 +156,12 @@ public final class ClientSharedCosmeticCache {
d.bbmodelLen = bbmodelLen;
d.width = width;
d.height = height;
d.frametime = frametime;
d.frames = frames;
d.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index
|| d.bbmodelLen != bbmodelLen || d.width != width || d.height != height) {
|| d.bbmodelLen != bbmodelLen || d.width != width || d.height != height
|| d.frametime != frametime || d.frames != frames) {
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk mismatch at {}/{}", hash, index, total);
IN_FLIGHT.remove(hash);
return;
@ -187,10 +202,12 @@ public final class ClientSharedCosmeticCache {
// Bake first; only write the blob to disk after it validates. Writing first would
// leave a rejected blob lingering on disk, where scanDisk would keep re-hydrating it
// and failing forever.
if (!bakeAndRegister(hash, bbmodelBin, d.width, d.height, deflatedRgba)) {
if (!bakeAndRegister(hash, bbmodelBin, d.width, d.height,
d.frametime, d.frames, deflatedRgba)) {
return;
}
byte[] blob = SharedCosmeticCache.buildBlob(bbmodelBin, d.width, d.height, deflatedRgba);
byte[] blob = SharedCosmeticCache.buildBlob(bbmodelBin, d.width, d.height,
d.frametime, d.frames, deflatedRgba);
try {
Files.createDirectories(root());
Files.write(fileFor(hash), blob);
@ -212,7 +229,8 @@ public final class ClientSharedCosmeticCache {
Files.deleteIfExists(fileFor(hash));
throw new IOException("blob corrupt");
}
boolean ok = bakeAndRegister(hash, view.bbmodelBinary(), view.width(), view.height(), view.deflatedRgba());
boolean ok = bakeAndRegister(hash, view.bbmodelBinary(), view.width(), view.height(),
view.frametime(), view.frames(), view.deflatedRgba());
if (!ok) {
Files.deleteIfExists(fileFor(hash));
throw new IOException("bake rejected");
@ -229,7 +247,9 @@ public final class ClientSharedCosmeticCache {
* disk that subsequent {@code scanDisk} calls would keep re-hydrating and failing.
*/
private static boolean bakeAndRegister(String hash, byte[] bbmodelBin,
int width, int height, byte[] deflatedRgba) {
int width, int height,
int frametime, int frames,
byte[] deflatedRgba) {
Bbmodel bbmodel;
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin);
try {
@ -271,15 +291,37 @@ public final class ClientSharedCosmeticCache {
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
}
}
// Replace any previous registration for this hash (e.g. earlier failed bake) so
// we don't leak a dangling DynamicTexture or stale FlipbookTicker entry.
TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier texId = textureIdFor(hash);
tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img));
com.razz.dfashion.client.FlipbookTicker.unregister(texId);
tm.release(texId);
if (frames > 1) {
// Animated: live texture is one frame; FlipbookTicker swaps in subsequent frames.
int frameH = height / frames;
NativeImage frame = new NativeImage(width, frameH, false);
img.copyRect(frame, 0, 0, 0, 0, width, frameH, false, false);
DynamicTexture liveTex = new DynamicTexture(() -> "decofashion shared " + hash, frame);
tm.register(texId, liveTex);
com.razz.dfashion.client.FlipbookTicker.register(
texId,
new com.razz.dfashion.client.FlipbookTicker.Entry(
img, liveTex, frametime, frames, width, frameH)
);
} else {
tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img));
}
Map<String, ModelPart> parts = BbmodelBaker.bake(
bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true);
BAKED.put(hash, new CosmeticCache.Baked(parts, texId));
DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{} parts={}",
hash, width, height, parts.keySet());
DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{}{} parts={}",
hash, width, height,
frames > 1 ? " flipbook=" + frametime + "t×" + frames + "f" : "",
parts.keySet());
return true;
}
@ -298,6 +340,7 @@ public final class ClientSharedCosmeticCache {
CosmeticCache.Baked removed = BAKED.remove(hash);
boolean changed = removed != null;
if (removed != null) {
com.razz.dfashion.client.FlipbookTicker.unregister(removed.texture());
try {
Minecraft.getInstance().getTextureManager().release(removed.texture());
} catch (Throwable t) {

@ -41,12 +41,27 @@ public final class ClientSharedCosmeticUploader {
private ClientSharedCosmeticUploader() {}
public static String upload(Path bbmodelFile, Path textureFile, String displayName) {
String result = doUpload(bbmodelFile, textureFile, displayName);
return upload(bbmodelFile, textureFile, displayName, 0, 1);
}
public static String upload(Path bbmodelFile, Path textureFile, String displayName,
int frametime, int frames) {
String result = doUpload(bbmodelFile, textureFile, displayName, frametime, frames);
DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result);
return result;
}
private static String doUpload(Path bbmodelFile, Path textureFile, String displayName) {
private static String doUpload(Path bbmodelFile, Path textureFile, String displayName,
int frametime, int frames) {
// Validate the flipbook spec early so we don't waste a parse on an obviously bad input.
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0
&& frametime <= com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMETIME
&& frames > 1
&& frames <= com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMES);
if (!staticFb && !animatedFb) {
return "Bad flipbook: frametime=" + frametime + " frames=" + frames;
}
if (!Files.isRegularFile(bbmodelFile)) return "Not a file: " + bbmodelFile;
if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile;
@ -93,11 +108,19 @@ public final class ClientSharedCosmeticUploader {
out.release();
}
// 4. Compute content hash — must match what the server will compute.
// 4. For flipbooks, the PNG height must divide cleanly by frame count. Reject early
// so the server doesn't have to.
if (frames > 1 && decoded.height % frames != 0) {
return "Flipbook frames=" + frames + " doesn't divide texture height " + decoded.height;
}
// 5. Compute content hash — must match what the server will compute.
String hash;
try {
hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin,
decoded.width, decoded.height, decoded.rgba);
decoded.width, decoded.height,
frametime, frames,
decoded.rgba);
} catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable";
}
@ -130,6 +153,7 @@ public final class ClientSharedCosmeticUploader {
new UploadCosmeticChunk(
i, total, bbmodelLen,
decoded.width, decoded.height,
frametime, frames,
slice
)));
}

@ -48,6 +48,10 @@ public final class ClientSkinCache {
int nextIndex;
int width;
int height;
int frametime;
int frames;
int holdFrame;
int holdFrametime;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
}
@ -82,20 +86,17 @@ public final class ClientSkinCache {
try {
byte[] blob = Files.readAllBytes(fileFor(hash));
int[] dims = SkinCache.readHeader(blob);
if (dims == null) {
SkinCache.BlobView view = SkinCache.readBlob(blob);
if (view == null) {
DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash);
return null;
}
int w = dims[0], h = dims[1];
if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) {
DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h);
return null;
}
byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE];
System.arraycopy(blob, SkinCache.HEADER_SIZE, deflated, 0, deflated.length);
byte[] rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(w, h));
return registerFromPixels(hash, w, h, rgba);
byte[] rgba = SkinCache.inflateBounded(view.deflatedRgba(),
SkinCache.rgbaByteCount(view.width(), view.height()));
return registerFromPixels(hash, view.width(), view.height(),
view.frametime(), view.frames(),
view.holdFrame(), view.holdFrametime(),
rgba);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex);
return null;
@ -116,12 +117,35 @@ public final class ClientSkinCache {
* (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more
* chunks are expected or on error.
*/
public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) {
if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) {
public static Identifier onChunk(String hash, int index, int total, int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] data) {
if (width <= 0 || width > SkinCache.MAX_DIM
|| height <= 0 || height > SkinCache.MAX_STRIP_HEIGHT) {
DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height);
IN_FLIGHT.remove(hash);
return null;
}
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0 && frametime <= SkinCache.MAX_FLIPBOOK_FRAMETIME
&& frames > 1 && frames <= SkinCache.MAX_FLIPBOOK_FRAMES
&& height % frames == 0);
if (!staticFb && !animatedFb) {
DecoFashion.LOGGER.warn("Skin download {}: bad flipbook {}t × {}f (h={})",
hash, frametime, frames, height);
IN_FLIGHT.remove(hash);
return null;
}
if (holdFrametime < 0 || holdFrametime > SkinCache.MAX_FLIPBOOK_FRAMETIME
|| (holdFrametime == 0 && holdFrame != 0)
|| (holdFrametime > 0 && (holdFrame < 0 || holdFrame >= frames))
|| (staticFb && (holdFrame != 0 || holdFrametime != 0))) {
DecoFashion.LOGGER.warn("Skin download {}: bad hold spec frame={} ticks={}",
hash, holdFrame, holdFrametime);
IN_FLIGHT.remove(hash);
return null;
}
Download d = IN_FLIGHT.get(hash);
if (d == null) {
@ -133,9 +157,15 @@ public final class ClientSkinCache {
d.nextIndex = 0;
d.width = width;
d.height = height;
d.frametime = frametime;
d.frames = frames;
d.holdFrame = holdFrame;
d.holdFrametime = holdFrametime;
d.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index
|| d.width != width || d.height != height) {
|| d.width != width || d.height != height
|| d.frametime != frametime || d.frames != frames
|| d.holdFrame != holdFrame || d.holdFrametime != holdFrametime) {
DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total);
IN_FLIGHT.remove(hash);
return null;
@ -170,12 +200,19 @@ public final class ClientSkinCache {
try {
Files.createDirectories(root());
Files.write(fileFor(hash), SkinCache.buildBlob(d.width, d.height, deflated));
Files.write(fileFor(hash),
SkinCache.buildBlob(d.width, d.height,
d.frametime, d.frames,
d.holdFrame, d.holdFrametime,
deflated));
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex);
}
return registerFromPixels(hash, d.width, d.height, rgba);
return registerFromPixels(hash, d.width, d.height,
d.frametime, d.frames,
d.holdFrame, d.holdFrametime,
rgba);
}
/**
@ -183,8 +220,15 @@ public final class ClientSkinCache {
* is involved {@code NativeImage} is allocated in RGBA format and each pixel is written
* via {@code setPixelABGR}, where the int is packed so the native {@code memPutInt} lays
* down bytes in the order {@code [R, G, B, A]} (little-endian platforms).
*
* <p>For flipbook skins (frames > 1), the live texture is sized to a single frame and the
* source strip is handed to {@link com.razz.dfashion.client.FlipbookTicker} so subsequent
* frames get blitted in per the frametime cadence.
*/
private static Identifier registerFromPixels(String hash, int width, int height, byte[] rgba) {
private static Identifier registerFromPixels(String hash, int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] rgba) {
int expected;
try {
expected = SkinCache.rgbaByteCount(width, height);
@ -208,11 +252,35 @@ public final class ClientSkinCache {
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
}
}
TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier id = textureIdFor(hash);
tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img));
// Releasing the previous registration (if any) prevents leaking when a previous
// failed bake left a stale entry. Mirrors the cosmetic shared-cache cleanup.
com.razz.dfashion.client.FlipbookTicker.unregister(id);
tm.release(id);
if (frames > 1) {
int frameH = height / frames;
NativeImage frame = new NativeImage(width, frameH, false);
img.copyRect(frame, 0, 0, 0, 0, width, frameH, false, false);
DynamicTexture liveTex = new DynamicTexture(() -> "decofashion skin " + hash, frame);
tm.register(id, liveTex);
com.razz.dfashion.client.FlipbookTicker.register(
id,
new com.razz.dfashion.client.FlipbookTicker.Entry(
img, liveTex, frametime, frames, width, frameH,
holdFrame, holdFrametime)
);
} else {
tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img));
}
REGISTERED.put(hash, id);
DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}", hash, id, width, height);
DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}{}{}",
hash, id, width, height,
frames > 1 ? " flipbook=" + frametime + "t×" + frames + "f" : "",
holdFrametime > 0 ? " hold=" + holdFrame + "@" + holdFrametime + "t" : "");
return id;
}
@ -255,6 +323,7 @@ public final class ClientSkinCache {
Identifier id = REGISTERED.remove(hash);
boolean changed = id != null;
if (id != null) {
com.razz.dfashion.client.FlipbookTicker.unregister(id);
try {
Minecraft.getInstance().getTextureManager().release(id);
} catch (Throwable t) {
@ -277,12 +346,37 @@ public final class ClientSkinCache {
* it immediately. Returns a short user-facing status string.
*/
public static String uploadFromFile(Path file, SkinModel model) {
String result = doUpload(file, model);
return uploadFromFile(file, model, 0, 1, 0, 0);
}
public static String uploadFromFile(Path file, SkinModel model, int frametime, int frames) {
return uploadFromFile(file, model, frametime, frames, 0, 0);
}
public static String uploadFromFile(Path file, SkinModel model,
int frametime, int frames,
int holdFrame, int holdFrametime) {
String result = doUpload(file, model, frametime, frames, holdFrame, holdFrametime);
DecoFashion.LOGGER.info("Skin upload: {}", result);
return result;
}
private static String doUpload(Path file, SkinModel model) {
private static String doUpload(Path file, SkinModel model,
int frametime, int frames,
int holdFrame, int holdFrametime) {
// Validate flipbook spec early — same shape rule as the server: (0, 1) static or (>0, ≥2).
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0 && frametime <= SkinCache.MAX_FLIPBOOK_FRAMETIME
&& frames > 1 && frames <= SkinCache.MAX_FLIPBOOK_FRAMES);
if (!staticFb && !animatedFb) {
return "Bad flipbook: frametime=" + frametime + " frames=" + frames;
}
if (holdFrametime < 0 || holdFrametime > SkinCache.MAX_FLIPBOOK_FRAMETIME
|| (holdFrametime == 0 && holdFrame != 0)
|| (holdFrametime > 0 && (holdFrame < 0 || holdFrame >= frames))
|| (staticFb && (holdFrame != 0 || holdFrametime != 0))) {
return "Bad hold: frame=" + holdFrame + " ticks=" + holdFrametime;
}
if (!Files.isRegularFile(file)) return "Not a file: " + file;
byte[] png;
@ -303,6 +397,12 @@ public final class ClientSkinCache {
return "Rejected: " + ex.getMessage();
}
// For a flipbook, the PNG height must divide cleanly by frame count. Reject early.
if (frames > 1 && decoded.height % frames != 0) {
return "Flipbook frames=" + frames
+ " doesn't divide texture height " + decoded.height;
}
byte[] deflated = deflate(decoded.rgba);
if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) {
return "Too large after compression: " + deflated.length;
@ -313,7 +413,8 @@ public final class ClientSkinCache {
String localHash;
try {
localHash = sha256HexOfPixels(decoded.width, decoded.height, decoded.rgba);
localHash = SkinCache.sha256HexOfPixels(decoded.width, decoded.height,
frametime, frames, holdFrame, holdFrametime, decoded.rgba);
} catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable";
}
@ -321,10 +422,17 @@ public final class ClientSkinCache {
try {
Files.createDirectories(root());
if (!hasOnDisk(localHash)) {
Files.write(fileFor(localHash), SkinCache.buildBlob(decoded.width, decoded.height, deflated));
Files.write(fileFor(localHash),
SkinCache.buildBlob(decoded.width, decoded.height,
frametime, frames,
holdFrame, holdFrametime,
deflated));
}
if (!REGISTERED.containsKey(localHash)) {
registerFromPixels(localHash, decoded.width, decoded.height, decoded.rgba);
registerFromPixels(localHash, decoded.width, decoded.height,
frametime, frames,
holdFrame, holdFrametime,
decoded.rgba);
}
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage());
@ -338,7 +446,10 @@ public final class ClientSkinCache {
byte[] slice = new byte[len];
System.arraycopy(deflated, off, slice, 0, len);
conn.send(new ServerboundCustomPayloadPacket(
new UploadSkinChunk(i, total, decoded.width, decoded.height, model, slice)));
new UploadSkinChunk(i, total, decoded.width, decoded.height,
frametime, frames,
holdFrame, holdFrametime,
model, slice)));
}
return "Uploading " + decoded.width + "x" + decoded.height + " ("
+ deflated.length + " bytes, " + total + " chunks, hash "
@ -363,19 +474,6 @@ public final class ClientSkinCache {
}
}
private static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update((byte) ((width >>> 8) & 0xFF));
md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 0xFF));
md.update(rgba);
byte[] out = md.digest();
StringBuilder sb = new StringBuilder(out.length * 2);
for (byte b : out) sb.append(String.format("%02x", b));
return sb.toString();
}
public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) {
ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return;

@ -9,6 +9,7 @@ import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.state.BlockState;
import org.joml.Vector3f;
@ -20,11 +21,14 @@ public final class ClosetClientHandler {
LocalPlayer player = mc.player;
if (player == null || mc.level == null) return;
ClosetModelCache.Baked closed = ClosetModelCache.closed;
if (closed != null) {
Vector3f anchor = closed.locators().get("player_stand_location");
if (anchor != null) {
teleportToAnchor(player, pos, state.getValue(ClosetBlock.FACING), anchor);
Identifier variantId = state.getBlock() instanceof ClosetBlock cb ? cb.variantId() : null;
if (variantId != null) {
ClosetModelCache.Variant variant = ClosetModelCache.byVariant.get(variantId);
if (variant != null && variant.closed() != null) {
Vector3f anchor = variant.closed().locators().get("player_stand_location");
if (anchor != null) {
teleportToAnchor(player, pos, state.getValue(ClosetBlock.FACING), anchor);
}
}
}
@ -33,14 +37,14 @@ public final class ClosetClientHandler {
}
// Must mirror ClosetRenderer's net transform for the point to line up with the
// locator's visual position. The renderer does `rotateY(180° - facing.toYRot())`
// then `scale(-1, 1, 1)`, which applied to a bbmodel (x, z) point yields:
// NORTH → (-x, z)
// SOUTH → ( x, -z)
// EAST → (-z, -x)
// WEST → ( z, x)
// These are NOT the same as decocraft's rotatePosition — decocraft doesn't bake
// its cubes with mirrorX=true, so it has no compensating scale flip to account for.
// locator's visual position. The renderer applies `rotateY(180° - facing.toYRot())`
// (no scale flip — natural-UV bake matches decocraft's renderer), which with
// Direction.toYRot() = {NORTH:180, SOUTH:0, WEST:90, EAST:270} takes a bbmodel
// (x, z) point to:
// NORTH → ( x, z) (rotation = 0°)
// SOUTH → (-x, -z) (rotation = 180°)
// EAST → (-z, x) (rotation = -90°)
// WEST → ( z, -x) (rotation = 90°)
private static void teleportToAnchor(LocalPlayer player, BlockPos blockPos,
Direction facing, Vector3f anchor) {
float lx = anchor.x / 16f;
@ -49,11 +53,11 @@ public final class ClosetClientHandler {
double rx, rz;
switch (facing) {
case NORTH -> { rx = -lx; rz = lz; }
case SOUTH -> { rx = lx; rz = -lz; }
case EAST -> { rx = -lz; rz = -lx; }
case WEST -> { rx = lz; rz = lx; }
default -> { rx = -lx; rz = lz; }
case NORTH -> { rx = lx; rz = lz; }
case SOUTH -> { rx = -lx; rz = -lz; }
case EAST -> { rx = -lz; rz = lx; }
case WEST -> { rx = lz; rz = -lx; }
default -> { rx = lx; rz = lz; }
}
double worldX = blockPos.getX() + 0.5 + rx;

@ -6,17 +6,15 @@ import net.minecraft.resources.Identifier;
import org.joml.Vector3f;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class ClosetModelCache {
public record Baked(
Map<String, ModelPart> parts,
Map<String, Vector3f> locators,
Identifier texture
) {}
public record State(Map<String, ModelPart> parts, Map<String, Vector3f> locators) {}
public static volatile Baked closed = null;
public static volatile Baked open = null;
public record Variant(State closed, State open, Identifier texture) {}
public static final Map<Identifier, Variant> byVariant = new ConcurrentHashMap<>();
private ClosetModelCache() {}
}

@ -2,8 +2,10 @@ package com.razz.dfashion.client;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
public class ClosetRenderState extends BlockEntityRenderState {
public boolean open = false;
public Direction facing = Direction.NORTH;
public Identifier variantId = null;
}

@ -33,35 +33,38 @@ public class ClosetRenderer implements BlockEntityRenderer<ClosetBlockEntity, Cl
BlockEntityRenderState.extractBase(be, state, breakProgress);
state.open = be.isOpen();
state.facing = be.getBlockState().getValue(ClosetBlock.FACING);
state.variantId = be.getBlockState().getBlock() instanceof ClosetBlock cb ? cb.variantId() : null;
}
@Override
public void submit(ClosetRenderState state, PoseStack poseStack,
SubmitNodeCollector collector, CameraRenderState camera) {
ClosetModelCache.Baked baked = state.open ? ClosetModelCache.open : ClosetModelCache.closed;
if (state.variantId == null) return;
ClosetModelCache.Variant variant = ClosetModelCache.byVariant.get(state.variantId);
if (variant == null) return;
ClosetModelCache.State baked = state.open ? variant.open() : variant.closed();
if (baked == null) return;
poseStack.pushPose();
// Center on the block, then rotate to face the placed direction.
// Our bbmodels author the closet's door/front on -Z (not Blockbench's
// canonical +Z), so rotation = 180° - facing.toYRot() takes bbmodel -Z
// to world FACING. ModelPart.Cube already stores vertices pre-scaled by
// 1/16, so no additional pixel→block scaling is needed here.
// to world FACING. (Direction.toYRot() values: NORTH=180, SOUTH=0,
// WEST=90, EAST=270 — note EAST is 270 not 90, the formula relies on
// these specific values.) ModelPart.Cube already stores vertices
// pre-scaled by 1/16, so no additional pixel→block scaling is needed here.
poseStack.translate(0.5f, 0.0f, 0.5f);
poseStack.mulPose(new org.joml.Quaternionf().rotationY(
(float) Math.toRadians(180f - state.facing.toYRot())
));
// Cubes baked with mirrorX=true have X-flipped vertex winding (matches the
// cosmetic pipeline). BEs don't get the entity-renderer pre-flip, so we
// apply a compensating X-flip here to keep UVs landing on the right faces.
poseStack.scale(-1.0f, 1.0f, 1.0f);
for (ModelPart part : baked.parts().values()) {
collector.submitModelPart(
part,
poseStack,
RenderTypes.entityTranslucent(baked.texture()),
RenderTypes.entityTranslucent(variant.texture()),
state.lightCoords,
OverlayTexture.NO_OVERLAY,
null

@ -45,9 +45,9 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords,
AvatarRenderState state, float yRot, float xRot) {
// Local cache may be empty (no built-in / user-folder cosmetics loaded) yet the player
// can still have Shared refs that resolve through ClientSharedCosmeticCache. Don't
// early-return on local emptiness.
// Local cache may be empty (no built-in cosmetics loaded) yet the player can still
// have Shared refs that resolve through ClientSharedCosmeticCache. Don't early-return
// on local emptiness.
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
Minecraft mc = Minecraft.getInstance();

@ -0,0 +1,115 @@
package com.razz.dfashion.client;
import com.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Per-tick frame advancer for animated cosmetic / closet textures. Each registered entry owns:
* - a source {@link NativeImage} (the full vertical flipbook strip)
* - the live single-frame {@link DynamicTexture} bound to the renderer's texture id
* - a {@code frametime} (ticks/frame) and {@code frames} count
*
* <p>On each client tick, every entry's elapsed-tick counter advances. When it crosses the
* frametime threshold, the next frame's pixel rows are blitted into the live texture and
* {@link DynamicTexture#upload()} pushes them to the GPU.
*
* <p>Generic over the texture id keys are arbitrary {@link Identifier}s. Closets use
* {@code decofashion_closet:<variant>}; built-in cosmetics use {@code decofashion_cosmetic:<id>};
* shared cosmetics use {@code decofashion_shared:<hash>}. Re-registering under the same id
* replaces the previous entry and closes its source image.
*/
@EventBusSubscriber(modid = com.razz.dfashion.DecoFashion.MODID, value = Dist.CLIENT)
public final class FlipbookTicker {
public static final class Entry {
final NativeImage source;
final DynamicTexture target;
final int frametime;
final int frames;
final int frameWidth;
final int frameHeight;
/** Frame index whose display gets extended to {@link #holdFrametime} ticks. Ignored
* when {@code holdFrametime == 0} (no hold). For a blink-style cycle, this is the
* "eyes open" frame and {@code holdFrametime} is the gap between blinks. */
final int holdFrame;
/** Display time in ticks for {@link #holdFrame}. {@code 0} = use the regular
* {@link #frametime} for every frame (no hold). */
final int holdFrametime;
int frameIndex = 0;
int tickInFrame = 0;
public Entry(NativeImage source, DynamicTexture target,
int frametime, int frames, int frameWidth, int frameHeight) {
this(source, target, frametime, frames, frameWidth, frameHeight, 0, 0);
}
public Entry(NativeImage source, DynamicTexture target,
int frametime, int frames, int frameWidth, int frameHeight,
int holdFrame, int holdFrametime) {
this.source = source;
this.target = target;
this.frametime = frametime;
this.frames = frames;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.holdFrame = holdFrame;
this.holdFrametime = holdFrametime;
}
}
/** Keyed by the texture id so re-registering on resource reload replaces the old entry. */
private static final Map<Identifier, Entry> ENTRIES = new ConcurrentHashMap<>();
private FlipbookTicker() {}
public static void register(Identifier textureId, Entry entry) {
Entry old = ENTRIES.put(textureId, entry);
if (old != null && old.source != entry.source) {
old.source.close();
}
}
public static void unregister(Identifier textureId) {
Entry old = ENTRIES.remove(textureId);
if (old != null) old.source.close();
}
public static void clear() {
for (Entry e : ENTRIES.values()) {
e.source.close();
}
ENTRIES.clear();
}
@SubscribeEvent
static void onClientTick(ClientTickEvent.Pre event) {
if (ENTRIES.isEmpty()) return;
for (Entry e : ENTRIES.values()) {
e.tickInFrame++;
int currentFrametime = (e.holdFrametime > 0 && e.frameIndex == e.holdFrame)
? e.holdFrametime
: e.frametime;
if (e.tickInFrame < currentFrametime) continue;
e.tickInFrame = 0;
e.frameIndex = (e.frameIndex + 1) % e.frames;
blitFrame(e);
e.target.upload();
}
}
private static void blitFrame(Entry e) {
NativeImage dst = e.target.getPixels();
if (dst == null) return;
int srcYOffset = e.frameIndex * e.frameHeight;
e.source.copyRect(dst, 0, srcYOffset, 0, 0, e.frameWidth, e.frameHeight, false, false);
}
}

@ -29,7 +29,13 @@ public final class SafeImageLoader {
if (size > MAX_PNG_FILE_BYTES) {
throw new IOException("PNG file too large: " + size + " > " + MAX_PNG_FILE_BYTES);
}
byte[] png = Files.readAllBytes(file);
return loadNativeImage(Files.readAllBytes(file));
}
public static NativeImage loadNativeImage(byte[] png) throws IOException {
if (png.length > MAX_PNG_FILE_BYTES) {
throw new IOException("PNG too large: " + png.length + " > " + MAX_PNG_FILE_BYTES);
}
SafePngReader.Image decoded = SafePngReader.decode(png);
NativeImage img = new NativeImage(decoded.width, decoded.height, false);
int src = 0;

@ -188,6 +188,30 @@ public class ClosetScreen extends Screen {
private @Nullable StringWidget shareStatusLabel;
private String shareStatusMessage = "";
/** Flipbook section state preserved across form rebuilds (toggle re-init). Empty by
* default so the EditBox hint is visible until the user types something. */
private boolean shareFlipbookEnabled = false;
private String shareFlipbookFramesText = "";
private String shareFlipbookFrametimeText = "";
private @Nullable EditBox shareFlipbookFramesField;
private @Nullable EditBox shareFlipbookFrametimeField;
// ---- Flipbook-skin upload form state (separate dialog from cosmetic share form). ----
private boolean showingSkinFlipbookForm = false;
private String skinFlipbookPathText = "";
private String skinFlipbookFramesText = "";
private String skinFlipbookFrametimeText = "";
private String skinFlipbookHoldFrameText = "";
private String skinFlipbookHoldTicksText = "";
private com.razz.dfashion.skin.SkinModel skinFlipbookModel = com.razz.dfashion.skin.SkinModel.WIDE;
private String skinFlipbookStatusMessage = "";
private @Nullable EditBox skinFlipbookPathField;
private @Nullable EditBox skinFlipbookFramesField;
private @Nullable EditBox skinFlipbookFrametimeField;
private @Nullable EditBox skinFlipbookHoldFrameField;
private @Nullable EditBox skinFlipbookHoldTicksField;
private @Nullable StringWidget skinFlipbookStatusLabel;
/** UX cap on displayName shown in the field. Wire codec caps at 128; 64 is user-friendly. */
private static final int SHARE_NAME_MAX_LEN = 64;
/** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */
@ -260,6 +284,10 @@ public class ClosetScreen extends Screen {
buildShareForm();
return;
}
if (showingSkinFlipbookForm) {
buildSkinFlipbookForm();
return;
}
lastSharedEnabled = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
lastBodyHideEnabled = DecoFashionConfig.BODY_HIDE_ENABLED.get();
@ -545,9 +573,9 @@ public class ClosetScreen extends Screen {
int gridY = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Center the 3-button action column vertically against the 36-tall grid row.
int columnHeight = SKIN_ACTION_H * 3 + SKIN_ACTION_GAP * 2; // 68
int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2; // gridY - 16
// Center the 4-button action column vertically against the 36-tall grid row.
int columnHeight = SKIN_ACTION_H * 4 + SKIN_ACTION_GAP * 3; // 92
int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2;
Button upload = Button.builder(
Component.literal("Upload Skin"),
@ -556,10 +584,18 @@ public class ClosetScreen extends Screen {
addRenderableWidget(upload);
cosmeticButtons.add(upload);
Button flipbook = Button.builder(
Component.literal("Flipbook Skin"),
b -> openSkinFlipbookForm()
).bounds(COSMETIC_ROW_LEFT, actionYTop + SKIN_ACTION_H + SKIN_ACTION_GAP,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(flipbook);
cosmeticButtons.add(flipbook);
Button toggle = Button.builder(
Component.literal(pendingSkinModel == SkinModel.SLIM ? "Slim" : "Wide"),
b -> flipSkinModel()
).bounds(COSMETIC_ROW_LEFT, actionYTop + SKIN_ACTION_H + SKIN_ACTION_GAP,
).bounds(COSMETIC_ROW_LEFT, actionYTop + (SKIN_ACTION_H + SKIN_ACTION_GAP) * 2,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(toggle);
cosmeticButtons.add(toggle);
@ -572,7 +608,7 @@ public class ClosetScreen extends Screen {
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll();
}
).bounds(COSMETIC_ROW_LEFT, actionYTop + (SKIN_ACTION_H + SKIN_ACTION_GAP) * 2,
).bounds(COSMETIC_ROW_LEFT, actionYTop + (SKIN_ACTION_H + SKIN_ACTION_GAP) * 3,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(reset);
cosmeticButtons.add(reset);
@ -919,6 +955,11 @@ public class ClosetScreen extends Screen {
shareNameField = null;
shareStatusLabel = null;
shareStatusMessage = "";
shareFlipbookFramesField = null;
shareFlipbookFrametimeField = null;
shareFlipbookEnabled = false;
shareFlipbookFramesText = "";
shareFlipbookFrametimeText = "";
rebuildWidgets();
}
@ -979,6 +1020,49 @@ public class ClosetScreen extends Screen {
addRenderableWidget(shareNameField);
y += fieldH + rowGap;
// Flipbook row. Toggle button takes the left half; when enabled, the right half
// splits into two short EditBoxes for "Frames" and "Speed (ticks/frame)".
int toggleW = (fieldW - 10) / 2;
Button flipToggle = Button.builder(
Component.literal("Flipbook: " + (shareFlipbookEnabled ? "ON" : "OFF")),
b -> {
// Persist current EditBox text into the cached strings before rebuild,
// so the user doesn't lose what they typed when they flip the toggle.
if (shareFlipbookFramesField != null) {
shareFlipbookFramesText = shareFlipbookFramesField.getValue();
}
if (shareFlipbookFrametimeField != null) {
shareFlipbookFrametimeText = shareFlipbookFrametimeField.getValue();
}
shareFlipbookEnabled = !shareFlipbookEnabled;
rebuildWidgets();
}
).bounds(x0 + 10, y, toggleW, fieldH).build();
addRenderableWidget(flipToggle);
if (shareFlipbookEnabled) {
int halfW = (fieldW - 10 - toggleW - 5) / 2;
shareFlipbookFramesField = new EditBox(this.font,
x0 + 10 + toggleW + 5, y, halfW, fieldH,
Component.literal("Frames"));
shareFlipbookFramesField.setMaxLength(4);
shareFlipbookFramesField.setHint(Component.literal("Frames (e.g. 8)"));
shareFlipbookFramesField.setValue(shareFlipbookFramesText);
addRenderableWidget(shareFlipbookFramesField);
shareFlipbookFrametimeField = new EditBox(this.font,
x0 + 10 + toggleW + 5 + halfW + 5, y, halfW, fieldH,
Component.literal("Speed"));
shareFlipbookFrametimeField.setMaxLength(4);
shareFlipbookFrametimeField.setHint(Component.literal("Ticks (e.g. 4)"));
shareFlipbookFrametimeField.setValue(shareFlipbookFrametimeText);
addRenderableWidget(shareFlipbookFrametimeField);
} else {
shareFlipbookFramesField = null;
shareFlipbookFrametimeField = null;
}
y += fieldH + rowGap;
// Status line (updated after submit).
shareStatusLabel = new StringWidget(
x0 + 10, y, fieldW, 15,
@ -1061,7 +1145,35 @@ public class ClosetScreen extends Screen {
if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString());
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
// Flipbook params: 0/1 (static) unless the toggle is on AND both fields parse to
// sensible positive ints. Bad input → status message, don't fall through to upload.
int frametime = 0;
int frames = 1;
if (shareFlipbookEnabled) {
Integer parsedFrames = parsePositiveInt(
shareFlipbookFramesField != null
? shareFlipbookFramesField.getValue() : shareFlipbookFramesText);
Integer parsedFrametime = parsePositiveInt(
shareFlipbookFrametimeField != null
? shareFlipbookFrametimeField.getValue() : shareFlipbookFrametimeText);
if (parsedFrames == null || parsedFrames < 2
|| parsedFrames > com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMES) {
setShareStatus("Frames must be 2.."
+ com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMES);
return;
}
if (parsedFrametime == null || parsedFrametime < 1
|| parsedFrametime > com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMETIME) {
setShareStatus("Speed must be 1.."
+ com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMETIME
+ " ticks/frame");
return;
}
frames = parsedFrames;
frametime = parsedFrametime;
}
String result = ClientSharedCosmeticUploader.upload(model, texture, name, frametime, frames);
setShareStatus(result);
// Close the form on successful submission. "Uploading " means chunks went to the
@ -1083,6 +1195,258 @@ public class ClosetScreen extends Screen {
if (shareStatusLabel != null) shareStatusLabel.setMessage(Component.literal(shareStatusMessage));
}
// ---- Skin flipbook upload form ----
private void openSkinFlipbookForm() {
// Seed the model toggle from the current pending model so users don't have to
// reselect Slim/Wide if they already picked it on the main skin tab.
skinFlipbookModel = pendingSkinModel;
showingSkinFlipbookForm = true;
rebuildWidgets();
}
private void closeSkinFlipbookForm() {
// Persist the EditBoxes' current values so the user doesn't lose typed input
// if they reopen the dialog.
if (skinFlipbookPathField != null) skinFlipbookPathText = skinFlipbookPathField.getValue();
if (skinFlipbookFramesField != null) skinFlipbookFramesText = skinFlipbookFramesField.getValue();
if (skinFlipbookFrametimeField != null) skinFlipbookFrametimeText = skinFlipbookFrametimeField.getValue();
if (skinFlipbookHoldFrameField != null) skinFlipbookHoldFrameText = skinFlipbookHoldFrameField.getValue();
if (skinFlipbookHoldTicksField != null) skinFlipbookHoldTicksText = skinFlipbookHoldTicksField.getValue();
showingSkinFlipbookForm = false;
skinFlipbookPathField = null;
skinFlipbookFramesField = null;
skinFlipbookFrametimeField = null;
skinFlipbookHoldFrameField = null;
skinFlipbookHoldTicksField = null;
skinFlipbookStatusLabel = null;
skinFlipbookStatusMessage = "";
rebuildWidgets();
}
/** Modal form: texture path + Slim/Wide + flipbook frames + flipbook ticks + Submit/Cancel. */
private void buildSkinFlipbookForm() {
int panelW = Math.min(360, this.width - 40);
int cx = this.width / 2;
int x0 = cx - panelW / 2;
int fieldW = panelW - 20;
int fieldH = 20;
int rowGap = 10;
int y = this.height / 2 - 90;
StringWidget title = new StringWidget(
x0, y, panelW, 15,
Component.literal("Upload Flipbook Skin"),
this.font
);
addRenderableWidget(title);
y += 22;
// Texture path: EditBox + Browse.
int browseW = 60;
skinFlipbookPathField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
Component.literal("Texture path"));
skinFlipbookPathField.setMaxLength(SHARE_PATH_MAX_LEN);
skinFlipbookPathField.setHint(Component.literal("Path to .png"));
skinFlipbookPathField.setValue(skinFlipbookPathText);
addRenderableWidget(skinFlipbookPathField);
addRenderableWidget(Button.builder(
Component.literal("Browse"),
b -> pickSkinFlipbookPath()
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
y += fieldH + rowGap;
// Slim/Wide toggle + Frames + Ticks row.
int third = (fieldW - 20) / 3;
Button modelToggle = Button.builder(
Component.literal(skinFlipbookModel == com.razz.dfashion.skin.SkinModel.SLIM ? "Slim" : "Wide"),
b -> {
if (skinFlipbookFramesField != null)
skinFlipbookFramesText = skinFlipbookFramesField.getValue();
if (skinFlipbookFrametimeField != null)
skinFlipbookFrametimeText = skinFlipbookFrametimeField.getValue();
if (skinFlipbookPathField != null)
skinFlipbookPathText = skinFlipbookPathField.getValue();
if (skinFlipbookHoldFrameField != null)
skinFlipbookHoldFrameText = skinFlipbookHoldFrameField.getValue();
if (skinFlipbookHoldTicksField != null)
skinFlipbookHoldTicksText = skinFlipbookHoldTicksField.getValue();
skinFlipbookModel = (skinFlipbookModel == com.razz.dfashion.skin.SkinModel.SLIM)
? com.razz.dfashion.skin.SkinModel.WIDE
: com.razz.dfashion.skin.SkinModel.SLIM;
rebuildWidgets();
}
).bounds(x0 + 10, y, third, fieldH).build();
addRenderableWidget(modelToggle);
skinFlipbookFramesField = new EditBox(this.font,
x0 + 10 + third + 5, y, third, fieldH,
Component.literal("Frames"));
skinFlipbookFramesField.setMaxLength(4);
skinFlipbookFramesField.setHint(Component.literal("Frames (e.g. 8)"));
skinFlipbookFramesField.setValue(skinFlipbookFramesText);
addRenderableWidget(skinFlipbookFramesField);
skinFlipbookFrametimeField = new EditBox(this.font,
x0 + 10 + (third + 5) * 2, y, third, fieldH,
Component.literal("Speed"));
skinFlipbookFrametimeField.setMaxLength(4);
skinFlipbookFrametimeField.setHint(Component.literal("Ticks (e.g. 4)"));
skinFlipbookFrametimeField.setValue(skinFlipbookFrametimeText);
addRenderableWidget(skinFlipbookFrametimeField);
y += fieldH + rowGap;
// Optional hold-frame row. Both fields blank = no hold; both filled = hold the
// given frame for the given tick count (e.g. blink: frame 0 held for 60t).
int half = (fieldW - 10) / 2;
skinFlipbookHoldFrameField = new EditBox(this.font, x0 + 10, y, half, fieldH,
Component.literal("Hold frame"));
skinFlipbookHoldFrameField.setMaxLength(4);
skinFlipbookHoldFrameField.setHint(Component.literal("Hold frame (optional)"));
skinFlipbookHoldFrameField.setValue(skinFlipbookHoldFrameText);
addRenderableWidget(skinFlipbookHoldFrameField);
skinFlipbookHoldTicksField = new EditBox(this.font, x0 + 10 + half + 5, y, half - 5, fieldH,
Component.literal("Hold ticks"));
skinFlipbookHoldTicksField.setMaxLength(4);
skinFlipbookHoldTicksField.setHint(Component.literal("Hold ticks (optional)"));
skinFlipbookHoldTicksField.setValue(skinFlipbookHoldTicksText);
addRenderableWidget(skinFlipbookHoldTicksField);
y += fieldH + rowGap;
skinFlipbookStatusLabel = new StringWidget(
x0 + 10, y, fieldW, 15,
Component.literal(skinFlipbookStatusMessage),
this.font
);
addRenderableWidget(skinFlipbookStatusLabel);
y += 22;
int btnW = (fieldW - 10) / 2;
addRenderableWidget(Button.builder(
Component.literal("Submit"),
b -> submitSkinFlipbookForm()
).bounds(x0 + 10, y, btnW, fieldH).build());
addRenderableWidget(Button.builder(
Component.literal("Cancel"),
b -> closeSkinFlipbookForm()
).bounds(x0 + 10 + btnW + 10, y, btnW, fieldH).build());
}
private void pickSkinFlipbookPath() {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.png"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select skin PNG", "", filters, "PNG images", false);
if (path != null && !path.isEmpty() && skinFlipbookPathField != null) {
skinFlipbookPathField.setValue(path);
}
}
}
private void submitSkinFlipbookForm() {
if (skinFlipbookPathField == null || skinFlipbookFramesField == null
|| skinFlipbookFrametimeField == null) return;
String pathRaw = sanitizeSharePath(skinFlipbookPathField.getValue());
if (pathRaw.isEmpty()) { setSkinFlipbookStatus("Texture path required"); return; }
Path texture;
try {
texture = Paths.get(pathRaw);
} catch (InvalidPathException ex) {
setSkinFlipbookStatus("Invalid path: " + ex.getMessage());
return;
}
if (!Files.isRegularFile(texture)) { setSkinFlipbookStatus("Texture file not found"); return; }
if (!pathRaw.toLowerCase(Locale.ROOT).endsWith(".png")) {
setSkinFlipbookStatus("Texture must be .png");
return;
}
Integer parsedFrames = parsePositiveInt(skinFlipbookFramesField.getValue());
Integer parsedFrametime = parsePositiveInt(skinFlipbookFrametimeField.getValue());
if (parsedFrames == null || parsedFrames < 2
|| parsedFrames > com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMES) {
setSkinFlipbookStatus("Frames must be 2.."
+ com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMES);
return;
}
if (parsedFrametime == null || parsedFrametime < 1
|| parsedFrametime > com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMETIME) {
setSkinFlipbookStatus("Speed must be 1.."
+ com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMETIME
+ " ticks/frame");
return;
}
// Hold-frame is optional. Both blank = no hold; if either is filled, both must be
// present and valid. The hold frame index must be < frames; the hold ticks must be
// a positive int up to MAX_FLIPBOOK_FRAMETIME (same cap as regular frametime).
int holdFrame = 0;
int holdFrametime = 0;
String holdFrameRaw = skinFlipbookHoldFrameField != null
? skinFlipbookHoldFrameField.getValue().trim() : "";
String holdTicksRaw = skinFlipbookHoldTicksField != null
? skinFlipbookHoldTicksField.getValue().trim() : "";
if (!holdFrameRaw.isEmpty() || !holdTicksRaw.isEmpty()) {
if (holdFrameRaw.isEmpty() || holdTicksRaw.isEmpty()) {
setSkinFlipbookStatus("Hold frame + ticks both required (or both blank)");
return;
}
Integer parsedHoldFrame = parseNonNegativeInt(holdFrameRaw);
Integer parsedHoldTicks = parsePositiveInt(holdTicksRaw);
if (parsedHoldFrame == null || parsedHoldFrame >= parsedFrames) {
setSkinFlipbookStatus("Hold frame must be 0.." + (parsedFrames - 1));
return;
}
if (parsedHoldTicks == null
|| parsedHoldTicks > com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMETIME) {
setSkinFlipbookStatus("Hold ticks must be 1.."
+ com.razz.dfashion.skin.SkinCache.MAX_FLIPBOOK_FRAMETIME);
return;
}
holdFrame = parsedHoldFrame;
holdFrametime = parsedHoldTicks;
}
// Reflect the chosen model on the main skin tab so subsequent equips use it.
pendingSkinModel = skinFlipbookModel;
String result = ClientSkinCache.uploadFromFile(texture, skinFlipbookModel,
parsedFrametime, parsedFrames,
holdFrame, holdFrametime);
setSkinFlipbookStatus(result);
if (result != null && result.startsWith("Uploading ")) {
skinFlipbookPathText = "";
skinFlipbookFramesText = "";
skinFlipbookFrametimeText = "";
skinFlipbookHoldFrameText = "";
skinFlipbookHoldTicksText = "";
closeSkinFlipbookForm();
}
}
private static @Nullable Integer parseNonNegativeInt(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
try {
int v = Integer.parseInt(t);
return v >= 0 ? v : null;
} catch (NumberFormatException ex) {
return null;
}
}
private void setSkinFlipbookStatus(String msg) {
skinFlipbookStatusMessage = msg == null ? "" : msg;
if (skinFlipbookStatusLabel != null) {
skinFlipbookStatusLabel.setMessage(Component.literal(skinFlipbookStatusMessage));
}
}
/** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */
private static String sanitizeSharePath(String raw) {
if (raw == null) return "";
@ -1097,6 +1461,19 @@ public class ClosetScreen extends Screen {
return s;
}
/** Parse a non-negative integer; returns null on any failure including overflow. */
private static @Nullable Integer parsePositiveInt(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
try {
int v = Integer.parseInt(t);
return v > 0 ? v : null;
} catch (NumberFormatException ex) {
return null;
}
}
/** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */
private static String sanitizeShareName(String raw) {
if (raw == null) return "";
@ -1511,6 +1888,8 @@ public class ClosetScreen extends Screen {
// Toggle off → render nothing (no frames, no entities). Buttons are already
// hidden + inactive (handled in rebuildCosmeticRow).
if (!showPreviews) return;
// Modal forms cover the whole screen — previews would clip through their widgets.
if (showingShareForm || showingSkinFlipbookForm) return;
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;

@ -28,7 +28,7 @@ public final class CosmeticAttachments {
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.stringUtf8(64), CosmeticRef.STREAM_CODEC);
/** Per-player map of category -> equipped cosmetic ref. A ref is either a
* {@link CosmeticRef.Local} (built-in / user-folder cosmetic) or a
* {@link CosmeticRef.Local} (built-in cosmetic) or a
* {@link CosmeticRef.Shared} (server-stored blob by content hash). */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, CosmeticRef>>> EQUIPPED =
ATTACHMENT_TYPES.register(

@ -2,9 +2,22 @@ package com.razz.dfashion.cosmetic;
import net.minecraft.resources.Identifier;
import org.jspecify.annotations.Nullable;
public record CosmeticDefinition(
String displayName,
String category,
Identifier model,
Identifier texture
) {}
Identifier texture,
@Nullable Flipbook flipbook
) {
/** Optional vertical flipbook spec. {@code frames} is the number of stacked frames in
* the source PNG; {@code frametime} is ticks per frame. No interpolate field. */
public record Flipbook(int frametime, int frames) {}
/** Convenience for callers that don't care about flipbook (existing tests / non-animated). */
public CosmeticDefinition(String displayName, String category, Identifier model, Identifier texture) {
this(displayName, category, model, texture, null);
}
}

@ -13,7 +13,7 @@ import net.minecraft.resources.Identifier;
/**
* One of two ways an equipped cosmetic slot can reference its content:
* <ul>
* <li>{@link Local} a built-in or user-folder cosmetic addressed by {@link Identifier}.
* <li>{@link Local} a built-in cosmetic addressed by {@link Identifier}.
* Resolved against {@code CosmeticCache.cosmetics}.</li>
* <li>{@link Shared} a server-side blob addressed by its 64-char content hash.
* Resolved against {@code ClientSharedCosmeticCache}.</li>

@ -1,158 +0,0 @@
package com.razz.dfashion.cosmetic;
import com.razz.dfashion.DecoFashion;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} for user-authored
* cosmetics. One folder per cosmetic; category = parent dir name; variants = extra PNGs.
*/
public final class UserCosmeticLoader {
public static final String USER_NAMESPACE = "decofashion_user";
private static final String ROOT_DIR = "decofashion/cosmetics";
/** One scan result — a concrete (id, def, on-disk model file, on-disk texture file) tuple. */
public record Entry(
Identifier id,
CosmeticDefinition def,
Path modelFile,
Path textureFile
) {}
private UserCosmeticLoader() {}
public static List<Entry> scan(Path gameDir) {
List<Entry> out = new ArrayList<>();
Path root = gameDir.resolve(ROOT_DIR);
if (!Files.isDirectory(root)) {
try {
Files.createDirectories(root);
DecoFashion.LOGGER.info("Created user cosmetics folder at {}", root);
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Couldn't create user cosmetics folder {}: {}",
root, ex.getMessage());
}
return out;
}
try (DirectoryStream<Path> categories = Files.newDirectoryStream(root)) {
for (Path categoryDir : categories) {
if (!Files.isDirectory(categoryDir)) continue;
String category = normalize(categoryDir.getFileName().toString());
if (category.isEmpty()) continue;
try (DirectoryStream<Path> folders = Files.newDirectoryStream(categoryDir)) {
for (Path folder : folders) {
if (!Files.isDirectory(folder)) continue;
scanOne(folder, category, out);
}
}
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed scanning user cosmetics under {}", root, ex);
}
return out;
}
private static void scanOne(Path cosmeticDir, String category, List<Entry> out) throws IOException {
String rawFolder = cosmeticDir.getFileName().toString();
String folderId = normalize(rawFolder);
if (folderId.isEmpty()) return;
Path modelFile = null;
List<Path> pngs = new ArrayList<>();
try (DirectoryStream<Path> files = Files.newDirectoryStream(cosmeticDir)) {
for (Path f : files) {
if (!Files.isRegularFile(f)) continue;
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
if (name.endsWith(".bbmodel")) {
if (modelFile == null) modelFile = f;
} else if (name.endsWith(".png")) {
pngs.add(f);
}
}
}
if (modelFile == null) {
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .bbmodel, skipping", category, rawFolder);
return;
}
if (pngs.isEmpty()) {
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .png, skipping", category, rawFolder);
return;
}
pngs.sort(Comparator.comparing(p -> p.getFileName().toString().toLowerCase(Locale.ROOT)));
// Single texture that matches the folder name → un-suffixed id/display.
boolean singleMatching = pngs.size() == 1
&& stem(pngs.get(0)).equalsIgnoreCase(rawFolder);
for (Path png : pngs) {
String textureId = normalize(stem(png));
if (textureId.isEmpty()) continue;
String idPath;
String displayName;
if (singleMatching) {
idPath = folderId;
displayName = titleCase(folderId);
} else {
idPath = folderId + "_" + textureId;
displayName = titleCase(folderId) + " " + titleCase(textureId);
}
Identifier id = Identifier.fromNamespaceAndPath(USER_NAMESPACE, idPath);
Identifier modelRef = Identifier.fromNamespaceAndPath(
USER_NAMESPACE, "cosmetic/" + idPath + ".bbmodel");
Identifier textureRef = Identifier.fromNamespaceAndPath(
USER_NAMESPACE, "textures/cosmetic/" + idPath + ".png");
CosmeticDefinition def = new CosmeticDefinition(displayName, category, modelRef, textureRef);
out.add(new Entry(id, def, modelFile, png));
}
}
private static String stem(Path file) {
String name = file.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot < 0 ? name : name.substring(0, dot);
}
/** lowercase; spaces/hyphens → underscore; strip anything outside [a-z0-9_]. */
private static String normalize(String raw) {
StringBuilder sb = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = Character.toLowerCase(raw.charAt(i));
if (c == ' ' || c == '-') sb.append('_');
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') sb.append(c);
}
return sb.toString();
}
/** snake_case → Title Case. */
private static String titleCase(String id) {
if (id.isEmpty()) return id;
String[] parts = id.split("_");
StringBuilder sb = new StringBuilder();
for (String part : parts) {
if (part.isEmpty()) continue;
if (sb.length() > 0) sb.append(' ');
sb.append(Character.toUpperCase(part.charAt(0)));
if (part.length() > 1) sb.append(part.substring(1));
}
return sb.toString();
}
}

@ -226,6 +226,7 @@ public final class CosmeticShareNetwork {
msg.index(), msg.total(),
msg.bbmodelLen(),
msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.data()
);
if (finalized == null) return;
@ -295,6 +296,7 @@ public final class CosmeticShareNetwork {
msg.hash(),
i, total, bbmodelLen,
view.width(), view.height(),
view.frametime(), view.frames(),
slice
)));
}
@ -431,6 +433,7 @@ public final class CosmeticShareNetwork {
com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk(
msg.hash(), msg.index(), msg.total(),
msg.bbmodelLen(), msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.data()
);
}

@ -26,10 +26,10 @@ import java.util.concurrent.ConcurrentHashMap;
* Server-side shared-cosmetic store. Holds content-addressed <b>{@code .dfcos} blobs</b>
* under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
*
* <p>Blob layout (big-endian everywhere):
* <p>Blob layout v1 (big-endian, original):
* <pre>
* [4 bytes] magic "DFCO"
* [1 byte] version (currently 1)
* [1 byte] version = 1
* [4 bytes] u32 bbmodel-binary length
* [N bytes] bbmodel binary (per {@link BbmodelCodec})
* [2 bytes] u16 texture width
@ -37,6 +37,22 @@ import java.util.concurrent.ConcurrentHashMap;
* [M bytes] deflated RGBA pixels
* </pre>
*
* <p>Blob layout v2 (current flipbook fields appended after dims):
* <pre>
* [4 bytes] magic "DFCO"
* [1 byte] version = 2
* [4 bytes] u32 bbmodel-binary length
* [N bytes] bbmodel binary
* [2 bytes] u16 texture width
* [2 bytes] u16 texture height
* [2 bytes] u16 flipbook frametime (ticks/frame; 0 if static)
* [2 bytes] u16 flipbook frames (frame count; 1 if static)
* [M bytes] deflated RGBA pixels
* </pre>
*
* <p>v1 blobs already on disk are still readable via {@link #readBlob} (frametime/frames
* default to 0/1 = static). New uploads are written as v2 unconditionally.
*
* <p>The server never parses JSON or PNG on this path. Uploads arrive as already-encoded
* bbmodel-binary + already-decoded-and-deflated RGBA from the authoring client. Every
* read path validates the magic header before trusting a byte.
@ -45,10 +61,18 @@ public final class SharedCosmeticCache {
public static final String FILE_EXT = ".dfcos";
public static final byte[] MAGIC = { 'D', 'F', 'C', 'O' };
public static final byte VERSION = 1;
public static final byte VERSION_V1 = 1;
public static final byte VERSION = 2;
/** magic (4) + version (1) + bbmodelLen (4) + w (2) + h (2) = 13, plus the bbmodel body.
* v2 adds 4 bytes (frametime u16 + frames u16) to the body before the deflated RGBA. */
public static final int HEADER_FIXED_SIZE_V1 = 13;
public static final int HEADER_FIXED_SIZE = 17;
/** magic (4) + version (1) + bbmodelLen (4) + w (2) + h (2) = 13, plus the bbmodel body. */
public static final int HEADER_FIXED_SIZE = 13;
/** Flipbook frametime/frames are u16 caps mirror that. Real cosmetics use 32 frames
* and 120 ticks/frame; these caps are slack but bounded. */
public static final int MAX_FLIPBOOK_FRAMES = 64;
public static final int MAX_FLIPBOOK_FRAMETIME = 1024;
/** Cap on the bbmodel binary section of a single blob. Matches the local file cap for
* .bbmodel JSON; binary is usually ~1/3 the size of the source JSON, so this is slack. */
@ -66,8 +90,13 @@ public final class SharedCosmeticCache {
private SharedCosmeticCache() {}
/** Parsed view of a {@code .dfcos} blob. */
public record BlobView(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {}
/** Parsed view of a {@code .dfcos} blob. {@code frametime}/{@code frames} are 0/1 for
* v1 blobs and static v2 blobs alike. */
public record BlobView(
byte[] bbmodelBinary, int width, int height,
int frametime, int frames,
byte[] deflatedRgba
) {}
/** Result of a successful upload the canonical hash, dims, the canonicalized
* bbmodel binary as stored on disk, and the extracted bone set for wardrobe
@ -76,6 +105,8 @@ public final class SharedCosmeticCache {
String hash,
int width,
int height,
int frametime,
int frames,
byte[] canonicalBbmodelBinary,
List<String> bones
) {}
@ -87,6 +118,8 @@ public final class SharedCosmeticCache {
int bbmodelLen;
int width;
int height;
int frametime;
int frames;
ByteArrayOutputStream buffer;
}
@ -121,14 +154,15 @@ public final class SharedCosmeticCache {
/**
* Validate magic + parse header off a blob. Returns a {@link BlobView} on success,
* {@code null} if the blob is short, has bad magic, or claims lengths that overflow
* the buffer. No trust is extended to the body sections until this passes.
* the buffer. Handles both v1 (legacy, no flipbook) and v2 layouts.
*/
public static BlobView readBlob(byte[] blob) {
if (blob == null || blob.length < HEADER_FIXED_SIZE) return null;
if (blob == null || blob.length < HEADER_FIXED_SIZE_V1) return null;
for (int i = 0; i < MAGIC.length; i++) {
if (blob[i] != MAGIC[i]) return null;
}
if (blob[4] != VERSION) return null;
byte version = blob[4];
if (version != VERSION && version != VERSION_V1) return null;
int bbmodelLen = readU32(blob, 5);
if (bbmodelLen < 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) return null;
@ -140,20 +174,36 @@ public final class SharedCosmeticCache {
int h = ((blob[widthOff + 2] & 0xFF) << 8) | (blob[widthOff + 3] & 0xFF);
if (w <= 0 || h <= 0 || w > MAX_DIM || h > MAX_DIM) return null;
int frametime = 0;
int frames = 1;
int afterDims = widthOff + 4;
if (version == VERSION) {
if (afterDims + 4 > blob.length) return null;
frametime = ((blob[afterDims] & 0xFF) << 8) | (blob[afterDims + 1] & 0xFF);
frames = ((blob[afterDims + 2] & 0xFF) << 8) | (blob[afterDims + 3] & 0xFF);
if (frametime < 0 || frametime > MAX_FLIPBOOK_FRAMETIME) return null;
if (frames < 1 || frames > MAX_FLIPBOOK_FRAMES) return null;
// frametime > 0 implies multi-frame; static blobs use frametime=0 frames=1.
if (frametime == 0 && frames > 1) return null;
if (frametime > 0 && frames < 2) return null;
afterDims += 4;
}
byte[] bbmodel = new byte[bbmodelLen];
System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen);
int deflatedStart = widthOff + 4;
int deflatedLen = blob.length - deflatedStart;
int deflatedLen = blob.length - afterDims;
if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null;
byte[] deflated = new byte[deflatedLen];
System.arraycopy(blob, deflatedStart, deflated, 0, deflatedLen);
System.arraycopy(blob, afterDims, deflated, 0, deflatedLen);
return new BlobView(bbmodel, w, h, deflated);
return new BlobView(bbmodel, w, h, frametime, frames, deflated);
}
/** Assemble a wire-format blob. Reverse of {@link #readBlob}. */
public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {
/** Assemble a v2 wire-format blob. Reverse of {@link #readBlob}. Pass {@code frametime=0,
* frames=1} for static cosmetics. */
public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height,
int frametime, int frames, byte[] deflatedRgba) {
if (bbmodelBinary.length > MAX_BBMODEL_BIN_BYTES) {
throw new IllegalArgumentException("bbmodel binary too large: " + bbmodelBinary.length);
}
@ -163,6 +213,12 @@ public final class SharedCosmeticCache {
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
throw new IllegalArgumentException("bad dims " + width + "x" + height);
}
if (frametime < 0 || frametime > MAX_FLIPBOOK_FRAMETIME
|| frames < 1 || frames > MAX_FLIPBOOK_FRAMES
|| (frametime == 0 && frames > 1)
|| (frametime > 0 && frames < 2)) {
throw new IllegalArgumentException("bad flipbook " + frametime + "t × " + frames + "f");
}
int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length;
byte[] out = new byte[size];
System.arraycopy(MAGIC, 0, out, 0, 4);
@ -174,16 +230,22 @@ public final class SharedCosmeticCache {
out[widthOff + 1] = (byte) (width & 0xFF);
out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF);
out[widthOff + 3] = (byte) (height & 0xFF);
System.arraycopy(deflatedRgba, 0, out, widthOff + 4, deflatedRgba.length);
out[widthOff + 4] = (byte) ((frametime >>> 8) & 0xFF);
out[widthOff + 5] = (byte) (frametime & 0xFF);
out[widthOff + 6] = (byte) ((frames >>> 8) & 0xFF);
out[widthOff + 7] = (byte) (frames & 0xFF);
System.arraycopy(deflatedRgba, 0, out, widthOff + 8, deflatedRgba.length);
return out;
}
/**
* Hash over {@code [bbmodelBinary || u16 w || u16 h || raw RGBA]}. Canonical across
* deflate levels so identical content dedups regardless of who encoded it. Pass raw
* (inflated) pixels, not the deflate stream.
* Hash over {@code [bbmodelBinary || u16 w || u16 h || u16 frametime || u16 frames || raw RGBA]}.
* Canonical across deflate levels so identical content dedups regardless of who encoded it.
* Including frametime/frames means flipbook vs non-flipbook of identical pixels hash differently
* which is correct, the rendered behavior differs.
*/
public static String hashContent(byte[] bbmodelBinary, int width, int height, byte[] rawRgba)
public static String hashContent(byte[] bbmodelBinary, int width, int height,
int frametime, int frames, byte[] rawRgba)
throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(bbmodelBinary);
@ -191,6 +253,10 @@ public final class SharedCosmeticCache {
md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 0xFF));
md.update((byte) ((frametime >>> 8) & 0xFF));
md.update((byte) (frametime & 0xFF));
md.update((byte) ((frames >>> 8) & 0xFF));
md.update((byte) (frames & 0xFF));
md.update(rawRgba);
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(64);
@ -237,7 +303,9 @@ public final class SharedCosmeticCache {
public static Finalized acceptChunk(
MinecraftServer server, UUID playerId,
int index, int total, int bbmodelLen,
int width, int height, byte[] data
int width, int height,
int frametime, int frames,
byte[] data
) {
if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: invalid chunk {}/{}", playerId, index, total);
@ -254,6 +322,23 @@ public final class SharedCosmeticCache {
IN_FLIGHT.remove(playerId);
return null;
}
// Flipbook spec: (0, 1) = static, (>0, ≥2) = animated. Mismatched pairs rejected.
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0 && frametime <= MAX_FLIPBOOK_FRAMETIME
&& frames > 1 && frames <= MAX_FLIPBOOK_FRAMES);
if (!staticFb && !animatedFb) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad flipbook {}t × {}f",
playerId, frametime, frames);
IN_FLIGHT.remove(playerId);
return null;
}
// For animated flipbooks: PNG height must divide cleanly by frame count.
if (animatedFb && height % frames != 0) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: flipbook frames={} doesn't divide height={}",
playerId, frames, height);
IN_FLIGHT.remove(playerId);
return null;
}
if (data == null || data.length > MAX_CHUNK_BYTES) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})",
playerId, data == null ? -1 : data.length);
@ -269,6 +354,8 @@ public final class SharedCosmeticCache {
asm.bbmodelLen = bbmodelLen;
asm.width = width;
asm.height = height;
asm.frametime = frametime;
asm.frames = frames;
asm.buffer = new ByteArrayOutputStream(Math.min(
total * MAX_CHUNK_BYTES,
MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES));
@ -277,7 +364,8 @@ public final class SharedCosmeticCache {
asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
|| asm.bbmodelLen != bbmodelLen
|| asm.width != width || asm.height != height) {
|| asm.width != width || asm.height != height
|| asm.frametime != frametime || asm.frames != frames) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk mismatch at {}/{}",
playerId, index, total);
IN_FLIGHT.remove(playerId);
@ -375,7 +463,8 @@ public final class SharedCosmeticCache {
String hash;
try {
hash = hashContent(canonicalBbmodelBin, asm.width, asm.height, rgba);
hash = hashContent(canonicalBbmodelBin, asm.width, asm.height,
asm.frametime, asm.frames, rgba);
} catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null;
@ -394,7 +483,8 @@ public final class SharedCosmeticCache {
return null;
}
byte[] blob = buildBlob(canonicalBbmodelBin, asm.width, asm.height, deflatedRgba);
byte[] blob = buildBlob(canonicalBbmodelBin, asm.width, asm.height,
asm.frametime, asm.frames, deflatedRgba);
try {
writeIfAbsent(server, hash, blob);
} catch (IOException ex) {
@ -405,9 +495,11 @@ public final class SharedCosmeticCache {
List<String> bones = BoneExtraction.fromBbmodel(bbmodel);
DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} bbmodel={}B deflated={}B bones={}",
playerId, hash, asm.width, asm.height, canonicalBbmodelBin.length, deflatedRgba.length, bones);
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones);
DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} flipbook={}t×{}f bbmodel={}B deflated={}B bones={}",
playerId, hash, asm.width, asm.height, asm.frametime, asm.frames,
canonicalBbmodelBin.length, deflatedRgba.length, bones);
return new Finalized(hash, asm.width, asm.height, asm.frametime, asm.frames,
canonicalBbmodelBin, bones);
}
/**

@ -15,7 +15,7 @@ import net.minecraft.resources.Identifier;
* {@code hash} field so the receiving client can route to the right in-flight download.
*
* <p>Same wire-safety properties as the upload path: no PNG, no JSON. Just
* {@code [bbmodel binary][deflated RGBA]} with dims metadata per chunk.
* {@code [bbmodel binary][deflated RGBA]} with dims + flipbook metadata per chunk.
*/
public record CosmeticChunk(
String hash,
@ -24,6 +24,8 @@ public record CosmeticChunk(
int bbmodelLen,
int width,
int height,
int frametime,
int frames,
byte[] data
) implements CustomPacketPayload {
@ -38,6 +40,8 @@ public record CosmeticChunk(
ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen,
ByteBufCodecs.VAR_INT, CosmeticChunk::width,
ByteBufCodecs.VAR_INT, CosmeticChunk::height,
ByteBufCodecs.VAR_INT, CosmeticChunk::frametime,
ByteBufCodecs.VAR_INT, CosmeticChunk::frames,
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data,
CosmeticChunk::new
);
@ -46,4 +50,4 @@ public record CosmeticChunk(
public Type<CosmeticChunk> type() {
return TYPE;
}
}
}

@ -12,8 +12,11 @@ import net.minecraft.resources.Identifier;
/**
* Client server. One chunk of a shared-cosmetic upload. The payload bytes are the
* concatenation {@code [bbmodel binary][deflated RGBA]}; the authoring client knows both
* halves up-front so {@code bbmodelLen}, {@code width}, and {@code height} are repeated
* on every chunk for defense-in-depth mismatch detection on the server.
* halves up-front so {@code bbmodelLen}, {@code width}, {@code height}, and the flipbook
* spec are repeated on every chunk for defense-in-depth mismatch detection on the server.
*
* <p>{@code frametime}/{@code frames}: {@code (0, 1)} = static cosmetic; {@code (>0, 2)} =
* flipbook with that frametime in ticks and that frame count stacked vertically in the PNG.
*
* <p>No PNG container and no JSON ever crosses this wire. The bbmodel binary is produced
* by {@link com.razz.dfashion.cosmetic.share.BbmodelCodec} on the author's machine; the
@ -25,6 +28,8 @@ public record UploadCosmeticChunk(
int bbmodelLen,
int width,
int height,
int frametime,
int frames,
byte[] data
) implements CustomPacketPayload {
@ -38,6 +43,8 @@ public record UploadCosmeticChunk(
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::frametime,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::frames,
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data,
UploadCosmeticChunk::new
);
@ -46,4 +53,4 @@ public record UploadCosmeticChunk(
public Type<UploadCosmeticChunk> type() {
return TYPE;
}
}
}

@ -25,7 +25,15 @@ import java.util.zip.Inflater;
*/
public final class SafePngReader {
/** Per-axis dimension cap for square-ish images. Width is bounded by this in all cases. */
public static final int MAX_DIM = 4096;
/** Total height cap. Larger than {@link #MAX_DIM} to allow flipbook strips
* ({@code MAX_DIM} × N frames stacked vertically). With width capped at {@code MAX_DIM},
* the corresponding raw-byte ceiling is enforced via {@link #MAX_DECODED_BYTES}. */
public static final int MAX_HEIGHT = MAX_DIM * 64;
/** Hard cap on the decoded RGBA byte count from a single PNG. Mirrors
* {@code SkinCache.MAX_RAW_BYTES} so a malformed huge-strip PNG can't exhaust memory. */
public static final int MAX_DECODED_BYTES = 256 * 1024 * 1024;
/** Cap on a single chunk's declared length (128 MB). Defensive; real chunks are tiny. */
private static final int MAX_CHUNK_BYTES = 1 << 27;
@ -114,9 +122,16 @@ public final class SafePngReader {
int compression = src[dataStart + 10] & 0xFF;
int filter = src[dataStart + 11] & 0xFF;
int interlace = src[dataStart + 12] & 0xFF;
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_HEIGHT) {
throw new BadPngException("bad dimensions " + width + "x" + height);
}
// Reject early if width × height × 4 bytes would exceed our decode cap.
// The full unfilter pass below also bounds the raw scanline budget, but
// catching it here avoids any partial work on a too-big image.
if ((long) width * (long) height * 4L > MAX_DECODED_BYTES) {
throw new BadPngException("decoded size cap " + MAX_DECODED_BYTES
+ " exceeded by " + width + "x" + height);
}
// Accept the three color types common in authored skins/cosmetics:
// - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255
// - Type 3 (palette/indexed) depths 1/2/4/8 — PLTE + optional tRNS
@ -254,9 +269,11 @@ public final class SafePngReader {
};
}
/** {@code MAX_DIM * (1 + MAX_DIM * 8)} the largest legal {@code rawLen} across both
* supported bit depths (8 = 4 bpp, 16 = 8 bpp). */
private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_DIM * (1L + (long) MAX_DIM * 8L);
/** {@code MAX_HEIGHT * (1 + MAX_DIM * 8)} the largest legal {@code rawLen} across both
* supported bit depths (8 = 4 bpp, 16 = 8 bpp), with height extended to support flipbook
* strips. The {@link #MAX_DECODED_BYTES} check rejects any image whose RGBA wouldn't fit
* before this point is reached, so this is mostly a defense-in-depth bound. */
private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_HEIGHT * (1L + (long) MAX_DIM * 8L);
private static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
Inflater inf = new Inflater();

@ -20,8 +20,19 @@ import java.util.zip.Inflater;
/**
* Server-side skin store. Holds <b>pixel blobs</b> on disk under
* {@code <serverDir>/decofashion/skins/<hash>.dfskin} with an 8-byte header
* {@code [4-byte magic "DFSK"][u16 width][u16 height]} followed by the deflated RGBA payload.
* {@code <serverDir>/decofashion/skins/<hash>.dfskin}.
*
* <p>Two formats coexist:
* <ul>
* <li><b>v1</b> (legacy, magic {@code "DFSK"}): {@code [magic 4][u16 w][u16 h][deflated RGBA]}.</li>
* <li><b>v2</b> (current, magic {@code "DFS2"}):
* {@code [magic 4][u16 w][u16 h][u16 frametime][u16 frames][u16 holdFrame][u16 holdFrametime][deflated RGBA]}.
* For static skins, all four flipbook fields are zero (frames=1, all others=0). For animated
* skins, frametime>0 and frames2; {@code holdFrametime>0} marks {@code holdFrame} as a
* frame whose display extends to {@code holdFrametime} ticks (e.g. blink animations).</li>
* </ul>
*
* <p>Existing v1 blobs on disk remain readable; new uploads are always written as v2.
*
* <p>The server never parses PNG. Uploads arrive as already-decoded, deflated RGBA from the
* uploading client (which ran a memory-safe pure-Java parser); the server only reassembles
@ -33,26 +44,44 @@ import java.util.zip.Inflater;
*/
public final class SkinCache {
/** Hard ceiling on raw RGBA size (4096² × 4 bytes = 64 MB). Per-image cap derives from dims. */
public static final int MAX_RAW_BYTES = 4096 * 4096 * 4;
/** Hard ceiling on raw RGBA size 256 MB. Sized to accommodate flipbook strips
* (a 4096-wide frame at MAX_DIM tall × N frames; raw_bytes = 4096 × strip_height × 4
* must fit). For static skins, the per-image dim cap (MAX_DIM) is the practical limit
* and total RGBA fits well under 64 MB. Cosmetic uploads share this cap via
* {@code SharedCosmeticCache.MAX_RAW_BYTES = SkinCache.MAX_RAW_BYTES}. */
public static final int MAX_RAW_BYTES = 256 * 1024 * 1024;
/** Per-wire-packet cap: keeps single packets well under the channel MTU. */
public static final int MAX_CHUNK_BYTES = 512 * 1024;
/** Total deflated bytes we'll accept for a single upload/download. */
public static final int MAX_DEFLATED_BYTES = 32 * 1024 * 1024;
/** Total deflated bytes we'll accept for a single upload/download. Raised in step with
* {@link #MAX_RAW_BYTES} so a poorly-compressing flipbook (mostly random pixels) can
* still be uploaded; typical content deflates to ~2550% of raw. */
public static final int MAX_DEFLATED_BYTES = 96 * 1024 * 1024;
/** Matches {@link SafePngReader#MAX_DIM}. */
/** Per-axis dimension cap. Width is bounded by this; for flipbook strips, total height
* may go up to {@code MAX_DIM × MAX_FLIPBOOK_FRAMES} (also constrained by MAX_RAW_BYTES). */
public static final int MAX_DIM = SafePngReader.MAX_DIM;
/** Total flipbook strip height cap (MAX_DIM × MAX_FLIPBOOK_FRAMES = 262144). Validated
* in addition to MAX_RAW_BYTES so a tall-narrow strip can't slip past the byte budget
* by being narrow. */
public static final int MAX_STRIP_HEIGHT = MAX_DIM * 64;
/** Proprietary file-format extension. No OS or tool has a handler for it. */
public static final String FILE_EXT = ".dfskin";
/** "DFSK" in ASCII — prepended to every blob; every read validates it before trusting bytes. */
public static final byte[] MAGIC = { 'D', 'F', 'S', 'K' };
/** v1 magic (legacy blobs already on disk). Read-only. */
public static final byte[] MAGIC_V1 = { 'D', 'F', 'S', 'K' };
/** v2 magic (carries flipbook fields). Used for all new writes. */
public static final byte[] MAGIC = { 'D', 'F', 'S', '2' };
public static final int HEADER_SIZE_V1 = 8; // magic(4) + w(2) + h(2)
public static final int HEADER_SIZE_V2 = 16; // + frametime(2) + frames(2) + holdFrame(2) + holdFrametime(2)
/** Magic (4) + width u16 (2) + height u16 (2) = 8. */
public static final int HEADER_SIZE = 8;
/** Flipbook caps mirror cosmetic side. */
public static final int MAX_FLIPBOOK_FRAMES = 64;
public static final int MAX_FLIPBOOK_FRAMETIME = 1024;
private static final String SUBDIR = "decofashion/skins";
@ -60,12 +89,24 @@ public final class SkinCache {
private SkinCache() {}
/** Parsed view of a {@code .dfskin} blob. {@code frametime}/{@code frames} are 0/1 for
* v1 blobs and static v2 blobs alike; multi-frame v2 blobs report the author's spec.
* {@code holdFrame}/{@code holdFrametime} are 0/0 unless the author flagged a hold. */
public record BlobView(int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] deflatedRgba) {}
/** Per-player chunk assembly state. */
private static final class Assembly {
int expectedTotal;
int nextIndex;
int width;
int height;
int frametime;
int frames;
int holdFrame;
int holdFrametime;
SkinModel model;
ByteArrayOutputStream buffer;
}
@ -104,18 +145,58 @@ public final class SkinCache {
}
/**
* Validate the magic + parse the {@code (u16 w, u16 h)} header off a blob. Returns
* {@code [w, h]} on success, or {@code null} if the magic doesn't match or the blob
* is too short. No trust extended to the deflated body until this passes.
* Validate the magic + parse a v1 or v2 header off a blob. Returns a {@link BlobView}
* with width, height, flipbook spec, and the deflated body bytes. Returns {@code null}
* if the blob is short, magic mismatch, or out-of-range fields.
*/
public static int[] readHeader(byte[] blob) {
if (blob == null || blob.length < HEADER_SIZE) return null;
for (int i = 0; i < MAGIC.length; i++) {
if (blob[i] != MAGIC[i]) return null;
}
public static BlobView readBlob(byte[] blob) {
if (blob == null || blob.length < HEADER_SIZE_V1) return null;
boolean v2 = matches(blob, MAGIC);
boolean v1 = matches(blob, MAGIC_V1);
if (!v1 && !v2) return null;
int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF);
int h = ((blob[6] & 0xFF) << 8) | (blob[7] & 0xFF);
return new int[] { w, h };
if (w <= 0 || w > MAX_DIM) return null;
if (h <= 0 || h > MAX_STRIP_HEIGHT) return null;
int frametime = 0;
int frames = 1;
int holdFrame = 0;
int holdFrametime = 0;
int bodyOff = HEADER_SIZE_V1;
if (v2) {
if (blob.length < HEADER_SIZE_V2) return null;
frametime = ((blob[8] & 0xFF) << 8) | (blob[9] & 0xFF);
frames = ((blob[10] & 0xFF) << 8) | (blob[11] & 0xFF);
holdFrame = ((blob[12] & 0xFF) << 8) | (blob[13] & 0xFF);
holdFrametime = ((blob[14] & 0xFF) << 8) | (blob[15] & 0xFF);
if (frametime < 0 || frametime > MAX_FLIPBOOK_FRAMETIME) return null;
if (frames < 1 || frames > MAX_FLIPBOOK_FRAMES) return null;
if (frametime == 0 && frames > 1) return null;
if (frametime > 0 && frames < 2) return null;
if (frames > 1 && h % frames != 0) return null;
// Hold spec is optional. holdFrametime == 0 ⇒ no hold (holdFrame must be 0 too,
// for canonical encoding). holdFrametime > 0 ⇒ holdFrame must be a valid index.
if (holdFrametime < 0 || holdFrametime > MAX_FLIPBOOK_FRAMETIME) return null;
if (holdFrametime == 0 && holdFrame != 0) return null;
if (holdFrametime > 0 && (holdFrame < 0 || holdFrame >= frames)) return null;
bodyOff = HEADER_SIZE_V2;
}
int deflatedLen = blob.length - bodyOff;
if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null;
byte[] deflated = new byte[deflatedLen];
System.arraycopy(blob, bodyOff, deflated, 0, deflatedLen);
return new BlobView(w, h, frametime, frames, holdFrame, holdFrametime, deflated);
}
private static boolean matches(byte[] blob, byte[] magic) {
for (int i = 0; i < magic.length; i++) {
if (blob[i] != magic[i]) return false;
}
return true;
}
/**
@ -125,7 +206,10 @@ public final class SkinCache {
*/
public static SkinData acceptChunk(
MinecraftServer server, UUID playerId,
int index, int total, int width, int height, SkinModel model, byte[] data
int index, int total, int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
SkinModel model, byte[] data
) {
if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total);
@ -138,11 +222,33 @@ public final class SkinCache {
IN_FLIGHT.remove(playerId);
return null;
}
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
if (width <= 0 || width > MAX_DIM || height <= 0 || height > MAX_STRIP_HEIGHT) {
DecoFashion.LOGGER.warn("Skin upload from {}: bad dims {}x{}", playerId, width, height);
IN_FLIGHT.remove(playerId);
return null;
}
// Flipbook: (0, 1) static; (>0, ≥2) animated. Animated must divide height by frames.
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0 && frametime <= MAX_FLIPBOOK_FRAMETIME
&& frames > 1 && frames <= MAX_FLIPBOOK_FRAMES
&& height % frames == 0);
if (!staticFb && !animatedFb) {
DecoFashion.LOGGER.warn("Skin upload from {}: bad flipbook {}t × {}f (h={})",
playerId, frametime, frames, height);
IN_FLIGHT.remove(playerId);
return null;
}
// Hold spec: optional. (0, 0) = no hold; otherwise holdFrame ∈ [0, frames) and
// holdFrametime > 0. Static skins must always be (0, 0).
if (holdFrametime < 0 || holdFrametime > MAX_FLIPBOOK_FRAMETIME
|| (holdFrametime == 0 && holdFrame != 0)
|| (holdFrametime > 0 && (holdFrame < 0 || holdFrame >= frames))
|| (staticFb && (holdFrame != 0 || holdFrametime != 0))) {
DecoFashion.LOGGER.warn("Skin upload from {}: bad hold spec frame={} ticks={}",
playerId, holdFrame, holdFrametime);
IN_FLIGHT.remove(playerId);
return null;
}
Assembly asm;
if (index == 0) {
@ -151,13 +257,19 @@ public final class SkinCache {
asm.nextIndex = 0;
asm.width = width;
asm.height = height;
asm.frametime = frametime;
asm.frames = frames;
asm.holdFrame = holdFrame;
asm.holdFrametime = holdFrametime;
asm.model = model;
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_DEFLATED_BYTES));
IN_FLIGHT.put(playerId, asm);
} else {
asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
|| asm.width != width || asm.height != height) {
|| asm.width != width || asm.height != height
|| asm.frametime != frametime || asm.frames != frames
|| asm.holdFrame != holdFrame || asm.holdFrametime != holdFrametime) {
DecoFashion.LOGGER.warn("Skin upload from {}: chunk mismatch at {}/{}", playerId, index, total);
IN_FLIGHT.remove(playerId);
return null;
@ -201,7 +313,10 @@ public final class SkinCache {
String hash;
try {
hash = sha256HexOfPixels(asm.width, asm.height, rgba);
hash = sha256HexOfPixels(asm.width, asm.height,
asm.frametime, asm.frames,
asm.holdFrame, asm.holdFrametime,
rgba);
} catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null;
@ -226,15 +341,20 @@ public final class SkinCache {
Files.createDirectories(root(server));
Path target = fileFor(server, hash);
if (!Files.isRegularFile(target)) {
Files.write(target, buildBlob(asm.width, asm.height, deflated));
Files.write(target, buildBlob(asm.width, asm.height,
asm.frametime, asm.frames,
asm.holdFrame, asm.holdFrametime,
deflated));
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex);
return null;
}
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} dims={}x{} deflated={} model={}",
playerId, hash, asm.width, asm.height, deflated.length, asm.model);
DecoFashion.LOGGER.info(
"Skin upload finalized: player={} hash={} dims={}x{} flipbook={}t×{}f hold={}@{}t deflated={} model={}",
playerId, hash, asm.width, asm.height, asm.frametime, asm.frames,
asm.holdFrame, asm.holdFrametime, deflated.length, asm.model);
return new SkinData(hash, asm.model);
}
@ -262,15 +382,46 @@ public final class SkinCache {
return out;
}
/** Build a wire-format blob: magic + (u16 w, u16 h) + deflated pixels. */
public static byte[] buildBlob(int width, int height, byte[] deflated) {
byte[] blob = new byte[HEADER_SIZE + deflated.length];
/** Build a v2 wire-format blob: magic + (w, h, frametime, frames, holdFrame, holdFrametime)
* + deflated pixels. Pass {@code (0, 1, 0, 0)} for static skins; {@code (0, 0)} for
* the hold pair if no frame should hold. */
public static byte[] buildBlob(int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] deflated) {
if (deflated.length > MAX_DEFLATED_BYTES) {
throw new IllegalArgumentException("deflated too large: " + deflated.length);
}
if (width <= 0 || width > MAX_DIM || height <= 0 || height > MAX_STRIP_HEIGHT) {
throw new IllegalArgumentException("bad dims " + width + "x" + height);
}
if (frametime < 0 || frametime > MAX_FLIPBOOK_FRAMETIME
|| frames < 1 || frames > MAX_FLIPBOOK_FRAMES
|| (frametime == 0 && frames > 1)
|| (frametime > 0 && frames < 2)) {
throw new IllegalArgumentException("bad flipbook " + frametime + "t × " + frames + "f");
}
if (holdFrametime < 0 || holdFrametime > MAX_FLIPBOOK_FRAMETIME
|| (holdFrametime == 0 && holdFrame != 0)
|| (holdFrametime > 0 && (holdFrame < 0 || holdFrame >= frames))) {
throw new IllegalArgumentException(
"bad hold spec frame=" + holdFrame + " ticks=" + holdFrametime);
}
byte[] blob = new byte[HEADER_SIZE_V2 + deflated.length];
System.arraycopy(MAGIC, 0, blob, 0, MAGIC.length);
blob[4] = (byte) ((width >>> 8) & 0xFF);
blob[5] = (byte) (width & 0xFF);
blob[6] = (byte) ((height >>> 8) & 0xFF);
blob[7] = (byte) (height & 0xFF);
System.arraycopy(deflated, 0, blob, HEADER_SIZE, deflated.length);
blob[4] = (byte) ((width >>> 8) & 0xFF);
blob[5] = (byte) (width & 0xFF);
blob[6] = (byte) ((height >>> 8) & 0xFF);
blob[7] = (byte) (height & 0xFF);
blob[8] = (byte) ((frametime >>> 8) & 0xFF);
blob[9] = (byte) (frametime & 0xFF);
blob[10] = (byte) ((frames >>> 8) & 0xFF);
blob[11] = (byte) (frames & 0xFF);
blob[12] = (byte) ((holdFrame >>> 8) & 0xFF);
blob[13] = (byte) (holdFrame & 0xFF);
blob[14] = (byte) ((holdFrametime >>> 8) & 0xFF);
blob[15] = (byte) (holdFrametime & 0xFF);
System.arraycopy(deflated, 0, blob, HEADER_SIZE_V2, deflated.length);
return blob;
}
@ -328,14 +479,28 @@ public final class SkinCache {
}
}
/** Hash is computed over {@code [u16 w][u16 h][raw RGBA bytes]} so dedup is stable across
* deflate-level differences between clients. */
static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException {
/** Hash is computed over {@code [u16 w][u16 h][u16 frametime][u16 frames][u16 holdFrame]
* [u16 holdFrametime][raw RGBA]} so dedup is stable across deflate-level differences,
* flipbook vs static of identical pixels hash differently, AND a flipbook with vs
* without a hold-frame spec hash differently (rendered behavior differs). */
public static String sha256HexOfPixels(int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] rgba)
throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update((byte) ((width >>> 8) & 0xFF));
md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 0xFF));
md.update((byte) ((frametime >>> 8) & 0xFF));
md.update((byte) (frametime & 0xFF));
md.update((byte) ((frames >>> 8) & 0xFF));
md.update((byte) (frames & 0xFF));
md.update((byte) ((holdFrame >>> 8) & 0xFF));
md.update((byte) (holdFrame & 0xFF));
md.update((byte) ((holdFrametime >>> 8) & 0xFF));
md.update((byte) (holdFrametime & 0xFF));
md.update(rgba);
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(digest.length * 2);

@ -85,6 +85,8 @@ public final class SkinNetwork {
server, player.getUUID(),
msg.index(), msg.total(),
msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.holdFrame(), msg.holdFrametime(),
msg.model(), msg.data()
);
if (finalized != null) {
@ -147,23 +149,25 @@ public final class SkinNetwork {
return;
}
int[] dims = SkinCache.readHeader(blob);
if (dims == null || blob.length <= 4) {
DecoFashion.LOGGER.error("Skin request {}: blob missing header", msg.hash());
SkinCache.BlobView view = SkinCache.readBlob(blob);
if (view == null) {
DecoFashion.LOGGER.error("Skin request {}: blob corrupt or unsupported", msg.hash());
return;
}
int width = dims[0];
int height = dims[1];
int bodyLen = blob.length - 4;
byte[] deflated = view.deflatedRgba();
int chunkSize = SkinCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (bodyLen + chunkSize - 1) / chunkSize);
int total = Math.max(1, (deflated.length + chunkSize - 1) / chunkSize);
for (int i = 0; i < total; i++) {
int off = 4 + i * chunkSize;
int len = Math.min(chunkSize, blob.length - off);
int off = i * chunkSize;
int len = Math.min(chunkSize, deflated.length - off);
byte[] slice = new byte[len];
System.arraycopy(blob, off, slice, 0, len);
System.arraycopy(deflated, off, slice, 0, len);
player.connection.send(new ClientboundCustomPayloadPacket(
new SkinChunk(msg.hash(), i, total, width, height, slice)));
new SkinChunk(msg.hash(), i, total,
view.width(), view.height(),
view.frametime(), view.frames(),
view.holdFrame(), view.holdFrametime(),
slice)));
}
});
}
@ -185,7 +189,10 @@ public final class SkinNetwork {
}
com.razz.dfashion.client.ClientSkinCache.onChunk(
msg.hash(), msg.index(), msg.total(),
msg.width(), msg.height(), msg.data()
msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.holdFrame(), msg.holdFrametime(),
msg.data()
);
}
}

@ -15,9 +15,13 @@ import net.minecraft.resources.Identifier;
* <p>Mirrors {@link UploadSkinChunk}'s shape in reverse. Receivers reassemble the deflated
* buffer, inflate it under a bounded cumulative output cap (= {@code width*height*4}), and
* build a {@code NativeImage} directly via {@code setPixelABGR} no STB call anywhere in
* the download path.
* the download path. {@code frametime}/{@code frames} carry the skin's flipbook spec
* ({@code (0, 1)} for static skins).
*/
public record SkinChunk(String hash, int index, int total, int width, int height, byte[] data)
public record SkinChunk(String hash, int index, int total, int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
byte[] data)
implements CustomPacketPayload {
public static final Type<SkinChunk> TYPE =
@ -30,6 +34,10 @@ public record SkinChunk(String hash, int index, int total, int width, int height
ByteBufCodecs.VAR_INT, SkinChunk::total,
ByteBufCodecs.VAR_INT, SkinChunk::width,
ByteBufCodecs.VAR_INT, SkinChunk::height,
ByteBufCodecs.VAR_INT, SkinChunk::frametime,
ByteBufCodecs.VAR_INT, SkinChunk::frames,
ByteBufCodecs.VAR_INT, SkinChunk::holdFrame,
ByteBufCodecs.VAR_INT, SkinChunk::holdFrametime,
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES),SkinChunk::data,
SkinChunk::new
);
@ -38,4 +46,4 @@ public record SkinChunk(String hash, int index, int total, int width, int height
public Type<SkinChunk> type() {
return TYPE;
}
}
}

@ -15,12 +15,19 @@ import net.minecraft.resources.Identifier;
*
* <p>The uploading client parses its source PNG locally with {@code SafePngReader} (pure Java,
* no STB), extracts raw RGBA, deflates it, and streams the deflated buffer in fixed-size chunks.
* {@code w} and {@code h} are repeated on every chunk so reassembly can enforce them before
* touching the payload; server validates that subsequent chunks carry the same dimensions as
* the first. No PNG container ever crosses the wire polyglot/iCCP/trailing-IDAT attacks
* can't exist when the attack surface is two u16s and a deflate stream.
* {@code w}, {@code h}, and the flipbook spec are repeated on every chunk so reassembly can
* enforce them before touching the payload; server validates that subsequent chunks carry the
* same dimensions and flipbook fields as the first. No PNG container ever crosses the wire
* polyglot/iCCP/trailing-IDAT attacks can't exist when the attack surface is two u16s and a
* deflate stream.
*
* <p>{@code frametime}/{@code frames}: {@code (0, 1)} = static skin; {@code (>0, 2)} = flipbook
* with that frametime in ticks and that frame count stacked vertically in the PNG strip.
*/
public record UploadSkinChunk(int index, int total, int width, int height, SkinModel model, byte[] data)
public record UploadSkinChunk(int index, int total, int width, int height,
int frametime, int frames,
int holdFrame, int holdFrametime,
SkinModel model, byte[] data)
implements CustomPacketPayload {
public static final Type<UploadSkinChunk> TYPE =
@ -32,6 +39,10 @@ public record UploadSkinChunk(int index, int total, int width, int height, SkinM
ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
ByteBufCodecs.VAR_INT, UploadSkinChunk::width,
ByteBufCodecs.VAR_INT, UploadSkinChunk::height,
ByteBufCodecs.VAR_INT, UploadSkinChunk::frametime,
ByteBufCodecs.VAR_INT, UploadSkinChunk::frames,
ByteBufCodecs.VAR_INT, UploadSkinChunk::holdFrame,
ByteBufCodecs.VAR_INT, UploadSkinChunk::holdFrametime,
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data,
UploadSkinChunk::new
@ -41,4 +52,4 @@ public record UploadSkinChunk(int index, int total, int width, int height, SkinM
public Type<UploadSkinChunk> type() {
return TYPE;
}
}
}

@ -1,8 +0,0 @@
{
"variants": {
"facing=south": { "model": "decofashion:block/closet", "y": 0 },
"facing=west": { "model": "decofashion:block/closet", "y": 90 },
"facing=north": { "model": "decofashion:block/closet", "y": 180 },
"facing=east": { "model": "decofashion:block/closet", "y": 270 }
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -10,48 +10,5 @@
"category": "torso",
"model": "decofashion:cosmetic/pike.bbmodel",
"texture": "decofashion:textures/cosmetic/pike.png"
},
"mock_hat_01": {"display_name":"Bowler", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_02": {"display_name":"Top Hat", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_03": {"display_name":"Beanie", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_04": {"display_name":"Fedora", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_05": {"display_name":"Cap", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_06": {"display_name":"Crown", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_07": {"display_name":"Tiara", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_08": {"display_name":"Helmet", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_09": {"display_name":"Sombrero", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_10": {"display_name":"Beret", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_11": {"display_name":"Straw", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_12": {"display_name":"Pirate", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_13": {"display_name":"Witch", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_14": {"display_name":"Cowboy", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_15": {"display_name":"Santa", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_16": {"display_name":"Hood", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_17": {"display_name":"Headband", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_18": {"display_name":"Visor", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_19": {"display_name":"Flower", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_20": {"display_name":"Halo", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_torso_01": {"display_name":"Jacket", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_02": {"display_name":"Robe", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_03": {"display_name":"Vest", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_04": {"display_name":"Coat", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_05": {"display_name":"Shirt", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_06": {"display_name":"Cape", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_07": {"display_name":"Armor", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_08": {"display_name":"Dress", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_09": {"display_name":"Hoodie", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_10": {"display_name":"Tunic", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_head_01": {"display_name":"Mask", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_head_02": {"display_name":"Glasses", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_head_03": {"display_name":"Bandana", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_legs_01": {"display_name":"Pants", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_legs_02": {"display_name":"Shorts", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_legs_03": {"display_name":"Skirt", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_feet_01": {"display_name":"Boots", "category":"feet","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_feet_02": {"display_name":"Sandals", "category":"feet","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"}
}
}

@ -1,3 +1,3 @@
{
"_comment": "DecoFashion translation keys — populated as features are added"
"block.decofashion.wardrobe_1": "Wardrobe"
}

@ -1,5 +0,0 @@
{
"textures": {
"particle": "decofashion:closet/closet_base_spruce"
}
}

@ -1,3 +0,0 @@
{
"parent": "decofashion:block/closet"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@ -0,0 +1,7 @@
{
"wardrobe_1": {
"closed": "decofashion:closet/wardrobe_1_closed.bbmodel",
"open": "decofashion:closet/wardrobe_1_open.bbmodel",
"texture": "decofashion:textures/closet/wardrobe_1.png"
}
}

@ -49,8 +49,10 @@ class ForbiddenApiGuardTest {
ALLOWLIST.put("ImageIO.read", Set.of());
// Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs
// GSON on untrusted bytes (and validates the result post-parse).
ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java"));
// GSON on untrusted bytes (and validates the result post-parse). ClosetCatalog reads
// its catalog from /decofashion/closets.json on the mod's own classpath at mod
// construction time — a fully-trusted boundary (the file ships in the mod jar).
ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java", "ClosetCatalog.java"));
// Java serialization — RCE class on untrusted input. Never acceptable.
ALLOWLIST.put("ObjectInputStream", Set.of());

Loading…
Cancel
Save