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.client.SafeImageLoader;
import com.razz.dfashion.cosmetic.CosmeticCatalog; import com.razz.dfashion.cosmetic.CosmeticCatalog;
import com.razz.dfashion.cosmetic.CosmeticDefinition; import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.cosmetic.UserCosmeticLoader;
import com.mojang.blaze3d.platform.NativeImage; import com.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.Minecraft; 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.EventBusSubscriber;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; 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.AddClientReloadListenersEvent;
import net.neoforged.neoforge.client.event.EntityRenderersEvent; import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.io.Reader;
import java.nio.file.Files;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -52,11 +48,6 @@ import java.util.Optional;
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public class DecoFashionClient { 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 @SubscribeEvent
static void onClientSetup(FMLClientSetupEvent event) { static void onClientSetup(FMLClientSetupEvent event) {
ClosetPreferences.load(); 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 @SubscribeEvent
static void onAddLayers(EntityRenderersEvent.AddLayers event) { static void onAddLayers(EntityRenderersEvent.AddLayers event) {
for (PlayerModelType type : event.getSkins()) { for (PlayerModelType type : event.getSkins()) {
@ -86,13 +82,27 @@ public class DecoFashionClient {
event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new); 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) { 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, CosmeticDefinition> catalog = CosmeticCatalog.loadAll(rm);
Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>(); Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>();
for (Map.Entry<Identifier, CosmeticDefinition> entry : catalog.entrySet()) { for (Map.Entry<Identifier, CosmeticDefinition> entry : catalog.entrySet()) {
loadOne(rm, entry.getKey(), entry.getValue(), baked); loadOne(rm, entry.getKey(), entry.getValue(), baked);
} }
loadUserCosmetics(catalog, baked);
CosmeticCache.cosmetics = baked; CosmeticCache.cosmetics = baked;
CosmeticCache.catalog = catalog; CosmeticCache.catalog = catalog;
DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size()); DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size());
@ -105,66 +115,6 @@ public class DecoFashionClient {
ClientSharedCosmeticCache.scanDisk(); 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. * 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 * 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; v.x = x; v.y = y;
} }
private static final String CLOSET_TEXTURE_NAMESPACE = "decofashion_closet";
private static void loadCloset(ResourceManager rm) { private static void loadCloset(ResourceManager rm) {
Identifier closedModel = Identifier.fromNamespaceAndPath( // Release any DynamicTextures registered on the previous reload so we don't leak.
DecoFashion.MODID, "closet/closet_7.bbmodel"); // Per-id unregister (not FlipbookTicker.clear()) because the ticker is shared with
Identifier openModel = Identifier.fromNamespaceAndPath( // the cosmetic subsystem — clearing all entries would nuke cosmetics' flipbooks.
DecoFashion.MODID, "closet/closet_7_open.bbmodel"); for (ClosetModelCache.Variant prev : ClosetModelCache.byVariant.values()) {
Identifier texture = Identifier.fromNamespaceAndPath( com.razz.dfashion.client.FlipbookTicker.unregister(prev.texture());
DecoFashion.MODID, "textures/closet/closet_base_spruce.png"); 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.closed = bakeCloset(rm, closedModel, texture); ClosetModelCache.byVariant.put(
ClosetModelCache.open = bakeCloset(rm, openModel, texture); 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); Optional<Resource> res = rm.getResource(modelPath);
if (res.isEmpty()) { if (res.isEmpty()) {
DecoFashion.LOGGER.warn("Closet: no bbmodel at {}", modelPath); DecoFashion.LOGGER.warn("Closet: no bbmodel at {}", modelPath);
@ -266,15 +285,20 @@ public class DecoFashionClient {
} }
try (Reader reader = res.get().openAsReader()) { try (Reader reader = res.get().openAsReader()) {
Bbmodel model = BbModelParser.parse(reader); 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( 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); Map<String, org.joml.Vector3f> locators = resolveLocators(model);
DecoFashion.LOGGER.info( DecoFashion.LOGGER.info(
"Loaded closet model {} (res {}x{}): parts={}, locators={}", "Loaded closet model {} (res {}x{}): parts={}, locators={}",
modelPath, model.resolutionWidth(), model.resolutionHeight(), modelPath, model.resolutionWidth(), model.resolutionHeight(),
parts.keySet(), locators parts.keySet(), locators
); );
return new ClosetModelCache.Baked(parts, locators, texturePath); return new ClosetModelCache.State(parts, locators);
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Failed to read closet model {}", modelPath, ex); DecoFashion.LOGGER.error("Failed to read closet model {}", modelPath, ex);
return null; return null;
@ -297,15 +321,66 @@ public class DecoFashionClient {
Bbmodel model = BbModelParser.parse(reader); Bbmodel model = BbModelParser.parse(reader);
Map<String, ModelPart> parts = BbmodelBaker.bake( Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true); 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( DecoFashion.LOGGER.info(
"Loaded cosmetic {} [{}] (res {}x{}): parts={}", "Loaded cosmetic {} [{}] (res {}x{}): parts={}{}",
cosmeticId, def.displayName(), 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) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Failed to read {}", def.model(), 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 { 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) { 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<>(); Map<String, BbCube> cubeIndex = new HashMap<>();
for (BbCube c : model.elements()) cubeIndex.put(c.uuid(), c); 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 // layer already places us at the bone, so the ModelPart itself doesn't
// need to translate again. // need to translate again.
result.put(g.name(), 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) // ElementRef at root = loose cube, skip (no bone to attach to)
} }
@ -38,7 +52,8 @@ public class BbmodelBaker {
Map<String, BbCube> cubeIndex, Map<String, BbCube> cubeIndex,
Map<String, BbGroup> groupIndex, Map<String, BbGroup> groupIndex,
int texWidth, int texHeight, int texWidth, int texHeight,
boolean mirrorX boolean mirrorX,
boolean naturalUv
) { ) {
List<ModelPart.Cube> cubes = new ArrayList<>(); List<ModelPart.Cube> cubes = new ArrayList<>();
Map<String, ModelPart> children = new LinkedHashMap<>(); Map<String, ModelPart> children = new LinkedHashMap<>();
@ -53,7 +68,7 @@ public class BbmodelBaker {
// ModelPart.Cube is axis-aligned, so per-cube rotation is done // ModelPart.Cube is axis-aligned, so per-cube rotation is done
// by wrapping the cube in its own one-cube ModelPart whose pose // by wrapping the cube in its own one-cube ModelPart whose pose
// carries the rotation. // 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()); ModelPart wrapper = new ModelPart(List.of(inner), Map.of());
wrapper.setPos( wrapper.setPos(
bb.origin().x - group.origin().x, bb.origin().x - group.origin().x,
@ -67,13 +82,13 @@ public class BbmodelBaker {
); );
children.put("cube_" + bb.uuid(), wrapper); children.put("cube_" + bb.uuid(), wrapper);
} else { } else {
cubes.add(bbCubeToCube(bb, group.origin(), texWidth, texHeight, mirrorX)); cubes.add(bbCubeToCube(bb, group.origin(), texWidth, texHeight, mirrorX, naturalUv));
} }
} }
case BbOutlinerNode.GroupRef nested -> { case BbOutlinerNode.GroupRef nested -> {
BbGroup ng = groupIndex.get(nested.uuid()); BbGroup ng = groupIndex.get(nested.uuid());
children.put(ng.name(), 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( 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 x = bb.from().x - referenceOrigin.x;
float y = bb.from().y - referenceOrigin.y; float y = bb.from().y - referenceOrigin.y;
@ -133,13 +149,28 @@ public class BbmodelBaker {
Direction.NORTH, Direction.EAST, Direction.SOUTH 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; int idx = 0;
for (Direction dir : order) { for (Direction dir : order) {
if (!visibleFaces.contains(dir)) continue; if (!visibleFaces.contains(dir)) continue;
BbFace bbFace = bb.faces().get(nameOf(dir)); BbFace bbFace = bb.faces().get(nameOf(dir));
if (bbFace != null && bbFace.uv() != null && bbFace.uv().length >= 4) { 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] = new ModelPart.Polygon(
cube.polygons[idx].vertices(), verts,
bbFace.uv()[0], bbFace.uv()[1], bbFace.uv()[0], bbFace.uv()[1],
bbFace.uv()[2], bbFace.uv()[3], bbFace.uv()[2], bbFace.uv()[3],
texWidth, texHeight, texWidth, texHeight,
@ -153,6 +184,35 @@ public class BbmodelBaker {
return cube; 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) { private static String nameOf(Direction d) {
return d.getName(); return d.getName();
} }

@ -4,6 +4,7 @@ import com.mojang.serialization.MapCodec;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext; import net.minecraft.world.item.context.BlockPlaceContext;
@ -22,14 +23,29 @@ import net.minecraft.world.phys.BlockHitResult;
public class ClosetBlock extends BaseEntityBlock { 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 MapCodec<ClosetBlock> CODEC = simpleCodec(ClosetBlock::new);
public static final EnumProperty<Direction> FACING = HorizontalDirectionalBlock.FACING; public static final EnumProperty<Direction> FACING = HorizontalDirectionalBlock.FACING;
private final Identifier variantId;
public ClosetBlock(Properties properties) { public ClosetBlock(Properties properties) {
this(properties, null);
}
public ClosetBlock(Properties properties, Identifier variantId) {
super(properties); super(properties);
this.variantId = variantId;
registerDefaultState(defaultBlockState().setValue(FACING, Direction.NORTH)); registerDefaultState(defaultBlockState().setValue(FACING, Direction.NORTH));
} }
public Identifier variantId() {
return variantId;
}
@Override @Override
protected MapCodec<? extends BaseEntityBlock> codec() { protected MapCodec<? extends BaseEntityBlock> codec() {
return 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 com.razz.dfashion.DecoFashion;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.world.item.BlockItem; import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour; 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.DeferredItem;
import net.neoforged.neoforge.registries.DeferredRegister; import net.neoforged.neoforge.registries.DeferredRegister;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public final class ClosetRegistry { public final class ClosetRegistry {
public static final DeferredRegister.Blocks BLOCKS = public static final DeferredRegister.Blocks BLOCKS =
@ -26,23 +29,48 @@ public final class ClosetRegistry {
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES = public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, DecoFashion.MODID); DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, DecoFashion.MODID);
@SuppressWarnings("removal") private static final Map<Identifier, ClosetCatalog.Entry> CATALOG = ClosetCatalog.loadFromClasspath();
public static final DeferredBlock<ClosetBlock> CLOSET = BLOCKS.registerBlock( private static final Map<Identifier, DeferredBlock<ClosetBlock>> BLOCKS_BY_VARIANT = new LinkedHashMap<>();
static {
for (ClosetCatalog.Entry entry : CATALOG.values()) {
registerVariant(entry);
}
}
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<ClosetBlockEntity>> CLOSET_BE =
BLOCK_ENTITIES.register(
"closet", "closet",
ClosetBlock::new, () -> 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() BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD) .mapColor(MapColor.WOOD)
.strength(2.0f) .strength(2.0f)
.noOcclusion() .noOcclusion()
); );
BLOCKS_BY_VARIANT.put(variantId, block);
ITEMS.registerSimpleBlockItem(block);
}
public static final DeferredItem<BlockItem> CLOSET_ITEM = ITEMS.registerSimpleBlockItem(CLOSET); public static Map<Identifier, ClosetCatalog.Entry> catalog() {
return Collections.unmodifiableMap(CATALOG);
}
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<ClosetBlockEntity>> CLOSET_BE = public static Map<Identifier, DeferredBlock<ClosetBlock>> blocks() {
BLOCK_ENTITIES.register( return Collections.unmodifiableMap(BLOCKS_BY_VARIANT);
"closet", }
() -> new BlockEntityType<>(ClosetBlockEntity::new, CLOSET.get())
);
public static void register(IEventBus bus) { public static void register(IEventBus bus) {
BLOCKS.register(bus); BLOCKS.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 bbmodelLen;
int width; int width;
int height; int height;
int frametime;
int frames;
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
} }
@ -105,7 +107,7 @@ public final class ClientSharedCosmeticCache {
* state; mismatches clear in-flight state. * state; mismatches clear in-flight state.
*/ */
public static void onChunk(String hash, int index, int total, int bbmodelLen, 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)) { if (!SharedCosmeticCache.isValidHash(hash)) {
DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash"); DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash");
return; return;
@ -127,6 +129,16 @@ public final class ClientSharedCosmeticCache {
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return; 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) { if (data == null || data.length > SharedCosmeticCache.MAX_CHUNK_BYTES) {
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash); DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
@ -144,9 +156,12 @@ public final class ClientSharedCosmeticCache {
d.bbmodelLen = bbmodelLen; d.bbmodelLen = bbmodelLen;
d.width = width; d.width = width;
d.height = height; d.height = height;
d.frametime = frametime;
d.frames = frames;
d.buffer.reset(); d.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index } 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); DecoFashion.LOGGER.warn("shared cosmetic {}: chunk mismatch at {}/{}", hash, index, total);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return; return;
@ -187,10 +202,12 @@ public final class ClientSharedCosmeticCache {
// Bake first; only write the blob to disk after it validates. Writing first would // 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 // leave a rejected blob lingering on disk, where scanDisk would keep re-hydrating it
// and failing forever. // 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; 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 { try {
Files.createDirectories(root()); Files.createDirectories(root());
Files.write(fileFor(hash), blob); Files.write(fileFor(hash), blob);
@ -212,7 +229,8 @@ public final class ClientSharedCosmeticCache {
Files.deleteIfExists(fileFor(hash)); Files.deleteIfExists(fileFor(hash));
throw new IOException("blob corrupt"); 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) { if (!ok) {
Files.deleteIfExists(fileFor(hash)); Files.deleteIfExists(fileFor(hash));
throw new IOException("bake rejected"); 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. * disk that subsequent {@code scanDisk} calls would keep re-hydrating and failing.
*/ */
private static boolean bakeAndRegister(String hash, byte[] bbmodelBin, 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; Bbmodel bbmodel;
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin); ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin);
try { try {
@ -271,15 +291,37 @@ public final class ClientSharedCosmeticCache {
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); 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(); TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier texId = textureIdFor(hash); Identifier texId = textureIdFor(hash);
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)); tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img));
}
Map<String, ModelPart> parts = BbmodelBaker.bake( Map<String, ModelPart> parts = BbmodelBaker.bake(
bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true); bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true);
BAKED.put(hash, new CosmeticCache.Baked(parts, texId)); BAKED.put(hash, new CosmeticCache.Baked(parts, texId));
DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{} parts={}", DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{}{} parts={}",
hash, width, height, parts.keySet()); hash, width, height,
frames > 1 ? " flipbook=" + frametime + "t×" + frames + "f" : "",
parts.keySet());
return true; return true;
} }
@ -298,6 +340,7 @@ public final class ClientSharedCosmeticCache {
CosmeticCache.Baked removed = BAKED.remove(hash); CosmeticCache.Baked removed = BAKED.remove(hash);
boolean changed = removed != null; boolean changed = removed != null;
if (removed != null) { if (removed != null) {
com.razz.dfashion.client.FlipbookTicker.unregister(removed.texture());
try { try {
Minecraft.getInstance().getTextureManager().release(removed.texture()); Minecraft.getInstance().getTextureManager().release(removed.texture());
} catch (Throwable t) { } catch (Throwable t) {

@ -41,12 +41,27 @@ public final class ClientSharedCosmeticUploader {
private ClientSharedCosmeticUploader() {} private ClientSharedCosmeticUploader() {}
public static String upload(Path bbmodelFile, Path textureFile, String displayName) { 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); DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result);
return 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(bbmodelFile)) return "Not a file: " + bbmodelFile;
if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile; if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile;
@ -93,11 +108,19 @@ public final class ClientSharedCosmeticUploader {
out.release(); 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; String hash;
try { try {
hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin, hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin,
decoded.width, decoded.height, decoded.rgba); decoded.width, decoded.height,
frametime, frames,
decoded.rgba);
} catch (NoSuchAlgorithmException ex) { } catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable"; return "SHA-256 unavailable";
} }
@ -130,6 +153,7 @@ public final class ClientSharedCosmeticUploader {
new UploadCosmeticChunk( new UploadCosmeticChunk(
i, total, bbmodelLen, i, total, bbmodelLen,
decoded.width, decoded.height, decoded.width, decoded.height,
frametime, frames,
slice slice
))); )));
} }

@ -48,6 +48,10 @@ public final class ClientSkinCache {
int nextIndex; int nextIndex;
int width; int width;
int height; int height;
int frametime;
int frames;
int holdFrame;
int holdFrametime;
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
} }
@ -82,20 +86,17 @@ public final class ClientSkinCache {
try { try {
byte[] blob = Files.readAllBytes(fileFor(hash)); byte[] blob = Files.readAllBytes(fileFor(hash));
int[] dims = SkinCache.readHeader(blob); SkinCache.BlobView view = SkinCache.readBlob(blob);
if (dims == null) { if (view == null) {
DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash); DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash);
return null; return null;
} }
int w = dims[0], h = dims[1]; byte[] rgba = SkinCache.inflateBounded(view.deflatedRgba(),
if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) { SkinCache.rgbaByteCount(view.width(), view.height()));
DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h); return registerFromPixels(hash, view.width(), view.height(),
return null; view.frametime(), view.frames(),
} view.holdFrame(), view.holdFrametime(),
byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE]; rgba);
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);
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex); DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex);
return null; return null;
@ -116,12 +117,35 @@ public final class ClientSkinCache {
* (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more * (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more
* chunks are expected or on error. * chunks are expected or on error.
*/ */
public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) { public static Identifier onChunk(String hash, int index, int total, int width, int height,
if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) { 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); DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return null; 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); Download d = IN_FLIGHT.get(hash);
if (d == null) { if (d == null) {
@ -133,9 +157,15 @@ public final class ClientSkinCache {
d.nextIndex = 0; d.nextIndex = 0;
d.width = width; d.width = width;
d.height = height; d.height = height;
d.frametime = frametime;
d.frames = frames;
d.holdFrame = holdFrame;
d.holdFrametime = holdFrametime;
d.buffer.reset(); d.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index } 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); DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return null; return null;
@ -170,12 +200,19 @@ public final class ClientSkinCache {
try { try {
Files.createDirectories(root()); 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) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, 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 * 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 * 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). * 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; int expected;
try { try {
expected = SkinCache.rgbaByteCount(width, height); expected = SkinCache.rgbaByteCount(width, height);
@ -208,11 +252,35 @@ public final class ClientSkinCache {
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
} }
} }
TextureManager tm = Minecraft.getInstance().getTextureManager(); TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier id = textureIdFor(hash); Identifier id = textureIdFor(hash);
// 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)); tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img));
}
REGISTERED.put(hash, id); 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; return id;
} }
@ -255,6 +323,7 @@ public final class ClientSkinCache {
Identifier id = REGISTERED.remove(hash); Identifier id = REGISTERED.remove(hash);
boolean changed = id != null; boolean changed = id != null;
if (id != null) { if (id != null) {
com.razz.dfashion.client.FlipbookTicker.unregister(id);
try { try {
Minecraft.getInstance().getTextureManager().release(id); Minecraft.getInstance().getTextureManager().release(id);
} catch (Throwable t) { } catch (Throwable t) {
@ -277,12 +346,37 @@ public final class ClientSkinCache {
* it immediately. Returns a short user-facing status string. * it immediately. Returns a short user-facing status string.
*/ */
public static String uploadFromFile(Path file, SkinModel model) { 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); DecoFashion.LOGGER.info("Skin upload: {}", result);
return 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; if (!Files.isRegularFile(file)) return "Not a file: " + file;
byte[] png; byte[] png;
@ -303,6 +397,12 @@ public final class ClientSkinCache {
return "Rejected: " + ex.getMessage(); 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); byte[] deflated = deflate(decoded.rgba);
if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) { if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) {
return "Too large after compression: " + deflated.length; return "Too large after compression: " + deflated.length;
@ -313,7 +413,8 @@ public final class ClientSkinCache {
String localHash; String localHash;
try { 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) { } catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable"; return "SHA-256 unavailable";
} }
@ -321,10 +422,17 @@ public final class ClientSkinCache {
try { try {
Files.createDirectories(root()); Files.createDirectories(root());
if (!hasOnDisk(localHash)) { 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)) { 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) { } catch (IOException ex) {
DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage()); DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage());
@ -338,7 +446,10 @@ public final class ClientSkinCache {
byte[] slice = new byte[len]; byte[] slice = new byte[len];
System.arraycopy(deflated, off, slice, 0, len); System.arraycopy(deflated, off, slice, 0, len);
conn.send(new ServerboundCustomPayloadPacket( 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 + " (" return "Uploading " + decoded.width + "x" + decoded.height + " ("
+ deflated.length + " bytes, " + total + " chunks, hash " + 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) { public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) {
ClientPacketListener conn = Minecraft.getInstance().getConnection(); ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return; if (conn == null) return;

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

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

@ -2,8 +2,10 @@ package com.razz.dfashion.client;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
public class ClosetRenderState extends BlockEntityRenderState { public class ClosetRenderState extends BlockEntityRenderState {
public boolean open = false; public boolean open = false;
public Direction facing = Direction.NORTH; 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); BlockEntityRenderState.extractBase(be, state, breakProgress);
state.open = be.isOpen(); state.open = be.isOpen();
state.facing = be.getBlockState().getValue(ClosetBlock.FACING); state.facing = be.getBlockState().getValue(ClosetBlock.FACING);
state.variantId = be.getBlockState().getBlock() instanceof ClosetBlock cb ? cb.variantId() : null;
} }
@Override @Override
public void submit(ClosetRenderState state, PoseStack poseStack, public void submit(ClosetRenderState state, PoseStack poseStack,
SubmitNodeCollector collector, CameraRenderState camera) { 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; if (baked == null) return;
poseStack.pushPose(); poseStack.pushPose();
// Center on the block, then rotate to face the placed direction. // Center on the block, then rotate to face the placed direction.
// Our bbmodels author the closet's door/front on -Z (not Blockbench's // Our bbmodels author the closet's door/front on -Z (not Blockbench's
// canonical +Z), so rotation = 180° - facing.toYRot() takes bbmodel -Z // canonical +Z), so rotation = 180° - facing.toYRot() takes bbmodel -Z
// to world FACING. ModelPart.Cube already stores vertices pre-scaled by // to world FACING. (Direction.toYRot() values: NORTH=180, SOUTH=0,
// 1/16, so no additional pixel→block scaling is needed here. // 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.translate(0.5f, 0.0f, 0.5f);
poseStack.mulPose(new org.joml.Quaternionf().rotationY( poseStack.mulPose(new org.joml.Quaternionf().rotationY(
(float) Math.toRadians(180f - state.facing.toYRot()) (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()) { for (ModelPart part : baked.parts().values()) {
collector.submitModelPart( collector.submitModelPart(
part, part,
poseStack, poseStack,
RenderTypes.entityTranslucent(baked.texture()), RenderTypes.entityTranslucent(variant.texture()),
state.lightCoords, state.lightCoords,
OverlayTexture.NO_OVERLAY, OverlayTexture.NO_OVERLAY,
null null

@ -45,9 +45,9 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords, public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords,
AvatarRenderState state, float yRot, float xRot) { AvatarRenderState state, float yRot, float xRot) {
// Local cache may be empty (no built-in / user-folder cosmetics loaded) yet the player // Local cache may be empty (no built-in cosmetics loaded) yet the player can still
// can still have Shared refs that resolve through ClientSharedCosmeticCache. Don't // have Shared refs that resolve through ClientSharedCosmeticCache. Don't early-return
// early-return on local emptiness. // on local emptiness.
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics; Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
Minecraft mc = Minecraft.getInstance(); 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) { if (size > MAX_PNG_FILE_BYTES) {
throw new IOException("PNG file too large: " + 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); SafePngReader.Image decoded = SafePngReader.decode(png);
NativeImage img = new NativeImage(decoded.width, decoded.height, false); NativeImage img = new NativeImage(decoded.width, decoded.height, false);
int src = 0; int src = 0;

@ -188,6 +188,30 @@ public class ClosetScreen extends Screen {
private @Nullable StringWidget shareStatusLabel; private @Nullable StringWidget shareStatusLabel;
private String shareStatusMessage = ""; 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. */ /** 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; private static final int SHARE_NAME_MAX_LEN = 64;
/** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */ /** 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(); buildShareForm();
return; return;
} }
if (showingSkinFlipbookForm) {
buildSkinFlipbookForm();
return;
}
lastSharedEnabled = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get(); lastSharedEnabled = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
lastBodyHideEnabled = DecoFashionConfig.BODY_HIDE_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; int gridY = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Center the 3-button action column vertically against the 36-tall grid row. // Center the 4-button action column vertically against the 36-tall grid row.
int columnHeight = SKIN_ACTION_H * 3 + SKIN_ACTION_GAP * 2; // 68 int columnHeight = SKIN_ACTION_H * 4 + SKIN_ACTION_GAP * 3; // 92
int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2; // gridY - 16 int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2;
Button upload = Button.builder( Button upload = Button.builder(
Component.literal("Upload Skin"), Component.literal("Upload Skin"),
@ -556,10 +584,18 @@ public class ClosetScreen extends Screen {
addRenderableWidget(upload); addRenderableWidget(upload);
cosmeticButtons.add(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( Button toggle = Button.builder(
Component.literal(pendingSkinModel == SkinModel.SLIM ? "Slim" : "Wide"), Component.literal(pendingSkinModel == SkinModel.SLIM ? "Slim" : "Wide"),
b -> flipSkinModel() 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(); SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(toggle); addRenderableWidget(toggle);
cosmeticButtons.add(toggle); cosmeticButtons.add(toggle);
@ -572,7 +608,7 @@ public class ClosetScreen extends Screen {
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE)); ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll(); 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(); SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(reset); addRenderableWidget(reset);
cosmeticButtons.add(reset); cosmeticButtons.add(reset);
@ -919,6 +955,11 @@ public class ClosetScreen extends Screen {
shareNameField = null; shareNameField = null;
shareStatusLabel = null; shareStatusLabel = null;
shareStatusMessage = ""; shareStatusMessage = "";
shareFlipbookFramesField = null;
shareFlipbookFrametimeField = null;
shareFlipbookEnabled = false;
shareFlipbookFramesText = "";
shareFlipbookFrametimeText = "";
rebuildWidgets(); rebuildWidgets();
} }
@ -979,6 +1020,49 @@ public class ClosetScreen extends Screen {
addRenderableWidget(shareNameField); addRenderableWidget(shareNameField);
y += fieldH + rowGap; 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). // Status line (updated after submit).
shareStatusLabel = new StringWidget( shareStatusLabel = new StringWidget(
x0 + 10, y, fieldW, 15, x0 + 10, y, fieldW, 15,
@ -1061,7 +1145,35 @@ public class ClosetScreen extends Screen {
if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString()); 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); setShareStatus(result);
// Close the form on successful submission. "Uploading " means chunks went to the // 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)); 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. */ /** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */
private static String sanitizeSharePath(String raw) { private static String sanitizeSharePath(String raw) {
if (raw == null) return ""; if (raw == null) return "";
@ -1097,6 +1461,19 @@ public class ClosetScreen extends Screen {
return s; 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. */ /** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */
private static String sanitizeShareName(String raw) { private static String sanitizeShareName(String raw) {
if (raw == null) return ""; if (raw == null) return "";
@ -1511,6 +1888,8 @@ public class ClosetScreen extends Screen {
// Toggle off → render nothing (no frames, no entities). Buttons are already // Toggle off → render nothing (no frames, no entities). Buttons are already
// hidden + inactive (handled in rebuildCosmeticRow). // hidden + inactive (handled in rebuildCosmeticRow).
if (!showPreviews) return; if (!showPreviews) return;
// Modal forms cover the whole screen — previews would clip through their widgets.
if (showingShareForm || showingSkinFlipbookForm) return;
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player; LocalPlayer player = mc.player;

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

@ -2,9 +2,22 @@ package com.razz.dfashion.cosmetic;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import org.jspecify.annotations.Nullable;
public record CosmeticDefinition( public record CosmeticDefinition(
String displayName, String displayName,
String category, String category,
Identifier model, 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: * One of two ways an equipped cosmetic slot can reference its content:
* <ul> * <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> * Resolved against {@code CosmeticCache.cosmetics}.</li>
* <li>{@link Shared} a server-side blob addressed by its 64-char content hash. * <li>{@link Shared} a server-side blob addressed by its 64-char content hash.
* Resolved against {@code ClientSharedCosmeticCache}.</li> * 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.index(), msg.total(),
msg.bbmodelLen(), msg.bbmodelLen(),
msg.width(), msg.height(), msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.data() msg.data()
); );
if (finalized == null) return; if (finalized == null) return;
@ -295,6 +296,7 @@ public final class CosmeticShareNetwork {
msg.hash(), msg.hash(),
i, total, bbmodelLen, i, total, bbmodelLen,
view.width(), view.height(), view.width(), view.height(),
view.frametime(), view.frames(),
slice slice
))); )));
} }
@ -431,6 +433,7 @@ public final class CosmeticShareNetwork {
com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk( com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk(
msg.hash(), msg.index(), msg.total(), msg.hash(), msg.index(), msg.total(),
msg.bbmodelLen(), msg.width(), msg.height(), msg.bbmodelLen(), msg.width(), msg.height(),
msg.frametime(), msg.frames(),
msg.data() msg.data()
); );
} }

@ -26,10 +26,10 @@ import java.util.concurrent.ConcurrentHashMap;
* Server-side shared-cosmetic store. Holds content-addressed <b>{@code .dfcos} blobs</b> * Server-side shared-cosmetic store. Holds content-addressed <b>{@code .dfcos} blobs</b>
* under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}. * under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
* *
* <p>Blob layout (big-endian everywhere): * <p>Blob layout v1 (big-endian, original):
* <pre> * <pre>
* [4 bytes] magic "DFCO" * [4 bytes] magic "DFCO"
* [1 byte] version (currently 1) * [1 byte] version = 1
* [4 bytes] u32 bbmodel-binary length * [4 bytes] u32 bbmodel-binary length
* [N bytes] bbmodel binary (per {@link BbmodelCodec}) * [N bytes] bbmodel binary (per {@link BbmodelCodec})
* [2 bytes] u16 texture width * [2 bytes] u16 texture width
@ -37,6 +37,22 @@ import java.util.concurrent.ConcurrentHashMap;
* [M bytes] deflated RGBA pixels * [M bytes] deflated RGBA pixels
* </pre> * </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 * <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 * bbmodel-binary + already-decoded-and-deflated RGBA from the authoring client. Every
* read path validates the magic header before trusting a byte. * 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 String FILE_EXT = ".dfcos";
public static final byte[] MAGIC = { 'D', 'F', 'C', 'O' }; 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. */ /** Flipbook frametime/frames are u16 caps mirror that. Real cosmetics use 32 frames
public static final int HEADER_FIXED_SIZE = 13; * 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 /** 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. */ * .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() {} private SharedCosmeticCache() {}
/** Parsed view of a {@code .dfcos} blob. */ /** Parsed view of a {@code .dfcos} blob. {@code frametime}/{@code frames} are 0/1 for
public record BlobView(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {} * 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 /** Result of a successful upload the canonical hash, dims, the canonicalized
* bbmodel binary as stored on disk, and the extracted bone set for wardrobe * bbmodel binary as stored on disk, and the extracted bone set for wardrobe
@ -76,6 +105,8 @@ public final class SharedCosmeticCache {
String hash, String hash,
int width, int width,
int height, int height,
int frametime,
int frames,
byte[] canonicalBbmodelBinary, byte[] canonicalBbmodelBinary,
List<String> bones List<String> bones
) {} ) {}
@ -87,6 +118,8 @@ public final class SharedCosmeticCache {
int bbmodelLen; int bbmodelLen;
int width; int width;
int height; int height;
int frametime;
int frames;
ByteArrayOutputStream buffer; ByteArrayOutputStream buffer;
} }
@ -121,14 +154,15 @@ public final class SharedCosmeticCache {
/** /**
* Validate magic + parse header off a blob. Returns a {@link BlobView} on success, * 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 * {@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) { 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++) { for (int i = 0; i < MAGIC.length; i++) {
if (blob[i] != MAGIC[i]) return null; 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); int bbmodelLen = readU32(blob, 5);
if (bbmodelLen < 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) return null; 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); int h = ((blob[widthOff + 2] & 0xFF) << 8) | (blob[widthOff + 3] & 0xFF);
if (w <= 0 || h <= 0 || w > MAX_DIM || h > MAX_DIM) return null; 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]; byte[] bbmodel = new byte[bbmodelLen];
System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen); System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen);
int deflatedStart = widthOff + 4; int deflatedLen = blob.length - afterDims;
int deflatedLen = blob.length - deflatedStart;
if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null; if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null;
byte[] deflated = new byte[deflatedLen]; 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}. */ /** Assemble a v2 wire-format blob. Reverse of {@link #readBlob}. Pass {@code frametime=0,
public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) { * 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) { if (bbmodelBinary.length > MAX_BBMODEL_BIN_BYTES) {
throw new IllegalArgumentException("bbmodel binary too large: " + bbmodelBinary.length); 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) { if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
throw new IllegalArgumentException("bad dims " + width + "x" + 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");
}
int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length; int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length;
byte[] out = new byte[size]; byte[] out = new byte[size];
System.arraycopy(MAGIC, 0, out, 0, 4); System.arraycopy(MAGIC, 0, out, 0, 4);
@ -174,16 +230,22 @@ public final class SharedCosmeticCache {
out[widthOff + 1] = (byte) (width & 0xFF); out[widthOff + 1] = (byte) (width & 0xFF);
out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF); out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF);
out[widthOff + 3] = (byte) (height & 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; return out;
} }
/** /**
* Hash over {@code [bbmodelBinary || u16 w || u16 h || raw RGBA]}. Canonical across * Hash over {@code [bbmodelBinary || u16 w || u16 h || u16 frametime || u16 frames || raw RGBA]}.
* deflate levels so identical content dedups regardless of who encoded it. Pass raw * Canonical across deflate levels so identical content dedups regardless of who encoded it.
* (inflated) pixels, not the deflate stream. * 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 { throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(bbmodelBinary); md.update(bbmodelBinary);
@ -191,6 +253,10 @@ public final class SharedCosmeticCache {
md.update((byte) (width & 0xFF)); md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF)); md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 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); md.update(rawRgba);
byte[] digest = md.digest(); byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(64); StringBuilder sb = new StringBuilder(64);
@ -237,7 +303,9 @@ public final class SharedCosmeticCache {
public static Finalized acceptChunk( public static Finalized acceptChunk(
MinecraftServer server, UUID playerId, MinecraftServer server, UUID playerId,
int index, int total, int bbmodelLen, 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) { if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: invalid chunk {}/{}", playerId, 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); IN_FLIGHT.remove(playerId);
return null; 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) { if (data == null || data.length > MAX_CHUNK_BYTES) {
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})", DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})",
playerId, data == null ? -1 : data.length); playerId, data == null ? -1 : data.length);
@ -269,6 +354,8 @@ public final class SharedCosmeticCache {
asm.bbmodelLen = bbmodelLen; asm.bbmodelLen = bbmodelLen;
asm.width = width; asm.width = width;
asm.height = height; asm.height = height;
asm.frametime = frametime;
asm.frames = frames;
asm.buffer = new ByteArrayOutputStream(Math.min( asm.buffer = new ByteArrayOutputStream(Math.min(
total * MAX_CHUNK_BYTES, total * MAX_CHUNK_BYTES,
MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES)); MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES));
@ -277,7 +364,8 @@ public final class SharedCosmeticCache {
asm = IN_FLIGHT.get(playerId); asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
|| asm.bbmodelLen != bbmodelLen || 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 {}/{}", DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk mismatch at {}/{}",
playerId, index, total); playerId, index, total);
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
@ -375,7 +463,8 @@ public final class SharedCosmeticCache {
String hash; String hash;
try { try {
hash = hashContent(canonicalBbmodelBin, asm.width, asm.height, rgba); hash = hashContent(canonicalBbmodelBin, asm.width, asm.height,
asm.frametime, asm.frames, rgba);
} catch (NoSuchAlgorithmException ex) { } catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex); DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null; return null;
@ -394,7 +483,8 @@ public final class SharedCosmeticCache {
return null; 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 { try {
writeIfAbsent(server, hash, blob); writeIfAbsent(server, hash, blob);
} catch (IOException ex) { } catch (IOException ex) {
@ -405,9 +495,11 @@ public final class SharedCosmeticCache {
List<String> bones = BoneExtraction.fromBbmodel(bbmodel); List<String> bones = BoneExtraction.fromBbmodel(bbmodel);
DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} bbmodel={}B deflated={}B 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, canonicalBbmodelBin.length, deflatedRgba.length, bones); playerId, hash, asm.width, asm.height, asm.frametime, asm.frames,
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones); 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. * {@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 * <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( public record CosmeticChunk(
String hash, String hash,
@ -24,6 +24,8 @@ public record CosmeticChunk(
int bbmodelLen, int bbmodelLen,
int width, int width,
int height, int height,
int frametime,
int frames,
byte[] data byte[] data
) implements CustomPacketPayload { ) implements CustomPacketPayload {
@ -38,6 +40,8 @@ public record CosmeticChunk(
ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen, ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen,
ByteBufCodecs.VAR_INT, CosmeticChunk::width, ByteBufCodecs.VAR_INT, CosmeticChunk::width,
ByteBufCodecs.VAR_INT, CosmeticChunk::height, ByteBufCodecs.VAR_INT, CosmeticChunk::height,
ByteBufCodecs.VAR_INT, CosmeticChunk::frametime,
ByteBufCodecs.VAR_INT, CosmeticChunk::frames,
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data, ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data,
CosmeticChunk::new CosmeticChunk::new
); );

@ -12,8 +12,11 @@ import net.minecraft.resources.Identifier;
/** /**
* Client server. One chunk of a shared-cosmetic upload. The payload bytes are the * 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 * concatenation {@code [bbmodel binary][deflated RGBA]}; the authoring client knows both
* halves up-front so {@code bbmodelLen}, {@code width}, and {@code height} are repeated * halves up-front so {@code bbmodelLen}, {@code width}, {@code height}, and the flipbook
* on every chunk for defense-in-depth mismatch detection on the server. * 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 * <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 * by {@link com.razz.dfashion.cosmetic.share.BbmodelCodec} on the author's machine; the
@ -25,6 +28,8 @@ public record UploadCosmeticChunk(
int bbmodelLen, int bbmodelLen,
int width, int width,
int height, int height,
int frametime,
int frames,
byte[] data byte[] data
) implements CustomPacketPayload { ) implements CustomPacketPayload {
@ -38,6 +43,8 @@ public record UploadCosmeticChunk(
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen, ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width, ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height, ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::frametime,
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::frames,
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data, ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data,
UploadCosmeticChunk::new UploadCosmeticChunk::new
); );

@ -25,7 +25,15 @@ import java.util.zip.Inflater;
*/ */
public final class SafePngReader { 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; 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. */ /** Cap on a single chunk's declared length (128 MB). Defensive; real chunks are tiny. */
private static final int MAX_CHUNK_BYTES = 1 << 27; private static final int MAX_CHUNK_BYTES = 1 << 27;
@ -114,9 +122,16 @@ public final class SafePngReader {
int compression = src[dataStart + 10] & 0xFF; int compression = src[dataStart + 10] & 0xFF;
int filter = src[dataStart + 11] & 0xFF; int filter = src[dataStart + 11] & 0xFF;
int interlace = src[dataStart + 12] & 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); 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: // Accept the three color types common in authored skins/cosmetics:
// - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255 // - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255
// - Type 3 (palette/indexed) depths 1/2/4/8 — PLTE + optional tRNS // - 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 /** {@code MAX_HEIGHT * (1 + MAX_DIM * 8)} the largest legal {@code rawLen} across both
* supported bit depths (8 = 4 bpp, 16 = 8 bpp). */ * supported bit depths (8 = 4 bpp, 16 = 8 bpp), with height extended to support flipbook
private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_DIM * (1L + (long) MAX_DIM * 8L); * 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 { private static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
Inflater inf = new Inflater(); 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 * Server-side skin store. Holds <b>pixel blobs</b> on disk under
* {@code <serverDir>/decofashion/skins/<hash>.dfskin} with an 8-byte header * {@code <serverDir>/decofashion/skins/<hash>.dfskin}.
* {@code [4-byte magic "DFSK"][u16 width][u16 height]} followed by the deflated RGBA payload. *
* <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 * <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 * 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 { public final class SkinCache {
/** Hard ceiling on raw RGBA size (4096² × 4 bytes = 64 MB). Per-image cap derives from dims. */ /** Hard ceiling on raw RGBA size 256 MB. Sized to accommodate flipbook strips
public static final int MAX_RAW_BYTES = 4096 * 4096 * 4; * (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. */ /** Per-wire-packet cap: keeps single packets well under the channel MTU. */
public static final int MAX_CHUNK_BYTES = 512 * 1024; public static final int MAX_CHUNK_BYTES = 512 * 1024;
/** Total deflated bytes we'll accept for a single upload/download. */ /** Total deflated bytes we'll accept for a single upload/download. Raised in step with
public static final int MAX_DEFLATED_BYTES = 32 * 1024 * 1024; * {@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; 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. */ /** Proprietary file-format extension. No OS or tool has a handler for it. */
public static final String FILE_EXT = ".dfskin"; public static final String FILE_EXT = ".dfskin";
/** "DFSK" in ASCII — prepended to every blob; every read validates it before trusting bytes. */ /** v1 magic (legacy blobs already on disk). Read-only. */
public static final byte[] MAGIC = { 'D', 'F', 'S', 'K' }; 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' };
/** Magic (4) + width u16 (2) + height u16 (2) = 8. */ public static final int HEADER_SIZE_V1 = 8; // magic(4) + w(2) + h(2)
public static final int HEADER_SIZE = 8; public static final int HEADER_SIZE_V2 = 16; // + frametime(2) + frames(2) + holdFrame(2) + holdFrametime(2)
/** 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"; private static final String SUBDIR = "decofashion/skins";
@ -60,12 +89,24 @@ public final class SkinCache {
private 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. */ /** Per-player chunk assembly state. */
private static final class Assembly { private static final class Assembly {
int expectedTotal; int expectedTotal;
int nextIndex; int nextIndex;
int width; int width;
int height; int height;
int frametime;
int frames;
int holdFrame;
int holdFrametime;
SkinModel model; SkinModel model;
ByteArrayOutputStream buffer; 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 * Validate the magic + parse a v1 or v2 header off a blob. Returns a {@link BlobView}
* {@code [w, h]} on success, or {@code null} if the magic doesn't match or the blob * with width, height, flipbook spec, and the deflated body bytes. Returns {@code null}
* is too short. No trust extended to the deflated body until this passes. * if the blob is short, magic mismatch, or out-of-range fields.
*/ */
public static int[] readHeader(byte[] blob) { public static BlobView readBlob(byte[] blob) {
if (blob == null || blob.length < HEADER_SIZE) return null; if (blob == null || blob.length < HEADER_SIZE_V1) return null;
for (int i = 0; i < MAGIC.length; i++) { boolean v2 = matches(blob, MAGIC);
if (blob[i] != MAGIC[i]) return null; boolean v1 = matches(blob, MAGIC_V1);
} if (!v1 && !v2) return null;
int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF); int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF);
int h = ((blob[6] & 0xFF) << 8) | (blob[7] & 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( public static SkinData acceptChunk(
MinecraftServer server, UUID playerId, 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) { if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, 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); IN_FLIGHT.remove(playerId);
return null; 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); DecoFashion.LOGGER.warn("Skin upload from {}: bad dims {}x{}", playerId, width, height);
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
return null; 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; Assembly asm;
if (index == 0) { if (index == 0) {
@ -151,13 +257,19 @@ public final class SkinCache {
asm.nextIndex = 0; asm.nextIndex = 0;
asm.width = width; asm.width = width;
asm.height = height; asm.height = height;
asm.frametime = frametime;
asm.frames = frames;
asm.holdFrame = holdFrame;
asm.holdFrametime = holdFrametime;
asm.model = model; asm.model = model;
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_DEFLATED_BYTES)); asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_DEFLATED_BYTES));
IN_FLIGHT.put(playerId, asm); IN_FLIGHT.put(playerId, asm);
} else { } else {
asm = IN_FLIGHT.get(playerId); asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index 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); DecoFashion.LOGGER.warn("Skin upload from {}: chunk mismatch at {}/{}", playerId, index, total);
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
return null; return null;
@ -201,7 +313,10 @@ public final class SkinCache {
String hash; String hash;
try { 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) { } catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex); DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null; return null;
@ -226,15 +341,20 @@ public final class SkinCache {
Files.createDirectories(root(server)); Files.createDirectories(root(server));
Path target = fileFor(server, hash); Path target = fileFor(server, hash);
if (!Files.isRegularFile(target)) { 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) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex); DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex);
return null; return null;
} }
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} dims={}x{} deflated={} model={}", DecoFashion.LOGGER.info(
playerId, hash, asm.width, asm.height, deflated.length, asm.model); "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); return new SkinData(hash, asm.model);
} }
@ -262,15 +382,46 @@ public final class SkinCache {
return out; return out;
} }
/** Build a wire-format blob: magic + (u16 w, u16 h) + deflated pixels. */ /** Build a v2 wire-format blob: magic + (w, h, frametime, frames, holdFrame, holdFrametime)
public static byte[] buildBlob(int width, int height, byte[] deflated) { * + deflated pixels. Pass {@code (0, 1, 0, 0)} for static skins; {@code (0, 0)} for
byte[] blob = new byte[HEADER_SIZE + deflated.length]; * 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); System.arraycopy(MAGIC, 0, blob, 0, MAGIC.length);
blob[4] = (byte) ((width >>> 8) & 0xFF); blob[4] = (byte) ((width >>> 8) & 0xFF);
blob[5] = (byte) (width & 0xFF); blob[5] = (byte) (width & 0xFF);
blob[6] = (byte) ((height >>> 8) & 0xFF); blob[6] = (byte) ((height >>> 8) & 0xFF);
blob[7] = (byte) (height & 0xFF); blob[7] = (byte) (height & 0xFF);
System.arraycopy(deflated, 0, blob, HEADER_SIZE, deflated.length); 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; 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 /** Hash is computed over {@code [u16 w][u16 h][u16 frametime][u16 frames][u16 holdFrame]
* deflate-level differences between clients. */ * [u16 holdFrametime][raw RGBA]} so dedup is stable across deflate-level differences,
static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException { * 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"); MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update((byte) ((width >>> 8) & 0xFF)); md.update((byte) ((width >>> 8) & 0xFF));
md.update((byte) (width & 0xFF)); md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF)); md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 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); md.update(rgba);
byte[] digest = md.digest(); byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(digest.length * 2); StringBuilder sb = new StringBuilder(digest.length * 2);

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

@ -15,12 +15,19 @@ import net.minecraft.resources.Identifier;
* *
* <p>The uploading client parses its source PNG locally with {@code SafePngReader} (pure Java, * <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. * 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 * {@code w}, {@code h}, and the flipbook spec are repeated on every chunk so reassembly can
* touching the payload; server validates that subsequent chunks carry the same dimensions as * enforce them before touching the payload; server validates that subsequent chunks carry the
* the first. No PNG container ever crosses the wire polyglot/iCCP/trailing-IDAT attacks * same dimensions and flipbook fields as the first. No PNG container ever crosses the wire
* can't exist when the attack surface is two u16s and a deflate stream. * 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 { implements CustomPacketPayload {
public static final Type<UploadSkinChunk> TYPE = 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::total,
ByteBufCodecs.VAR_INT, UploadSkinChunk::width, ByteBufCodecs.VAR_INT, UploadSkinChunk::width,
ByteBufCodecs.VAR_INT, UploadSkinChunk::height, 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, SkinModel.STREAM_CODEC, UploadSkinChunk::model,
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data, ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data,
UploadSkinChunk::new UploadSkinChunk::new

@ -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", "category": "torso",
"model": "decofashion:cosmetic/pike.bbmodel", "model": "decofashion:cosmetic/pike.bbmodel",
"texture": "decofashion:textures/cosmetic/pike.png" "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()); ALLOWLIST.put("ImageIO.read", Set.of());
// Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs // Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs
// GSON on untrusted bytes (and validates the result post-parse). // GSON on untrusted bytes (and validates the result post-parse). ClosetCatalog reads
ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java")); // 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. // Java serialization — RCE class on untrusted input. Never acceptable.
ALLOWLIST.put("ObjectInputStream", Set.of()); ALLOWLIST.put("ObjectInputStream", Set.of());

Loading…
Cancel
Save