shared cosmetics filter + bone stuff + fixes
This commit is contained in:
+22
-21
@@ -22,7 +22,7 @@ sourceSets.main.resources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
// Add here additional repositories if required by some of the dependencies below.
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
base {
|
base {
|
||||||
@@ -49,6 +49,12 @@ neoForge {
|
|||||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client2 {
|
||||||
|
client()
|
||||||
|
programArguments.addAll '--username', 'Tester2'
|
||||||
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server()
|
server()
|
||||||
programArgument '--nogui'
|
programArgument '--nogui'
|
||||||
@@ -108,27 +114,22 @@ configurations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Example optional mod dependency with JEI
|
// Security tests: JUnit 5 + Jazzer (coverage-guided fuzz harnesses). These never run
|
||||||
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
// in the mod at runtime; they're only on the test classpath.
|
||||||
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
testImplementation platform('org.junit:junit-bom:5.11.3')
|
||||||
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
testImplementation 'com.code-intelligence:jazzer-junit:0.24.0'
|
||||||
|
}
|
||||||
|
|
||||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
// Regression tests run with `./gradlew test`. Jazzer @FuzzTest methods run for `maxDuration`
|
||||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
// each by default (30s). To run a longer fuzzing session on a specific harness:
|
||||||
// The group id is ignored when searching -- in this case, it is "blank"
|
// ./gradlew test --tests "com.razz.dfashion.security.SafePngReaderSecurityTest.fuzzDecode"
|
||||||
// implementation "blank:coolmod-${mc_version}:${coolmod_version}"
|
tasks.named('test', Test) {
|
||||||
|
useJUnitPlatform()
|
||||||
// Example mod dependency using a file as dependency
|
// Jazzer loads its instrumentation agent at runtime; Java 21+ requires this flag
|
||||||
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
|
// or the JVM warns and Jazzer may fail to attach.
|
||||||
|
jvmArgs '-XX:+EnableDynamicAgentLoading'
|
||||||
// Example project dependency using a sister or child project:
|
|
||||||
// implementation project(":myproject")
|
|
||||||
|
|
||||||
// For more info:
|
|
||||||
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
|
||||||
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This block of code expands all declared replace properties in the specified resource targets.
|
// This block of code expands all declared replace properties in the specified resource targets.
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
|||||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
import com.razz.dfashion.bbmodel.BbmodelBaker;
|
import com.razz.dfashion.bbmodel.BbmodelBaker;
|
||||||
import com.razz.dfashion.block.ClosetRegistry;
|
import com.razz.dfashion.block.ClosetRegistry;
|
||||||
|
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
||||||
import com.razz.dfashion.client.ClientSkinCache;
|
import com.razz.dfashion.client.ClientSkinCache;
|
||||||
import com.razz.dfashion.client.ClosetModelCache;
|
import com.razz.dfashion.client.ClosetModelCache;
|
||||||
import com.razz.dfashion.client.ClosetRenderer;
|
import com.razz.dfashion.client.ClosetRenderer;
|
||||||
import com.razz.dfashion.client.CosmeticCache;
|
import com.razz.dfashion.client.CosmeticCache;
|
||||||
import com.razz.dfashion.client.CosmeticRenderLayer;
|
import com.razz.dfashion.client.CosmeticRenderLayer;
|
||||||
|
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.razz.dfashion.cosmetic.UserCosmeticLoader;
|
||||||
@@ -38,7 +40,6 @@ 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.InputStream;
|
|
||||||
import java.io.Reader;
|
import java.io.Reader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -50,6 +51,11 @@ 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) {
|
||||||
DecoFashion.LOGGER.info("DecoFashion client setup complete");
|
DecoFashion.LOGGER.info("DecoFashion client setup complete");
|
||||||
@@ -94,6 +100,7 @@ public class DecoFashionClient {
|
|||||||
// Pre-register every skin PNG that's already on disk so the wardrobe grid shows
|
// Pre-register every skin PNG that's already on disk so the wardrobe grid shows
|
||||||
// the player's full history without waiting on attachment sync or re-uploads.
|
// the player's full history without waiting on attachment sync or re-uploads.
|
||||||
ClientSkinCache.scanDisk();
|
ClientSkinCache.scanDisk();
|
||||||
|
ClientSharedCosmeticCache.scanDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,25 +121,38 @@ public class DecoFashionClient {
|
|||||||
for (UserCosmeticLoader.Entry entry : entries) {
|
for (UserCosmeticLoader.Entry entry : entries) {
|
||||||
Bbmodel model = modelCache.get(entry.modelFile());
|
Bbmodel model = modelCache.get(entry.modelFile());
|
||||||
if (model == null) {
|
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())) {
|
try (Reader reader = Files.newBufferedReader(entry.modelFile())) {
|
||||||
model = BbModelParser.parse(reader);
|
model = BbModelParser.parse(reader);
|
||||||
modelCache.put(entry.modelFile(), model);
|
modelCache.put(entry.modelFile(), model);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {}",
|
DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {} ({})",
|
||||||
entry.id(), entry.modelFile(), ex);
|
entry.id(), entry.modelFile(), ex.getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try (InputStream in = Files.newInputStream(entry.textureFile())) {
|
NativeImage image;
|
||||||
NativeImage image = NativeImage.read(in);
|
try {
|
||||||
Identifier texId = entry.def().texture();
|
image = SafeImageLoader.loadNativeImage(entry.textureFile());
|
||||||
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
|
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
DecoFashion.LOGGER.error("User cosmetic {}: failed to load texture {}",
|
DecoFashion.LOGGER.error("User cosmetic {}: rejected texture {} ({})",
|
||||||
entry.id(), entry.textureFile(), ex);
|
entry.id(), entry.textureFile(), ex.getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Identifier texId = entry.def().texture();
|
||||||
|
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
|
||||||
|
|
||||||
Map<String, ModelPart> parts = BbmodelBaker.bake(
|
Map<String, ModelPart> parts = BbmodelBaker.bake(
|
||||||
model, model.resolutionWidth(), model.resolutionHeight(), true);
|
model, model.resolutionWidth(), model.resolutionHeight(), true);
|
||||||
|
|||||||
@@ -7,15 +7,47 @@ import org.joml.Vector3f;
|
|||||||
import java.io.Reader;
|
import java.io.Reader;
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses .bbmodel JSON (format_version 5.0+) into an internal geometry record. Callers
|
||||||
|
* must treat the input as untrusted: the parser enforces finite-float checks, coordinate
|
||||||
|
* range caps (to prevent NaN-by-overflow in the renderer's matrix math), resolution caps
|
||||||
|
* (to align with the texture parser's pixel budget), duplicate-uuid rejection, and outliner
|
||||||
|
* reference resolution. Element/group/locator counts are intentionally uncapped — the real
|
||||||
|
* DoS bound is the file-size cap at the caller.
|
||||||
|
*/
|
||||||
public class BbModelParser {
|
public class BbModelParser {
|
||||||
|
|
||||||
|
// Caps are deliberately minimal. The primary DoS defense is the file-size cap enforced
|
||||||
|
// upstream (see DecoFashionClient.MAX_BBMODEL_FILE_BYTES): a 16 MB JSON can't hide a
|
||||||
|
// billion cubes. Element/group/locator counts and outliner node totals are therefore
|
||||||
|
// NOT capped here — a complex authored cosmetic should parse without complaint.
|
||||||
|
//
|
||||||
|
// What we DO cap:
|
||||||
|
// - resolution: matches SafePngReader's MAX_DIM so UVs stay in the same pixel budget.
|
||||||
|
// - coordinates: must be finite AND within a range that won't NaN the renderer's
|
||||||
|
// matrix math (real bbmodel content lives in [-256, 256]; 1e4 is huge slack).
|
||||||
|
// - outliner depth: purely a stack-overflow guard for the recursive validator. Real
|
||||||
|
// content never breaks 20 levels; 256 is 40× slack. If legit authoring ever hits
|
||||||
|
// this, the fix is to rewrite the walk iteratively, not to raise the cap.
|
||||||
|
public static final int MAX_RESOLUTION = 4096;
|
||||||
|
public static final int MAX_OUTLINER_DEPTH = 256;
|
||||||
|
public static final float MAX_COORD = 1e4f;
|
||||||
|
|
||||||
private static final Gson GSON = new GsonBuilder()
|
private static final Gson GSON = new GsonBuilder()
|
||||||
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||||
.registerTypeAdapter(Vector3f.class, new Vector3fDeserializer())
|
.registerTypeAdapter(Vector3f.class, new Vector3fDeserializer())
|
||||||
.registerTypeAdapter(BbOutlinerNode.class, new BbOutlinerNode.BbOutlinerNodeDeserializer())
|
.registerTypeAdapter(BbOutlinerNode.class, new BbOutlinerNode.BbOutlinerNodeDeserializer())
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
|
public static final class BadBbmodelException extends JsonParseException {
|
||||||
|
public BadBbmodelException(String message) { super(message); }
|
||||||
|
}
|
||||||
|
|
||||||
public static Bbmodel parse(Reader in) {
|
public static Bbmodel parse(Reader in) {
|
||||||
JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
|
JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
|
||||||
|
|
||||||
@@ -36,7 +68,8 @@ public class BbModelParser {
|
|||||||
List<BbCube> cubes = new ArrayList<>();
|
List<BbCube> cubes = new ArrayList<>();
|
||||||
List<BbLocator> locators = new ArrayList<>();
|
List<BbLocator> locators = new ArrayList<>();
|
||||||
|
|
||||||
for (JsonElement e : root.getAsJsonArray("elements")) {
|
JsonArray elementsArr = root.has("elements") ? root.getAsJsonArray("elements") : new JsonArray();
|
||||||
|
for (JsonElement e : elementsArr) {
|
||||||
JsonObject obj = e.getAsJsonObject();
|
JsonObject obj = e.getAsJsonObject();
|
||||||
String type = obj.has("type") ? obj.get("type").getAsString() : "cube";
|
String type = obj.has("type") ? obj.get("type").getAsString() : "cube";
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -46,17 +79,113 @@ public class BbModelParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<BbGroup> groups = GSON.fromJson(
|
JsonArray groupsArr = root.has("groups") ? root.getAsJsonArray("groups") : new JsonArray();
|
||||||
root.getAsJsonArray("groups"),
|
List<BbGroup> groups = GSON.fromJson(groupsArr, new TypeToken<List<BbGroup>>(){}.getType());
|
||||||
new TypeToken<List<BbGroup>>(){}.getType()
|
|
||||||
);
|
|
||||||
|
|
||||||
List<BbOutlinerNode> outliner = GSON.fromJson(
|
JsonArray outlinerArr = root.has("outliner") ? root.getAsJsonArray("outliner") : new JsonArray();
|
||||||
root.getAsJsonArray("outliner"),
|
List<BbOutlinerNode> outliner = GSON.fromJson(outlinerArr, new TypeToken<List<BbOutlinerNode>>(){}.getType());
|
||||||
new TypeToken<List<BbOutlinerNode>>(){}.getType()
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Bbmodel(resW, resH, cubes, locators, groups, outliner);
|
Bbmodel model = new Bbmodel(resW, resH, cubes, locators, groups, outliner);
|
||||||
|
validate(model);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs all structural invariants over a {@link Bbmodel}. Exposed so wire-side codecs
|
||||||
|
* can re-validate after deserialization; see {@code BbmodelCodec}. */
|
||||||
|
public static void validate(Bbmodel m) {
|
||||||
|
requireRange("resolution.width", m.resolutionWidth(), 1, MAX_RESOLUTION);
|
||||||
|
requireRange("resolution.height", m.resolutionHeight(), 1, MAX_RESOLUTION);
|
||||||
|
|
||||||
|
// Unified id space — outliner ElementRefs can point to either a cube or a locator.
|
||||||
|
Set<String> elementIds = new HashSet<>();
|
||||||
|
for (BbCube c : m.elements()) {
|
||||||
|
requireNonNull("element.uuid", c.uuid());
|
||||||
|
validateVec("element.from", c.from());
|
||||||
|
validateVec("element.to", c.to());
|
||||||
|
validateVec("element.origin", c.origin());
|
||||||
|
validateVec("element.rotation", c.rotation());
|
||||||
|
requireFinite("element.inflate", c.inflate());
|
||||||
|
if (c.faces() != null) {
|
||||||
|
for (Map.Entry<String, BbFace> e : c.faces().entrySet()) {
|
||||||
|
BbFace f = e.getValue();
|
||||||
|
if (f != null && f.uv() != null) {
|
||||||
|
for (float uv : f.uv()) requireFinite("face.uv", uv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!elementIds.add(c.uuid())) throw new BadBbmodelException("duplicate element uuid " + c.uuid());
|
||||||
|
}
|
||||||
|
for (BbLocator loc : m.locators()) {
|
||||||
|
requireNonNull("locator.uuid", loc.uuid());
|
||||||
|
validateVec("locator.position", loc.position());
|
||||||
|
validateVec("locator.rotation", loc.rotation());
|
||||||
|
if (!elementIds.add(loc.uuid())) throw new BadBbmodelException("duplicate locator uuid " + loc.uuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> groupIds = new HashSet<>();
|
||||||
|
for (BbGroup g : m.groups()) {
|
||||||
|
requireNonNull("group.uuid", g.uuid());
|
||||||
|
validateVec("group.origin", g.origin());
|
||||||
|
validateVec("group.rotation", g.rotation());
|
||||||
|
if (!groupIds.add(g.uuid())) throw new BadBbmodelException("duplicate group uuid " + g.uuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (BbOutlinerNode node : m.outliner()) {
|
||||||
|
validateOutlinerNode(node, 0, elementIds, groupIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateOutlinerNode(
|
||||||
|
BbOutlinerNode node, int depth,
|
||||||
|
Set<String> elementIds, Set<String> groupIds
|
||||||
|
) {
|
||||||
|
// Stack-overflow guard only; not a content limit. See class-level comment.
|
||||||
|
if (depth > MAX_OUTLINER_DEPTH) {
|
||||||
|
throw new BadBbmodelException("outliner nesting exceeded stack-safety bound (>" + MAX_OUTLINER_DEPTH + ")");
|
||||||
|
}
|
||||||
|
switch (node) {
|
||||||
|
case BbOutlinerNode.ElementRef er -> {
|
||||||
|
requireNonNull("outliner.element.uuid", er.uuid());
|
||||||
|
if (!elementIds.contains(er.uuid())) {
|
||||||
|
throw new BadBbmodelException("outliner references unknown element " + er.uuid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case BbOutlinerNode.GroupRef gr -> {
|
||||||
|
requireNonNull("outliner.group.uuid", gr.uuid());
|
||||||
|
if (!groupIds.contains(gr.uuid())) {
|
||||||
|
throw new BadBbmodelException("outliner references unknown group " + gr.uuid());
|
||||||
|
}
|
||||||
|
if (gr.children() != null) {
|
||||||
|
for (BbOutlinerNode child : gr.children()) {
|
||||||
|
validateOutlinerNode(child, depth + 1, elementIds, groupIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireRange(String label, int v, int min, int max) {
|
||||||
|
if (v < min || v > max) {
|
||||||
|
throw new BadBbmodelException(label + " out of range [" + min + "," + max + "]: " + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireNonNull(String label, String s) {
|
||||||
|
if (s == null) throw new BadBbmodelException(label + " is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateVec(String label, Vector3f v) {
|
||||||
|
if (v == null) return;
|
||||||
|
requireFinite(label + ".x", v.x);
|
||||||
|
requireFinite(label + ".y", v.y);
|
||||||
|
requireFinite(label + ".z", v.z);
|
||||||
|
if (Math.abs(v.x) > MAX_COORD || Math.abs(v.y) > MAX_COORD || Math.abs(v.z) > MAX_COORD) {
|
||||||
|
throw new BadBbmodelException(label + " out of range: (" + v.x + "," + v.y + "," + v.z + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireFinite(String label, float v) {
|
||||||
|
if (!Float.isFinite(v)) throw new BadBbmodelException(label + " is not finite: " + v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,4 +201,4 @@ public class BbModelParser {
|
|||||||
return new Vector3f(x, y, z);
|
return new Vector3f(x, y, z);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,16 @@ import com.mojang.brigadier.arguments.StringArgumentType;
|
|||||||
import com.mojang.brigadier.context.CommandContext;
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
import com.razz.dfashion.DecoFashionClient;
|
import com.razz.dfashion.DecoFashionClient;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
||||||
import com.razz.dfashion.skin.SkinModel;
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
|
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
import net.minecraft.commands.Commands;
|
import net.minecraft.commands.Commands;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
@@ -16,8 +23,12 @@ import net.neoforged.bus.api.SubscribeEvent;
|
|||||||
import net.neoforged.fml.common.EventBusSubscriber;
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
|
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
|
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
|
||||||
public final class ClientCommands {
|
public final class ClientCommands {
|
||||||
@@ -33,6 +44,29 @@ public final class ClientCommands {
|
|||||||
.executes(ctx -> uploadSkin(ctx, SkinModel.WIDE))
|
.executes(ctx -> uploadSkin(ctx, SkinModel.WIDE))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.then(Commands.literal("share_cosmetic")
|
||||||
|
.then(Commands.argument("folder", StringArgumentType.greedyString())
|
||||||
|
.executes(ClientCommands::shareCosmetic)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(Commands.literal("list_shared").executes(ClientCommands::listShared))
|
||||||
|
.then(Commands.literal("equip_shared")
|
||||||
|
.then(Commands.argument("slot", StringArgumentType.word())
|
||||||
|
.then(Commands.argument("hash", StringArgumentType.word())
|
||||||
|
.executes(ClientCommands::equipShared)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(Commands.literal("unequip_shared")
|
||||||
|
.then(Commands.argument("slot", StringArgumentType.word())
|
||||||
|
.executes(ClientCommands::unequipShared)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(Commands.literal("delete_shared")
|
||||||
|
.then(Commands.argument("hash", StringArgumentType.word())
|
||||||
|
.executes(ClientCommands::deleteShared)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,5 +95,107 @@ public final class ClientCommands {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int shareCosmetic(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
String raw = StringArgumentType.getString(ctx, "folder").trim();
|
||||||
|
if ((raw.startsWith("\"") && raw.endsWith("\"")) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
||||||
|
raw = raw.substring(1, raw.length() - 1);
|
||||||
|
}
|
||||||
|
Path folder;
|
||||||
|
try {
|
||||||
|
folder = Paths.get(raw);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Invalid path: " + raw));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!Files.isDirectory(folder)) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Not a directory: " + folder));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path bbmodelFile = null;
|
||||||
|
Path textureFile = null;
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(folder)) {
|
||||||
|
for (Path f : stream) {
|
||||||
|
if (!Files.isRegularFile(f)) continue;
|
||||||
|
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||||
|
if (bbmodelFile == null && name.endsWith(".bbmodel")) bbmodelFile = f;
|
||||||
|
else if (textureFile == null && name.endsWith(".png")) textureFile = f;
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Scan failed: " + ex.getMessage()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (bbmodelFile == null) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("No .bbmodel found in " + folder));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (textureFile == null) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("No .png found in " + folder));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String displayName = folder.getFileName().toString();
|
||||||
|
String msg = ClientSharedCosmeticUploader.upload(bbmodelFile, textureFile, displayName);
|
||||||
|
ctx.getSource().sendSuccess(() -> Component.literal(msg), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int listShared(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Not in-world"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (lib.entries().isEmpty()) {
|
||||||
|
ctx.getSource().sendSuccess(() -> Component.literal("Shared library is empty"), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (CosmeticLibraryEntry e : lib.entries()) {
|
||||||
|
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||||
|
" " + e.hash().substring(0, 8) + "… " + e.displayName()), false);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int equipShared(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
String slot = StringArgumentType.getString(ctx, "slot");
|
||||||
|
String hash = StringArgumentType.getString(ctx, "hash");
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Invalid hash"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, hash));
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Equipped " + hash.substring(0, 8) + "… in '" + slot + "'"),
|
||||||
|
false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int unequipShared(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
String slot = StringArgumentType.getString(ctx, "slot");
|
||||||
|
// Empty hash = clear slot (server-side convention in AssignSharedCosmetic handler).
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, ""));
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Unequipped '" + slot + "'"), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int deleteShared(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
String hash = StringArgumentType.getString(ctx, "hash");
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Invalid hash"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Two-phase delete: tell the server to remove from the library, then release the
|
||||||
|
// local blob + texture. Server is authoritative but the local cache is what the
|
||||||
|
// render layer pulls from until the library attachment sync round-trips.
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
|
||||||
|
ClientSharedCosmeticCache.delete(hash);
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Deleted " + hash.substring(0, 8) + "…"), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
private ClientCommands() {}
|
private ClientCommands() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
import com.razz.dfashion.bbmodel.BbmodelBaker;
|
||||||
|
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
||||||
|
import com.razz.dfashion.skin.SkinCache;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||||
|
import net.minecraft.client.renderer.texture.TextureManager;
|
||||||
|
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side store for <b>shared cosmetics</b>. Holds content-addressed {@code .dfcos}
|
||||||
|
* blobs under {@code <gameDir>/decofashion/shared_cosmetics_cache/<hash>.dfcos}, baked
|
||||||
|
* {@code CosmeticCache.Baked} entries keyed by hash, and an in-flight download map for
|
||||||
|
* streaming reception.
|
||||||
|
*
|
||||||
|
* <p>Symmetric to {@link ClientSkinCache} but with an extra bbmodel-decode step. At no
|
||||||
|
* point on this path does any PNG decoder or JSON parser see wire bytes:
|
||||||
|
* <ul>
|
||||||
|
* <li>The bbmodel binary is decoded via {@link BbmodelCodec} — bounded and re-validated.</li>
|
||||||
|
* <li>The deflated RGBA is inflated under a {@code w*h*4} cap.</li>
|
||||||
|
* <li>The {@link NativeImage} is built pixel-by-pixel via {@code setPixelABGR}; STB is
|
||||||
|
* never invoked with untrusted bytes.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ClientSharedCosmeticCache {
|
||||||
|
|
||||||
|
private static final String SUBDIR = "decofashion/shared_cosmetics_cache";
|
||||||
|
|
||||||
|
private static final Map<String, CosmeticCache.Baked> BAKED = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Download> IN_FLIGHT = new ConcurrentHashMap<>();
|
||||||
|
private static final Set<String> PENDING_REQUESTS = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
private ClientSharedCosmeticCache() {}
|
||||||
|
|
||||||
|
private static final class Download {
|
||||||
|
int expectedTotal;
|
||||||
|
int nextIndex;
|
||||||
|
int bbmodelLen;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path root() {
|
||||||
|
return FMLPaths.GAMEDIR.get().resolve(SUBDIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path fileFor(String hash) {
|
||||||
|
return root().resolve(hash + SharedCosmeticCache.FILE_EXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Identifier textureIdFor(String hash) {
|
||||||
|
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "shared_cosmetics/" + hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CosmeticCache.Baked getBaked(String hash) {
|
||||||
|
return BAKED.get(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasOnDisk(String hash) {
|
||||||
|
return SharedCosmeticCache.isValidHash(hash) && Files.isRegularFile(fileFor(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we don't have a baked entry and haven't already asked the server, send one
|
||||||
|
* {@link RequestCosmetic}. Deduped so the render layer can call every frame without
|
||||||
|
* spamming. Pure in-memory dispatch — no disk I/O on the render thread.
|
||||||
|
*/
|
||||||
|
public static void requestIfMissing(String hash) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) return;
|
||||||
|
if (BAKED.containsKey(hash)) return;
|
||||||
|
if (IN_FLIGHT.containsKey(hash)) return;
|
||||||
|
if (!PENDING_REQUESTS.add(hash)) return;
|
||||||
|
IN_FLIGHT.put(hash, new Download());
|
||||||
|
sendToServer(new RequestCosmetic(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume one server→client chunk. Every field is validated against the first-chunk
|
||||||
|
* state; mismatches clear in-flight state.
|
||||||
|
*/
|
||||||
|
public static void onChunk(String hash, int index, int total, int bbmodelLen,
|
||||||
|
int width, int height, byte[] data) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (total <= 0 || index < 0 || index >= total) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: bad chunk {}/{}", hash, index, total);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bbmodelLen <= 0 || bbmodelLen > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: bad bbmodelLen {}", hash, bbmodelLen);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (width <= 0 || height <= 0
|
||||||
|
|| width > SharedCosmeticCache.MAX_DIM
|
||||||
|
|| height > SharedCosmeticCache.MAX_DIM) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: bad dims {}x{}", hash, width, height);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data == null || data.length > SharedCosmeticCache.MAX_CHUNK_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Download d = IN_FLIGHT.get(hash);
|
||||||
|
if (d == null) {
|
||||||
|
d = new Download();
|
||||||
|
IN_FLIGHT.put(hash, d);
|
||||||
|
}
|
||||||
|
if (index == 0) {
|
||||||
|
d.expectedTotal = total;
|
||||||
|
d.nextIndex = 0;
|
||||||
|
d.bbmodelLen = bbmodelLen;
|
||||||
|
d.width = width;
|
||||||
|
d.height = height;
|
||||||
|
d.buffer.reset();
|
||||||
|
} else if (d.expectedTotal != total || d.nextIndex != index
|
||||||
|
|| d.bbmodelLen != bbmodelLen || d.width != width || d.height != height) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk mismatch at {}/{}", hash, index, total);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxTotal = SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES + SharedCosmeticCache.MAX_DEFLATED_BYTES;
|
||||||
|
if (d.buffer.size() + data.length > maxTotal) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: exceeds total cap", hash);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
d.buffer.write(data);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
d.nextIndex = index + 1;
|
||||||
|
if (index + 1 < total) return;
|
||||||
|
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
PENDING_REQUESTS.remove(hash);
|
||||||
|
finalizeDownload(hash, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void finalizeDownload(String hash, Download d) {
|
||||||
|
byte[] payload = d.buffer.toByteArray();
|
||||||
|
if (payload.length < d.bbmodelLen) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: payload shorter than bbmodelLen", hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bbmodelBin = new byte[d.bbmodelLen];
|
||||||
|
System.arraycopy(payload, 0, bbmodelBin, 0, d.bbmodelLen);
|
||||||
|
byte[] deflatedRgba = new byte[payload.length - d.bbmodelLen];
|
||||||
|
System.arraycopy(payload, d.bbmodelLen, deflatedRgba, 0, deflatedRgba.length);
|
||||||
|
|
||||||
|
// Bake first; only write the blob to disk after it validates. Writing first would
|
||||||
|
// leave a rejected blob lingering on disk, where scanDisk would keep re-hydrating it
|
||||||
|
// and failing forever.
|
||||||
|
if (!bakeAndRegister(hash, bbmodelBin, d.width, d.height, deflatedRgba)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] blob = SharedCosmeticCache.buildBlob(bbmodelBin, d.width, d.height, deflatedRgba);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(root());
|
||||||
|
Files.write(fileFor(hash), blob);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: disk write failed ({})", hash, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a cached blob back into the baked cache without a round-trip. If bake fails,
|
||||||
|
* the on-disk file is deleted so a subsequent {@link #scanDisk} doesn't keep re-trying
|
||||||
|
* a permanently-broken blob.
|
||||||
|
*/
|
||||||
|
public static void rehydrateFromDisk(String hash) throws IOException {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) throw new IOException("invalid hash");
|
||||||
|
byte[] blob = Files.readAllBytes(fileFor(hash));
|
||||||
|
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||||
|
if (view == null) {
|
||||||
|
Files.deleteIfExists(fileFor(hash));
|
||||||
|
throw new IOException("blob corrupt");
|
||||||
|
}
|
||||||
|
boolean ok = bakeAndRegister(hash, view.bbmodelBinary(), view.width(), view.height(), view.deflatedRgba());
|
||||||
|
if (!ok) {
|
||||||
|
Files.deleteIfExists(fileFor(hash));
|
||||||
|
throw new IOException("bake rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode bbmodel, inflate RGBA, build {@link NativeImage}, register with
|
||||||
|
* {@link TextureManager}, bake the ModelPart tree, and publish into the render cache.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Returns {@code true} iff the blob fully decoded and was registered. Callers use the
|
||||||
|
* return value to gate disk persistence — a failed bake must never leave a blob on
|
||||||
|
* disk that subsequent {@code scanDisk} calls would keep re-hydrating and failing.
|
||||||
|
*/
|
||||||
|
private static boolean bakeAndRegister(String hash, byte[] bbmodelBin,
|
||||||
|
int width, int height, byte[] deflatedRgba) {
|
||||||
|
Bbmodel bbmodel;
|
||||||
|
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin);
|
||||||
|
try {
|
||||||
|
bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||||
|
if (in.readableBytes() != 0) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: trailing bytes after bbmodel decode", hash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: bbmodel rejected ({})", hash, ex.getMessage());
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
in.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
int expectedRaw;
|
||||||
|
try {
|
||||||
|
expectedRaw = SkinCache.rgbaByteCount(width, height);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: {}", hash, ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
byte[] rgba;
|
||||||
|
try {
|
||||||
|
rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: inflate failed ({})", hash, ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeImage img = new NativeImage(width, height, false);
|
||||||
|
int src = 0;
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int r = rgba[src++] & 0xFF;
|
||||||
|
int g = rgba[src++] & 0xFF;
|
||||||
|
int b = rgba[src++] & 0xFF;
|
||||||
|
int a = rgba[src++] & 0xFF;
|
||||||
|
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextureManager tm = Minecraft.getInstance().getTextureManager();
|
||||||
|
Identifier texId = textureIdFor(hash);
|
||||||
|
tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img));
|
||||||
|
|
||||||
|
Map<String, ModelPart> parts = BbmodelBaker.bake(
|
||||||
|
bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true);
|
||||||
|
BAKED.put(hash, new CosmeticCache.Baked(parts, texId));
|
||||||
|
DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{} parts={}",
|
||||||
|
hash, width, height, parts.keySet());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a cached shared cosmetic from this client — releases its texture, forgets the
|
||||||
|
* baked entry, removes the local cache file. The server's copy is untouched.
|
||||||
|
*/
|
||||||
|
public static boolean delete(String hash) {
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) return false;
|
||||||
|
CosmeticCache.Baked removed = BAKED.remove(hash);
|
||||||
|
boolean changed = removed != null;
|
||||||
|
if (removed != null) {
|
||||||
|
try {
|
||||||
|
Minecraft.getInstance().getTextureManager().release(removed.texture());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Files.deleteIfExists(fileFor(hash))) changed = true;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cosmetic {}: file delete failed ({})", hash, ex.getMessage());
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload every cached blob into the baked map so shared cosmetics render without a
|
||||||
|
* round-trip after a restart. Run at client setup (equivalent to {@code ClientSkinCache.scanDisk}).
|
||||||
|
*/
|
||||||
|
public static void scanDisk() {
|
||||||
|
Path dir = root();
|
||||||
|
if (!Files.isDirectory(dir)) return;
|
||||||
|
int loaded = 0;
|
||||||
|
int expectedNameLen = 64 + SharedCosmeticCache.FILE_EXT.length();
|
||||||
|
try (java.nio.file.DirectoryStream<Path> stream =
|
||||||
|
Files.newDirectoryStream(dir, "*" + SharedCosmeticCache.FILE_EXT)) {
|
||||||
|
for (Path p : stream) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
if (name.length() != expectedNameLen) continue;
|
||||||
|
String hash = name.substring(0, 64);
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) continue;
|
||||||
|
if (BAKED.containsKey(hash)) continue;
|
||||||
|
try {
|
||||||
|
rehydrateFromDisk(hash);
|
||||||
|
loaded++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cache: failed to load {} ({})",
|
||||||
|
hash, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("shared cache scan failed: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
if (loaded > 0) DecoFashion.LOGGER.info("shared cache: loaded {} cosmetic(s) from disk", loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authoritative dedupe check — is this hash already in the local player's library? */
|
||||||
|
public static boolean libraryContains(String hash) {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return false;
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
return lib.contains(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void sendToServer(CustomPacketPayload payload) {
|
||||||
|
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
||||||
|
if (conn == null) return;
|
||||||
|
conn.send(new ServerboundCustomPayloadPacket(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.bbmodel.BbModelParser;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
||||||
|
import com.razz.dfashion.skin.SafePngReader;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||||
|
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.zip.Deflater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author-side upload pipeline for shared cosmetics. Runs only on the uploading client.
|
||||||
|
*
|
||||||
|
* <p>Security-critical: this is the <b>only</b> place in the entire system where
|
||||||
|
* {@link BbModelParser} and {@link SafePngReader} see untrusted bytes. Everything
|
||||||
|
* downstream (server, receivers) operates on the validated binary produced here and on
|
||||||
|
* already-deflated RGBA. The output of this file is what every other client will
|
||||||
|
* eventually decode — if this file doesn't reject something, nothing else will either.
|
||||||
|
*
|
||||||
|
* <p>Anti-DoS: before sending, the server-authoritative {@link
|
||||||
|
* ClientSharedCosmeticCache#libraryContains} check short-circuits uploads that the
|
||||||
|
* server already has in this player's library. Re-uploads of identical content are
|
||||||
|
* therefore zero-bandwidth after the first success.
|
||||||
|
*/
|
||||||
|
public final class ClientSharedCosmeticUploader {
|
||||||
|
|
||||||
|
private ClientSharedCosmeticUploader() {}
|
||||||
|
|
||||||
|
public static String upload(Path bbmodelFile, Path textureFile, String displayName) {
|
||||||
|
String result = doUpload(bbmodelFile, textureFile, displayName);
|
||||||
|
DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String doUpload(Path bbmodelFile, Path textureFile, String displayName) {
|
||||||
|
if (!Files.isRegularFile(bbmodelFile)) return "Not a file: " + bbmodelFile;
|
||||||
|
if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile;
|
||||||
|
|
||||||
|
long bbSize;
|
||||||
|
long texSize;
|
||||||
|
try {
|
||||||
|
bbSize = Files.size(bbmodelFile);
|
||||||
|
texSize = Files.size(textureFile);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return "Size check failed: " + ex.getMessage();
|
||||||
|
}
|
||||||
|
if (bbSize > 16L * 1024 * 1024) return "bbmodel too large: " + bbSize;
|
||||||
|
if (texSize > SafeImageLoader.MAX_PNG_FILE_BYTES) return "PNG too large: " + texSize;
|
||||||
|
|
||||||
|
// 1. Parse bbmodel via BbModelParser (validates everything).
|
||||||
|
Bbmodel bbmodel;
|
||||||
|
try (Reader reader = Files.newBufferedReader(bbmodelFile)) {
|
||||||
|
bbmodel = BbModelParser.parse(reader);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return "bbmodel rejected: " + ex.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decode PNG via SafePngReader (memory-safe, RGBA only).
|
||||||
|
SafePngReader.Image decoded;
|
||||||
|
try {
|
||||||
|
byte[] pngBytes = Files.readAllBytes(textureFile);
|
||||||
|
decoded = SafePngReader.decode(pngBytes);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return "PNG rejected: " + ex.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Encode bbmodel canonically via BbmodelCodec. This is the form that will cross
|
||||||
|
// the wire, be stored, hashed, and eventually decoded by every receiver.
|
||||||
|
byte[] canonicalBbmodelBin;
|
||||||
|
ByteBuf out = Unpooled.buffer(1024);
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.encode(out, bbmodel);
|
||||||
|
if (out.readableBytes() > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) {
|
||||||
|
return "canonical bbmodel too large: " + out.readableBytes();
|
||||||
|
}
|
||||||
|
canonicalBbmodelBin = new byte[out.readableBytes()];
|
||||||
|
out.readBytes(canonicalBbmodelBin);
|
||||||
|
} finally {
|
||||||
|
out.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Compute content hash — must match what the server will compute.
|
||||||
|
String hash;
|
||||||
|
try {
|
||||||
|
hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin,
|
||||||
|
decoded.width, decoded.height, decoded.rgba);
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
return "SHA-256 unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Anti-DoS: if the server already has this hash in our library, skip the wire.
|
||||||
|
if (ClientSharedCosmeticCache.libraryContains(hash)) {
|
||||||
|
return "Already in library (" + hash.substring(0, 8) + "…); skipped";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Deflate raw RGBA.
|
||||||
|
byte[] deflated = deflate(decoded.rgba);
|
||||||
|
if (deflated.length > SharedCosmeticCache.MAX_DEFLATED_BYTES) {
|
||||||
|
return "RGBA deflated too large: " + deflated.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
||||||
|
if (conn == null) return "Not connected";
|
||||||
|
|
||||||
|
// 7. Assemble wire payload = bbmodel binary || deflated RGBA. Chunk and send.
|
||||||
|
int bbmodelLen = canonicalBbmodelBin.length;
|
||||||
|
int payloadLen = bbmodelLen + deflated.length;
|
||||||
|
int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES;
|
||||||
|
int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize);
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
int off = i * chunkSize;
|
||||||
|
int len = Math.min(chunkSize, payloadLen - off);
|
||||||
|
byte[] slice = sliceConcat(canonicalBbmodelBin, deflated, off, len);
|
||||||
|
conn.send(new ServerboundCustomPayloadPacket(
|
||||||
|
new UploadCosmeticChunk(
|
||||||
|
i, total, bbmodelLen,
|
||||||
|
decoded.width, decoded.height,
|
||||||
|
slice
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Uploading " + decoded.width + "x" + decoded.height + " ("
|
||||||
|
+ payloadLen + " bytes, " + total + " chunks, hash "
|
||||||
|
+ hash.substring(0, 8) + "…) [" + displayName + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slice bytes from {@code a ‖ b} into a new array of length {@code len} starting at {@code off}. */
|
||||||
|
private static byte[] sliceConcat(byte[] a, byte[] b, int off, int len) {
|
||||||
|
byte[] out = new byte[len];
|
||||||
|
int dst = 0;
|
||||||
|
if (off < a.length) {
|
||||||
|
int take = Math.min(len, a.length - off);
|
||||||
|
System.arraycopy(a, off, out, dst, take);
|
||||||
|
dst += take;
|
||||||
|
len -= take;
|
||||||
|
off += take;
|
||||||
|
}
|
||||||
|
if (len > 0) {
|
||||||
|
int bOff = off - a.length;
|
||||||
|
System.arraycopy(b, bOff, out, dst, len);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] deflate(byte[] raw) {
|
||||||
|
Deflater def = new Deflater(Deflater.BEST_COMPRESSION);
|
||||||
|
try {
|
||||||
|
def.setInput(raw);
|
||||||
|
def.finish();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64);
|
||||||
|
byte[] buf = new byte[64 * 1024];
|
||||||
|
while (!def.finished()) {
|
||||||
|
int n = def.deflate(buf);
|
||||||
|
if (n == 0) break;
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
} finally {
|
||||||
|
def.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.razz.dfashion.client;
|
|||||||
|
|
||||||
import com.mojang.blaze3d.platform.NativeImage;
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.SafePngReader;
|
||||||
import com.razz.dfashion.skin.SkinCache;
|
import com.razz.dfashion.skin.SkinCache;
|
||||||
import com.razz.dfashion.skin.SkinModel;
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
import com.razz.dfashion.skin.packet.UploadSkinChunk;
|
import com.razz.dfashion.skin.packet.UploadSkinChunk;
|
||||||
@@ -16,17 +17,24 @@ import net.neoforged.fml.loading.FMLPaths;
|
|||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.zip.Deflater;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side skin store. Writes received PNGs to {@code <gameDir>/decofashion/skins_cache/<hash>.png},
|
* Client-side skin store. Holds <b>pixel blobs</b> under
|
||||||
* registers them as {@link DynamicTexture}s under synthetic {@code decofashion:skins/<hash>} identifiers,
|
* {@code <gameDir>/decofashion/skins_cache/<hash>.bin} (4-byte {@code (u16 w, u16 h)} header
|
||||||
* and tracks in-flight downloads keyed by hash.
|
* + deflated RGBA body), and registers decoded images as {@link DynamicTexture}s under
|
||||||
|
* synthetic {@code decofashion:skins/<hash>} identifiers.
|
||||||
|
*
|
||||||
|
* <p>The only PNG parser in the pipeline is {@link SafePngReader} on the uploading client.
|
||||||
|
* Inbound data from the server is already raw deflated RGBA — we inflate under a bounded
|
||||||
|
* cap, fill a {@link NativeImage} directly via {@code setPixelABGR}, and never invoke STB.
|
||||||
*/
|
*/
|
||||||
public final class ClientSkinCache {
|
public final class ClientSkinCache {
|
||||||
|
|
||||||
@@ -38,6 +46,8 @@ public final class ClientSkinCache {
|
|||||||
private static final class Download {
|
private static final class Download {
|
||||||
int expectedTotal;
|
int expectedTotal;
|
||||||
int nextIndex;
|
int nextIndex;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +58,13 @@ public final class ClientSkinCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Path fileFor(String hash) {
|
private static Path fileFor(String hash) {
|
||||||
return root().resolve(hash + ".png");
|
return root().resolve(hash + SkinCache.FILE_EXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Identifier textureIdFor(String hash) {
|
public static Identifier textureIdFor(String hash) {
|
||||||
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash);
|
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Already have a {@link DynamicTexture} registered for this hash? */
|
|
||||||
public static boolean hasRegistered(String hash) {
|
public static boolean hasRegistered(String hash) {
|
||||||
return REGISTERED.containsKey(hash);
|
return REGISTERED.containsKey(hash);
|
||||||
}
|
}
|
||||||
@@ -64,21 +73,35 @@ public final class ClientSkinCache {
|
|||||||
return Files.isRegularFile(fileFor(hash));
|
return Files.isRegularFile(fileFor(hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load from disk + register if we have the PNG cached but haven't built a texture yet. */
|
/** Load from disk + register if we have the blob cached but haven't built a texture yet. */
|
||||||
public static Identifier ensureLoadedFromDisk(String hash) {
|
public static Identifier ensureLoadedFromDisk(String hash) {
|
||||||
|
if (!SkinCache.isValidHash(hash)) return null;
|
||||||
Identifier existing = REGISTERED.get(hash);
|
Identifier existing = REGISTERED.get(hash);
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
if (!hasOnDisk(hash)) return null;
|
if (!hasOnDisk(hash)) return null;
|
||||||
|
|
||||||
try (InputStream in = Files.newInputStream(fileFor(hash))) {
|
try {
|
||||||
return register(hash, in);
|
byte[] blob = Files.readAllBytes(fileFor(hash));
|
||||||
|
int[] dims = SkinCache.readHeader(blob);
|
||||||
|
if (dims == null) {
|
||||||
|
DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int w = dims[0], h = dims[1];
|
||||||
|
if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) {
|
||||||
|
DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE];
|
||||||
|
System.arraycopy(blob, SkinCache.HEADER_SIZE, deflated, 0, deflated.length);
|
||||||
|
byte[] rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(w, h));
|
||||||
|
return registerFromPixels(hash, w, h, rgba);
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin a chunked download for this hash. Later {@link #onChunk} calls reassemble. */
|
|
||||||
public static boolean isAwaiting(String hash) {
|
public static boolean isAwaiting(String hash) {
|
||||||
return IN_FLIGHT.containsKey(hash);
|
return IN_FLIGHT.containsKey(hash);
|
||||||
}
|
}
|
||||||
@@ -88,11 +111,18 @@ public final class ClientSkinCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consume one inbound {@code SkinChunk}. When the last chunk lands, writes to disk,
|
* Consume one inbound deflated-pixel chunk. When the last chunk lands, inflates with a
|
||||||
* registers a {@link DynamicTexture}, and returns its synthetic {@link Identifier}.
|
* bounded cap, writes the blob to disk, builds a {@link NativeImage} directly from pixels
|
||||||
* Returns {@code null} while more chunks are expected (or on error).
|
* (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more
|
||||||
|
* chunks are expected or on error.
|
||||||
*/
|
*/
|
||||||
public static Identifier onChunk(String hash, int index, int total, byte[] data) {
|
public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) {
|
||||||
|
if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height);
|
||||||
|
IN_FLIGHT.remove(hash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Download d = IN_FLIGHT.get(hash);
|
Download d = IN_FLIGHT.get(hash);
|
||||||
if (d == null) {
|
if (d == null) {
|
||||||
d = new Download();
|
d = new Download();
|
||||||
@@ -101,15 +131,17 @@ public final class ClientSkinCache {
|
|||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
d.expectedTotal = total;
|
d.expectedTotal = total;
|
||||||
d.nextIndex = 0;
|
d.nextIndex = 0;
|
||||||
|
d.width = width;
|
||||||
|
d.height = height;
|
||||||
d.buffer.reset();
|
d.buffer.reset();
|
||||||
} else if (d.expectedTotal != total || d.nextIndex != index) {
|
} else if (d.expectedTotal != total || d.nextIndex != index
|
||||||
DecoFashion.LOGGER.warn("Skin download {}: out-of-order chunk {}/{} (expected {}/{})",
|
|| d.width != width || d.height != height) {
|
||||||
hash, index, total, d.nextIndex, d.expectedTotal);
|
DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total);
|
||||||
IN_FLIGHT.remove(hash);
|
IN_FLIGHT.remove(hash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (d.buffer.size() + data.length > SkinCache.MAX_BYTES) {
|
if (d.buffer.size() + data.length > SkinCache.MAX_DEFLATED_BYTES) {
|
||||||
DecoFashion.LOGGER.warn("Skin download {}: exceeds size cap", hash);
|
DecoFashion.LOGGER.warn("Skin download {}: exceeds deflated cap", hash);
|
||||||
IN_FLIGHT.remove(hash);
|
IN_FLIGHT.remove(hash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -124,28 +156,63 @@ public final class ClientSkinCache {
|
|||||||
if (index + 1 < total) return null;
|
if (index + 1 < total) return null;
|
||||||
|
|
||||||
IN_FLIGHT.remove(hash);
|
IN_FLIGHT.remove(hash);
|
||||||
byte[] png = d.buffer.toByteArray();
|
byte[] deflated = d.buffer.toByteArray();
|
||||||
|
byte[] rgba;
|
||||||
|
try {
|
||||||
|
rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(d.width, d.height));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin download {}: {}", hash, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin download {}: inflate failed ({})", hash, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(root());
|
Files.createDirectories(root());
|
||||||
Files.write(fileFor(hash), png);
|
Files.write(fileFor(hash), SkinCache.buildBlob(d.width, d.height, 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);
|
||||||
}
|
}
|
||||||
try (InputStream in = new java.io.ByteArrayInputStream(png)) {
|
|
||||||
return register(hash, in);
|
return registerFromPixels(hash, d.width, d.height, rgba);
|
||||||
} catch (IOException ex) {
|
|
||||||
DecoFashion.LOGGER.error("Skin download {}: image decode failed", hash, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Identifier register(String hash, InputStream in) throws IOException {
|
/**
|
||||||
NativeImage img = NativeImage.read(in);
|
* Build a {@link NativeImage} directly from RGBA bytes and register it. No PNG decoder
|
||||||
|
* is involved — {@code NativeImage} is allocated in RGBA format and each pixel is written
|
||||||
|
* via {@code setPixelABGR}, where the int is packed so the native {@code memPutInt} lays
|
||||||
|
* down bytes in the order {@code [R, G, B, A]} (little-endian platforms).
|
||||||
|
*/
|
||||||
|
private static Identifier registerFromPixels(String hash, int width, int height, byte[] rgba) {
|
||||||
|
int expected;
|
||||||
|
try {
|
||||||
|
expected = SkinCache.rgbaByteCount(width, height);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Skin {}: {}", hash, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (rgba.length != expected) {
|
||||||
|
DecoFashion.LOGGER.error("Skin {}: pixel buffer length {} != {}*{}*4",
|
||||||
|
hash, rgba.length, width, height);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
NativeImage img = new NativeImage(width, height, false);
|
||||||
|
int src = 0;
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int r = rgba[src++] & 0xFF;
|
||||||
|
int g = rgba[src++] & 0xFF;
|
||||||
|
int b = rgba[src++] & 0xFF;
|
||||||
|
int a = rgba[src++] & 0xFF;
|
||||||
|
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);
|
||||||
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={}", hash, id);
|
DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}", hash, id, width, height);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,21 +221,22 @@ public final class ClientSkinCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register every cached PNG under {@code <gameDir>/decofashion/skins_cache/}. Runs on
|
* Register every cached blob under {@code <gameDir>/decofashion/skins_cache/}. Runs on
|
||||||
* client setup and wardrobe open so previously-uploaded skins (which persist as files)
|
* client setup and wardrobe open so previously-uploaded skins reappear in the grid
|
||||||
* reappear in the grid without waiting for an attachment to name them.
|
* without waiting for an attachment to name them. Filename must be a 64-char lowercase
|
||||||
* Filename must be a 64-char lowercase hex hash with {@code .png} suffix to be accepted.
|
* hex hash with {@code .bin} suffix.
|
||||||
*/
|
*/
|
||||||
public static void scanDisk() {
|
public static void scanDisk() {
|
||||||
Path dir = root();
|
Path dir = root();
|
||||||
if (!Files.isDirectory(dir)) return;
|
if (!Files.isDirectory(dir)) return;
|
||||||
int loaded = 0;
|
int loaded = 0;
|
||||||
try (java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.png")) {
|
int expectedNameLen = 64 + SkinCache.FILE_EXT.length();
|
||||||
for (Path png : stream) {
|
try (java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*" + SkinCache.FILE_EXT)) {
|
||||||
String name = png.getFileName().toString();
|
for (Path bin : stream) {
|
||||||
if (name.length() != 68) continue; // 64 hex + ".png"
|
String name = bin.getFileName().toString();
|
||||||
|
if (name.length() != expectedNameLen) continue;
|
||||||
String hash = name.substring(0, 64);
|
String hash = name.substring(0, 64);
|
||||||
if (!hash.matches("[0-9a-f]+")) continue;
|
if (!SkinCache.isValidHash(hash)) continue;
|
||||||
if (REGISTERED.containsKey(hash)) continue;
|
if (REGISTERED.containsKey(hash)) continue;
|
||||||
if (ensureLoadedFromDisk(hash) != null) loaded++;
|
if (ensureLoadedFromDisk(hash) != null) loaded++;
|
||||||
}
|
}
|
||||||
@@ -180,10 +248,10 @@ public final class ClientSkinCache {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop a cached skin from this client — releases the texture, forgets the hash→id map
|
* Drop a cached skin from this client — releases the texture, forgets the hash→id map
|
||||||
* entry, and removes the on-disk PNG. The server's copy is untouched (other players
|
* entry, and removes the on-disk blob. The server's copy is untouched.
|
||||||
* may still be using it). Returns true if anything was removed.
|
|
||||||
*/
|
*/
|
||||||
public static boolean delete(String hash) {
|
public static boolean delete(String hash) {
|
||||||
|
if (!SkinCache.isValidHash(hash)) return false;
|
||||||
Identifier id = REGISTERED.remove(hash);
|
Identifier id = REGISTERED.remove(hash);
|
||||||
boolean changed = id != null;
|
boolean changed = id != null;
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
@@ -203,67 +271,114 @@ public final class ClientSkinCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a PNG from disk, validate, chunk it, and ship the chunks upstream via
|
* Read a PNG from disk, decode it with {@link SafePngReader} (pure Java, no STB),
|
||||||
* {@link UploadSkinChunk}. Also caches the PNG locally (same hash the server will compute)
|
* deflate the raw RGBA, chunk it, and ship chunks upstream as {@link UploadSkinChunk}s.
|
||||||
* so the wardrobe grid can show it immediately without waiting for the server round-trip.
|
* Also caches locally under the same hash the server will compute so the wardrobe shows
|
||||||
* Returns a short user-facing status string for display.
|
* 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);
|
||||||
|
DecoFashion.LOGGER.info("Skin upload: {}", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String doUpload(Path file, SkinModel model) {
|
||||||
if (!Files.isRegularFile(file)) return "Not a file: " + file;
|
if (!Files.isRegularFile(file)) return "Not a file: " + file;
|
||||||
|
|
||||||
byte[] png;
|
byte[] png;
|
||||||
try {
|
try {
|
||||||
|
long size = Files.size(file);
|
||||||
|
if (size > SafeImageLoader.MAX_PNG_FILE_BYTES) {
|
||||||
|
return "PNG file too large: " + size;
|
||||||
|
}
|
||||||
png = Files.readAllBytes(file);
|
png = Files.readAllBytes(file);
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
return "Read failed: " + ex.getMessage();
|
return "Read failed: " + ex.getMessage();
|
||||||
}
|
}
|
||||||
if (png.length > SkinCache.MAX_BYTES) {
|
|
||||||
return "Too large: " + png.length + " > " + SkinCache.MAX_BYTES;
|
SafePngReader.Image decoded;
|
||||||
|
try {
|
||||||
|
decoded = SafePngReader.decode(png);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return "Rejected: " + ex.getMessage();
|
||||||
}
|
}
|
||||||
if (png.length < 8
|
|
||||||
|| png[0] != (byte) 0x89 || png[1] != 'P' || png[2] != 'N' || png[3] != 'G') {
|
byte[] deflated = deflate(decoded.rgba);
|
||||||
return "Not a PNG";
|
if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) {
|
||||||
|
return "Too large after compression: " + deflated.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
||||||
if (conn == null) return "Not connected";
|
if (conn == null) return "Not connected";
|
||||||
|
|
||||||
// Local-cache first so the grid picks it up without waiting for the server echo.
|
String localHash;
|
||||||
String localHash = null;
|
try {
|
||||||
|
localHash = sha256HexOfPixels(decoded.width, decoded.height, decoded.rgba);
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
return "SHA-256 unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localHash = sha256Hex(png);
|
|
||||||
Files.createDirectories(root());
|
Files.createDirectories(root());
|
||||||
if (!hasOnDisk(localHash)) Files.write(fileFor(localHash), png);
|
if (!hasOnDisk(localHash)) {
|
||||||
ensureLoadedFromDisk(localHash);
|
Files.write(fileFor(localHash), SkinCache.buildBlob(decoded.width, decoded.height, deflated));
|
||||||
} catch (Exception ex) {
|
}
|
||||||
|
if (!REGISTERED.containsKey(localHash)) {
|
||||||
|
registerFromPixels(localHash, decoded.width, decoded.height, decoded.rgba);
|
||||||
|
}
|
||||||
|
} 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
int chunk = SkinCache.MAX_CHUNK_BYTES;
|
int chunk = SkinCache.MAX_CHUNK_BYTES;
|
||||||
int total = Math.max(1, (png.length + chunk - 1) / chunk);
|
int total = Math.max(1, (deflated.length + chunk - 1) / chunk);
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
int off = i * chunk;
|
int off = i * chunk;
|
||||||
int len = Math.min(chunk, png.length - off);
|
int len = Math.min(chunk, deflated.length - off);
|
||||||
byte[] slice = new byte[len];
|
byte[] slice = new byte[len];
|
||||||
System.arraycopy(png, off, slice, 0, len);
|
System.arraycopy(deflated, off, slice, 0, len);
|
||||||
conn.send(new ServerboundCustomPayloadPacket(new UploadSkinChunk(i, total, model, slice)));
|
conn.send(new ServerboundCustomPayloadPacket(
|
||||||
|
new UploadSkinChunk(i, total, decoded.width, decoded.height, model, slice)));
|
||||||
}
|
}
|
||||||
return "Uploading " + png.length + " bytes in " + total + " chunks"
|
return "Uploading " + decoded.width + "x" + decoded.height + " ("
|
||||||
+ (localHash != null ? " (hash " + localHash.substring(0, 8) + ")" : "");
|
+ deflated.length + " bytes, " + total + " chunks, hash "
|
||||||
|
+ localHash.substring(0, 8) + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sha256Hex(byte[] in) throws java.security.NoSuchAlgorithmException {
|
private static byte[] deflate(byte[] raw) {
|
||||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
Deflater def = new Deflater(Deflater.BEST_COMPRESSION);
|
||||||
byte[] out = md.digest(in);
|
try {
|
||||||
|
def.setInput(raw);
|
||||||
|
def.finish();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64);
|
||||||
|
byte[] buf = new byte[64 * 1024];
|
||||||
|
while (!def.finished()) {
|
||||||
|
int n = def.deflate(buf);
|
||||||
|
if (n == 0) break;
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
} finally {
|
||||||
|
def.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
StringBuilder sb = new StringBuilder(out.length * 2);
|
||||||
for (byte b : out) sb.append(String.format("%02x", b));
|
for (byte b : out) sb.append(String.format("%02x", b));
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shortcut: send any server-bound payload from the client. */
|
|
||||||
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;
|
||||||
conn.send(new ServerboundCustomPayloadPacket(payload));
|
conn.send(new ServerboundCustomPayloadPacket(payload));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ public class ClosetRenderer implements BlockEntityRenderer<ClosetBlockEntity, Cl
|
|||||||
collector.submitModelPart(
|
collector.submitModelPart(
|
||||||
part,
|
part,
|
||||||
poseStack,
|
poseStack,
|
||||||
RenderTypes.entityCutout(baked.texture()),
|
RenderTypes.entityTranslucent(baked.texture()),
|
||||||
state.lightCoords,
|
state.lightCoords,
|
||||||
OverlayTexture.NO_OVERLAY,
|
OverlayTexture.NO_OVERLAY,
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.razz.dfashion.client;
|
|||||||
|
|
||||||
import com.mojang.blaze3d.vertex.PoseStack;
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
|
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.client.model.geom.ModelPart;
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
@@ -33,7 +34,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
* falls back to the live entity's {@link CosmeticAttachments#EQUIPPED} attachment.
|
* falls back to the live entity's {@link CosmeticAttachments#EQUIPPED} attachment.
|
||||||
* Use {@link IdentityHashMap} semantics so collisions can't happen across states.
|
* Use {@link IdentityHashMap} semantics so collisions can't happen across states.
|
||||||
*/
|
*/
|
||||||
public static final Map<AvatarRenderState, Map<String, net.minecraft.resources.Identifier>> RENDER_OVERRIDES =
|
public static final Map<AvatarRenderState, Map<String, CosmeticRef>> RENDER_OVERRIDES =
|
||||||
Collections.synchronizedMap(new IdentityHashMap<>());
|
Collections.synchronizedMap(new IdentityHashMap<>());
|
||||||
|
|
||||||
public CosmeticRenderLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent) {
|
public CosmeticRenderLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent) {
|
||||||
@@ -44,14 +45,16 @@ 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
|
||||||
|
// can still have Shared refs that resolve through ClientSharedCosmeticCache. Don't
|
||||||
|
// early-return on local emptiness.
|
||||||
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
|
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
|
||||||
if (cache.isEmpty()) return;
|
|
||||||
|
|
||||||
Minecraft mc = Minecraft.getInstance();
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
|
||||||
// GUI preview override takes precedence: the wardrobe Screen sets this to display
|
// GUI preview override takes precedence: the wardrobe Screen sets this to display
|
||||||
// a specific cosmetic in a mini-player box without touching the live attachment.
|
// a specific cosmetic in a mini-player box without touching the live attachment.
|
||||||
Map<String, Identifier> equipped = RENDER_OVERRIDES.get(state);
|
Map<String, CosmeticRef> equipped = RENDER_OVERRIDES.get(state);
|
||||||
if (equipped == null) {
|
if (equipped == null) {
|
||||||
if (mc.level == null) return;
|
if (mc.level == null) return;
|
||||||
Entity entity = mc.level.getEntity(state.id);
|
Entity entity = mc.level.getEntity(state.id);
|
||||||
@@ -62,9 +65,9 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
|
|
||||||
PlayerModel model = getParentModel();
|
PlayerModel model = getParentModel();
|
||||||
|
|
||||||
for (Identifier cosmeticId : equipped.values()) {
|
for (CosmeticRef ref : equipped.values()) {
|
||||||
CosmeticCache.Baked cosmetic = cache.get(cosmeticId);
|
CosmeticCache.Baked cosmetic = resolve(ref, cache);
|
||||||
if (cosmetic == null) continue; // unknown cosmetic id, skip
|
if (cosmetic == null) continue; // unknown / not yet downloaded
|
||||||
|
|
||||||
for (Map.Entry<String, ModelPart> entry : cosmetic.parts().entrySet()) {
|
for (Map.Entry<String, ModelPart> entry : cosmetic.parts().entrySet()) {
|
||||||
ModelPart bone = findBone(model, entry.getKey());
|
ModelPart bone = findBone(model, entry.getKey());
|
||||||
@@ -78,7 +81,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
collector.submitModelPart(
|
collector.submitModelPart(
|
||||||
entry.getValue(),
|
entry.getValue(),
|
||||||
poseStack,
|
poseStack,
|
||||||
RenderTypes.entityCutout(cosmetic.texture()),
|
RenderTypes.entityTranslucent(cosmetic.texture()),
|
||||||
lightCoords,
|
lightCoords,
|
||||||
OverlayTexture.NO_OVERLAY,
|
OverlayTexture.NO_OVERLAY,
|
||||||
null
|
null
|
||||||
@@ -88,6 +91,19 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CosmeticCache.Baked resolve(
|
||||||
|
CosmeticRef ref, Map<Identifier, CosmeticCache.Baked> localCache
|
||||||
|
) {
|
||||||
|
return switch (ref) {
|
||||||
|
case CosmeticRef.Local l -> localCache.get(l.id());
|
||||||
|
case CosmeticRef.Shared s -> {
|
||||||
|
CosmeticCache.Baked baked = ClientSharedCosmeticCache.getBaked(s.hash());
|
||||||
|
if (baked == null) ClientSharedCosmeticCache.requestIfMissing(s.hash());
|
||||||
|
yield baked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static ModelPart findBone(PlayerModel model, String rawName) {
|
private static ModelPart findBone(PlayerModel model, String rawName) {
|
||||||
String name = normalize(rawName);
|
String name = normalize(rawName);
|
||||||
ModelPart bone = switch (name) {
|
ModelPart bone = switch (name) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
|
import com.razz.dfashion.skin.SafePngReader;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only sanctioned bridge from a user-supplied PNG file on disk to a {@link NativeImage}.
|
||||||
|
*
|
||||||
|
* <p>Runs the PNG through {@link SafePngReader} — pure Java, no STB, 8-bit RGBA only, hard
|
||||||
|
* dim cap, IHDR/IDAT/IEND whitelist, CRC-checked, zero trailing bytes — then builds the
|
||||||
|
* {@code NativeImage} by writing pixels directly with {@code setPixelABGR}. {@code NativeImage.read}
|
||||||
|
* (and therefore STB) is never invoked with untrusted bytes. Mirrors the pattern already used on
|
||||||
|
* the skin download path in {@link ClientSkinCache}.
|
||||||
|
*/
|
||||||
|
public final class SafeImageLoader {
|
||||||
|
|
||||||
|
/** Hard cap on a PNG file we'll even read off disk. A 4096² 8-bit-RGBA PNG is ≤ ~67 MB
|
||||||
|
* uncompressed; real authored content is well under 20 MB. */
|
||||||
|
public static final long MAX_PNG_FILE_BYTES = 64L * 1024 * 1024;
|
||||||
|
|
||||||
|
private SafeImageLoader() {}
|
||||||
|
|
||||||
|
public static NativeImage loadNativeImage(Path file) throws IOException {
|
||||||
|
long size = Files.size(file);
|
||||||
|
if (size > MAX_PNG_FILE_BYTES) {
|
||||||
|
throw new IOException("PNG file too large: " + size + " > " + MAX_PNG_FILE_BYTES);
|
||||||
|
}
|
||||||
|
byte[] png = Files.readAllBytes(file);
|
||||||
|
SafePngReader.Image decoded = SafePngReader.decode(png);
|
||||||
|
NativeImage img = new NativeImage(decoded.width, decoded.height, false);
|
||||||
|
int src = 0;
|
||||||
|
for (int y = 0; y < decoded.height; y++) {
|
||||||
|
for (int x = 0; x < decoded.width; x++) {
|
||||||
|
int r = decoded.rgba[src++] & 0xFF;
|
||||||
|
int g = decoded.rgba[src++] & 0xFF;
|
||||||
|
int b = decoded.rgba[src++] & 0xFF;
|
||||||
|
int a = decoded.rgba[src++] & 0xFF;
|
||||||
|
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
package com.razz.dfashion.client.screen;
|
package com.razz.dfashion.client.screen;
|
||||||
|
|
||||||
import com.razz.dfashion.block.ClosetBlockEntity;
|
import com.razz.dfashion.block.ClosetBlockEntity;
|
||||||
|
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
||||||
|
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
|
||||||
import com.razz.dfashion.client.ClientSkinCache;
|
import com.razz.dfashion.client.ClientSkinCache;
|
||||||
import com.razz.dfashion.client.CosmeticCache;
|
import com.razz.dfashion.client.CosmeticCache;
|
||||||
import com.razz.dfashion.client.CosmeticRenderLayer;
|
import com.razz.dfashion.client.CosmeticRenderLayer;
|
||||||
import com.razz.dfashion.client.SkinInfoOverride;
|
import com.razz.dfashion.client.SkinInfoOverride;
|
||||||
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
import com.razz.dfashion.cosmetic.CosmeticDefinition;
|
import com.razz.dfashion.cosmetic.CosmeticDefinition;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
|
import com.razz.dfashion.cosmetic.share.BoneExtraction;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
||||||
import com.razz.dfashion.skin.SkinAttachments;
|
import com.razz.dfashion.skin.SkinAttachments;
|
||||||
import com.razz.dfashion.skin.SkinData;
|
import com.razz.dfashion.skin.SkinData;
|
||||||
|
import com.razz.dfashion.skin.SkinLibrary;
|
||||||
|
import com.razz.dfashion.skin.SkinLibraryEntry;
|
||||||
import com.razz.dfashion.skin.SkinModel;
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
import com.razz.dfashion.skin.packet.AssignSkin;
|
import com.razz.dfashion.skin.packet.AssignSkin;
|
||||||
|
import com.razz.dfashion.skin.packet.DeleteSkin;
|
||||||
|
import com.razz.dfashion.skin.packet.RequestSkin;
|
||||||
|
|
||||||
import net.minecraft.core.ClientAsset;
|
import net.minecraft.core.ClientAsset;
|
||||||
import net.minecraft.world.entity.player.PlayerModelType;
|
import net.minecraft.world.entity.player.PlayerModelType;
|
||||||
@@ -21,6 +33,8 @@ import net.minecraft.client.CameraType;
|
|||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
import net.minecraft.client.gui.components.Button;
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.StringWidget;
|
||||||
import net.minecraft.client.gui.screens.Screen;
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
import net.minecraft.client.player.LocalPlayer;
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||||
@@ -45,19 +59,37 @@ import org.joml.Quaternionf;
|
|||||||
import org.joml.Vector3f;
|
import org.joml.Vector3f;
|
||||||
import org.lwjgl.glfw.GLFW;
|
import org.lwjgl.glfw.GLFW;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class ClosetScreen extends Screen {
|
public class ClosetScreen extends Screen {
|
||||||
|
|
||||||
// Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the
|
// Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the
|
||||||
// per-player skin override UI (upload/reset) rather than the usual cosmetic row.
|
// per-player skin override UI (upload/reset) rather than the usual cosmetic row.
|
||||||
|
// "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid).
|
||||||
private static final String[] CATEGORIES = {
|
private static final String[] CATEGORIES = {
|
||||||
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin"
|
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared"
|
||||||
};
|
};
|
||||||
private static final String SKIN_TAB = "skin";
|
private static final String SKIN_TAB = "skin";
|
||||||
|
private static final String SHARED_TAB = "shared";
|
||||||
|
|
||||||
|
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
|
||||||
|
private static final String FALLBACK_SHARED_CATEGORY = "particle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bone-based filters on the shared tab. {@code null} = "All"; others are canonical
|
||||||
|
* bone group names used by {@link BoneExtraction}. Labels are user-facing ({@code Torso}
|
||||||
|
* rather than the canonical {@code Body}).
|
||||||
|
*/
|
||||||
|
private static final String[] SHARED_FILTER_KEYS = { null, "Head", "Body", "Arm", "Leg" };
|
||||||
|
private static final String[] SHARED_FILTER_LABELS = { "All", "Head", "Torso", "Arms", "Legs" };
|
||||||
|
|
||||||
// Per-tick rotation rate while an arrow is held (20 tps → 120°/sec).
|
// Per-tick rotation rate while an arrow is held (20 tps → 120°/sec).
|
||||||
private static final float ROTATE_PER_TICK = 6f;
|
private static final float ROTATE_PER_TICK = 6f;
|
||||||
@@ -76,6 +108,7 @@ public class ClosetScreen extends Screen {
|
|||||||
private static final int COSMETIC_INNER_PADDING = 3;
|
private static final int COSMETIC_INNER_PADDING = 3;
|
||||||
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
|
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
|
||||||
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
|
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
|
||||||
|
private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library
|
||||||
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
|
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
|
||||||
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
|
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
|
||||||
|
|
||||||
@@ -117,6 +150,40 @@ public class ClosetScreen extends Screen {
|
|||||||
private double dragLastX = 0;
|
private double dragLastX = 0;
|
||||||
private boolean isDraggingRow = false;
|
private boolean isDraggingRow = false;
|
||||||
|
|
||||||
|
// ---- Share-cosmetic form state ----
|
||||||
|
// When showingShareForm is true, init() replaces the normal tabs/grid/buttons with a
|
||||||
|
// modal form. Widget refs are nullable because they're rebuilt on every widget refresh
|
||||||
|
// (Screen.rebuildWidgets clears children and re-runs init).
|
||||||
|
private boolean showingShareForm = false;
|
||||||
|
private @Nullable EditBox shareTextureField;
|
||||||
|
private @Nullable EditBox shareModelField;
|
||||||
|
private @Nullable EditBox shareNameField;
|
||||||
|
private @Nullable StringWidget shareStatusLabel;
|
||||||
|
private String shareStatusMessage = "";
|
||||||
|
|
||||||
|
/** UX cap on displayName shown in the field. Wire codec caps at 128; 64 is user-friendly. */
|
||||||
|
private static final int SHARE_NAME_MAX_LEN = 64;
|
||||||
|
/** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */
|
||||||
|
private static final int SHARE_PATH_MAX_LEN = 4096;
|
||||||
|
|
||||||
|
// ---- Shared-library tab state ----
|
||||||
|
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
|
||||||
|
private @Nullable String sharedBoneFilter = null;
|
||||||
|
private int sharedVerticalScroll = 0;
|
||||||
|
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
|
||||||
|
private int lastKnownSharedCount = -1;
|
||||||
|
|
||||||
|
/** Filter bar Y (below the tab row). */
|
||||||
|
private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10;
|
||||||
|
private static final int SHARED_FILTER_H = 20;
|
||||||
|
private static final int SHARED_FILTER_BTN_W = 50;
|
||||||
|
private static final int SHARED_FILTER_BTN_GAP = 4;
|
||||||
|
|
||||||
|
/** Top of the vertical-scroll grid on the Shared tab (below the filter bar). */
|
||||||
|
private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 10;
|
||||||
|
/** Height reserved for bottom buttons; the grid stops above this. */
|
||||||
|
private static final int SHARED_GRID_BOTTOM_PAD = 50;
|
||||||
|
|
||||||
public ClosetScreen(@Nullable ClosetBlockEntity closet) {
|
public ClosetScreen(@Nullable ClosetBlockEntity closet) {
|
||||||
super(Component.literal("Wardrobe"));
|
super(Component.literal("Wardrobe"));
|
||||||
this.closet = closet;
|
this.closet = closet;
|
||||||
@@ -147,6 +214,11 @@ public class ClosetScreen extends Screen {
|
|||||||
ClientSkinCache.scanDisk();
|
ClientSkinCache.scanDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showingShareForm) {
|
||||||
|
buildShareForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
buildTabs();
|
buildTabs();
|
||||||
buildBottomControls();
|
buildBottomControls();
|
||||||
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
|
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
|
||||||
@@ -155,7 +227,7 @@ public class ClosetScreen extends Screen {
|
|||||||
private void buildTabs() {
|
private void buildTabs() {
|
||||||
int x = TAB_X_START;
|
int x = TAB_X_START;
|
||||||
for (String cat : CATEGORIES) {
|
for (String cat : CATEGORIES) {
|
||||||
String label = cat.substring(0, 1).toUpperCase();
|
String label = tabLabel(cat);
|
||||||
addRenderableWidget(Button.builder(
|
addRenderableWidget(Button.builder(
|
||||||
Component.literal(label),
|
Component.literal(label),
|
||||||
b -> selectCategory(cat)
|
b -> selectCategory(cat)
|
||||||
@@ -164,6 +236,14 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */
|
||||||
|
private static String tabLabel(String cat) {
|
||||||
|
return switch (cat) {
|
||||||
|
case SHARED_TAB -> "L";
|
||||||
|
default -> cat.substring(0, 1).toUpperCase();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void buildBottomControls() {
|
private void buildBottomControls() {
|
||||||
int centerX = this.width / 2;
|
int centerX = this.width / 2;
|
||||||
int y = this.height - 40;
|
int y = this.height - 40;
|
||||||
@@ -190,6 +270,14 @@ public class ClosetScreen extends Screen {
|
|||||||
rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons
|
rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons
|
||||||
}
|
}
|
||||||
).bounds(this.width - 70, y, 60, 20).build());
|
).bounds(this.width - 70, y, 60, 20).build());
|
||||||
|
|
||||||
|
// Open the shared-cosmetic upload form. All validation + parsing happens in the
|
||||||
|
// uploader's pipeline (SafePngReader + BbModelParser + BbmodelCodec); this button
|
||||||
|
// just swaps the closet UI into the form's widgets.
|
||||||
|
addRenderableWidget(Button.builder(
|
||||||
|
Component.literal("Share"),
|
||||||
|
b -> openShareForm()
|
||||||
|
).bounds(this.width - 140, y, 60, 20).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void selectCategory(String cat) {
|
private void selectCategory(String cat) {
|
||||||
@@ -202,16 +290,22 @@ public class ClosetScreen extends Screen {
|
|||||||
private void rebuildCosmeticRow() {
|
private void rebuildCosmeticRow() {
|
||||||
for (Button btn : cosmeticButtons) removeWidget(btn);
|
for (Button btn : cosmeticButtons) removeWidget(btn);
|
||||||
cosmeticButtons.clear();
|
cosmeticButtons.clear();
|
||||||
|
sharedButtonHashes.clear();
|
||||||
|
|
||||||
if (selectedCategory == null) return;
|
if (selectedCategory == null) return;
|
||||||
if (SKIN_TAB.equals(selectedCategory)) {
|
if (SKIN_TAB.equals(selectedCategory)) {
|
||||||
buildSkinTabRow();
|
buildSkinTabRow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (SHARED_TAB.equals(selectedCategory)) {
|
||||||
|
buildSharedTabRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
|
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
|
||||||
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
|
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
|
||||||
|
|
||||||
|
// Authored cosmetics for this category.
|
||||||
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
||||||
CosmeticDefinition def = entry.getValue();
|
CosmeticDefinition def = entry.getValue();
|
||||||
if (!selectedCategory.equals(def.category())) continue;
|
if (!selectedCategory.equals(def.category())) continue;
|
||||||
@@ -234,6 +328,32 @@ public class ClosetScreen extends Screen {
|
|||||||
addRenderableWidget(btn);
|
addRenderableWidget(btn);
|
||||||
x += COSMETIC_SIZE + COSMETIC_SPACING;
|
x += COSMETIC_SIZE + COSMETIC_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unified browse: append shared-library entries whose bones map to this category.
|
||||||
|
// Same box size + stride as authored — they scroll together in the same horizontal
|
||||||
|
// row. Visual distinguisher is a different border color in the preview renderer.
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
CosmeticLibrary lib = player != null
|
||||||
|
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
|
||||||
|
: CosmeticLibrary.EMPTY;
|
||||||
|
|
||||||
|
for (CosmeticLibraryEntry libEntry : lib.entries()) {
|
||||||
|
if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue;
|
||||||
|
|
||||||
|
// Pull from server if the blob hasn't materialized locally yet.
|
||||||
|
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
|
||||||
|
|
||||||
|
Button btn = Button.builder(
|
||||||
|
Component.empty(),
|
||||||
|
b -> equipShared(libEntry)
|
||||||
|
).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build();
|
||||||
|
btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width);
|
||||||
|
btn.active = btn.visible;
|
||||||
|
cosmeticButtons.add(btn);
|
||||||
|
addRenderableWidget(btn);
|
||||||
|
sharedButtonHashes.put(btn, libEntry.hash());
|
||||||
|
x += COSMETIC_SIZE + COSMETIC_SPACING;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skin tab layout — action buttons stack vertically on the left, grid runs right.
|
// Skin tab layout — action buttons stack vertically on the left, grid runs right.
|
||||||
@@ -295,10 +415,22 @@ public class ClosetScreen extends Screen {
|
|||||||
cosmeticButtons.add(reset);
|
cosmeticButtons.add(reset);
|
||||||
|
|
||||||
// Grid row starts past the action column. Boxes honor the Toggle button; action
|
// Grid row starts past the action column. Boxes honor the Toggle button; action
|
||||||
// column above stays visible either way.
|
// column above stays visible either way. Source of truth is the per-player server
|
||||||
|
// library attachment — not the local blob cache — so other players' uploads never
|
||||||
|
// appear here even when their blobs happen to be on disk.
|
||||||
int boxX = SKIN_GRID_START_X - cosmeticScroll;
|
int boxX = SKIN_GRID_START_X - cosmeticScroll;
|
||||||
for (Map.Entry<String, Identifier> entry : ClientSkinCache.snapshotRegistered().entrySet()) {
|
LocalPlayer localPlayer = Minecraft.getInstance().player;
|
||||||
final String hash = entry.getKey();
|
SkinLibrary library = localPlayer != null
|
||||||
|
? localPlayer.getData(SkinAttachments.LIBRARY.get())
|
||||||
|
: SkinLibrary.EMPTY;
|
||||||
|
for (SkinLibraryEntry libEntry : library.entries()) {
|
||||||
|
final String hash = libEntry.hash();
|
||||||
|
// Pull bytes from the server if this client hasn't seen them yet.
|
||||||
|
if (ClientSkinCache.ensureLoadedFromDisk(hash) == null
|
||||||
|
&& !ClientSkinCache.isAwaiting(hash)) {
|
||||||
|
ClientSkinCache.markRequested(hash);
|
||||||
|
ClientSkinCache.sendToServer(new RequestSkin(hash));
|
||||||
|
}
|
||||||
boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width);
|
boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width);
|
||||||
boolean show = showPreviews && onScreen;
|
boolean show = showPreviews && onScreen;
|
||||||
|
|
||||||
@@ -328,6 +460,187 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Shared-library tab (vertical scroll grid) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared tab layout: horizontal filter row at top (All / Head / Torso / Arms / Legs),
|
||||||
|
* wrapping grid below using full width. Clicking a filter narrows the grid to entries
|
||||||
|
* whose bone set includes the filter's bone group. Entries with only {@link
|
||||||
|
* BoneExtraction#OTHER} bones show only under "All".
|
||||||
|
*/
|
||||||
|
private void buildSharedTabRow() {
|
||||||
|
sharedButtonHashes.clear();
|
||||||
|
|
||||||
|
// Filter bar — one button per filter option; active filter is dimmed so the player
|
||||||
|
// can tell which one is applied without hover state.
|
||||||
|
int fx = COSMETIC_ROW_LEFT;
|
||||||
|
for (int i = 0; i < SHARED_FILTER_KEYS.length; i++) {
|
||||||
|
final String key = SHARED_FILTER_KEYS[i];
|
||||||
|
Button filterBtn = Button.builder(
|
||||||
|
Component.literal(SHARED_FILTER_LABELS[i]),
|
||||||
|
b -> setSharedBoneFilter(key)
|
||||||
|
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
|
||||||
|
filterBtn.active = !java.util.Objects.equals(sharedBoneFilter, key);
|
||||||
|
addRenderableWidget(filterBtn);
|
||||||
|
cosmeticButtons.add(filterBtn);
|
||||||
|
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid area — full-width now that the slot selector is gone.
|
||||||
|
int gridY = SHARED_GRID_TOP;
|
||||||
|
int gridLeft = COSMETIC_ROW_LEFT;
|
||||||
|
int gridRight = this.width - 10;
|
||||||
|
int gridBottom = this.height - SHARED_GRID_BOTTOM_PAD;
|
||||||
|
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
|
||||||
|
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
|
||||||
|
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
CosmeticLibrary lib = player != null
|
||||||
|
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
|
||||||
|
: CosmeticLibrary.EMPTY;
|
||||||
|
|
||||||
|
int idx = 0;
|
||||||
|
for (CosmeticLibraryEntry libEntry : lib.entries()) {
|
||||||
|
if (!entryMatchesSharedFilter(libEntry)) continue;
|
||||||
|
String hash = libEntry.hash();
|
||||||
|
// Pull from server if the blob hasn't materialized locally yet.
|
||||||
|
ClientSharedCosmeticCache.requestIfMissing(hash);
|
||||||
|
|
||||||
|
int row = idx / boxesPerRow;
|
||||||
|
int col = idx % boxesPerRow;
|
||||||
|
int boxX = gridLeft + col * stride;
|
||||||
|
int boxY = gridY + row * stride - sharedVerticalScroll;
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
boolean onScreen = boxY + COSMETIC_SIZE > gridY && boxY < gridBottom;
|
||||||
|
boolean show = showPreviews && onScreen;
|
||||||
|
|
||||||
|
Button pick = Button.builder(
|
||||||
|
Component.empty(),
|
||||||
|
b -> equipShared(libEntry)
|
||||||
|
).bounds(boxX, boxY, COSMETIC_SIZE, COSMETIC_SIZE).build();
|
||||||
|
pick.visible = show;
|
||||||
|
pick.active = show;
|
||||||
|
cosmeticButtons.add(pick);
|
||||||
|
addRenderableWidget(pick);
|
||||||
|
sharedButtonHashes.put(pick, hash);
|
||||||
|
|
||||||
|
Button del = Button.builder(
|
||||||
|
Component.literal("x"),
|
||||||
|
b -> deleteShared(hash)
|
||||||
|
).bounds(boxX + COSMETIC_SIZE - 12, boxY - 12, 12, 12).build();
|
||||||
|
del.visible = show;
|
||||||
|
del.active = show;
|
||||||
|
cosmeticButtons.add(del);
|
||||||
|
addRenderableWidget(del);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setSharedBoneFilter(@Nullable String filter) {
|
||||||
|
sharedBoneFilter = filter;
|
||||||
|
sharedVerticalScroll = 0;
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does an entry's bone set include the currently-selected filter's group? Maps
|
||||||
|
* filter keys to canonical bones: {@code "Head"} → just {@code Head};
|
||||||
|
* {@code "Body"} → {@code Body}; {@code "Arm"} → either left/right arm;
|
||||||
|
* {@code "Leg"} → either left/right leg. {@code null} filter always matches.
|
||||||
|
*/
|
||||||
|
private boolean entryMatchesSharedFilter(CosmeticLibraryEntry entry) {
|
||||||
|
if (sharedBoneFilter == null) return true;
|
||||||
|
List<String> bones = entry.bones();
|
||||||
|
return switch (sharedBoneFilter) {
|
||||||
|
case "Head" -> bones.contains(BoneExtraction.HEAD);
|
||||||
|
case "Body" -> bones.contains(BoneExtraction.BODY);
|
||||||
|
case "Arm" -> bones.contains(BoneExtraction.LEFT_ARM) || bones.contains(BoneExtraction.RIGHT_ARM);
|
||||||
|
case "Leg" -> bones.contains(BoneExtraction.LEFT_LEG) || bones.contains(BoneExtraction.RIGHT_LEG);
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the equip category from the cosmetic's primary bone and toggle equip in
|
||||||
|
* that slot — matches the authored path's click-to-unequip behavior. An empty hash
|
||||||
|
* on the wire tells the server to clear the slot (see
|
||||||
|
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork#onAssignShared}).
|
||||||
|
*/
|
||||||
|
private void equipShared(CosmeticLibraryEntry entry) {
|
||||||
|
String category = BoneExtraction.primaryCategory(entry.bones());
|
||||||
|
if (category == null) category = FALLBACK_SHARED_CATEGORY;
|
||||||
|
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
Map<String, CosmeticRef> currentEquipped = player != null
|
||||||
|
? player.getData(CosmeticAttachments.EQUIPPED.get())
|
||||||
|
: Map.of();
|
||||||
|
CosmeticRef current = currentEquipped.get(category);
|
||||||
|
boolean alreadyEquipped = current instanceof CosmeticRef.Shared s
|
||||||
|
&& entry.hash().equals(s.hash());
|
||||||
|
|
||||||
|
String hashOnWire = alreadyEquipped ? "" : entry.hash();
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(category, hashOnWire));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the library entry for a given content hash. O(N) over library entries —
|
||||||
|
* fine since {@link CosmeticLibrary#MAX_ENTRIES} caps this at a small number.
|
||||||
|
*/
|
||||||
|
private @Nullable CosmeticLibraryEntry findSharedEntry(String hash) {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return null;
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
for (CosmeticLibraryEntry e : lib.entries()) {
|
||||||
|
if (e.hash().equals(hash)) return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same derivation used by {@link #equipShared}, looked up by hash — for preview rendering. */
|
||||||
|
private String sharedCategoryForHash(String hash) {
|
||||||
|
CosmeticLibraryEntry entry = findSharedEntry(hash);
|
||||||
|
if (entry == null) return FALLBACK_SHARED_CATEGORY;
|
||||||
|
String cat = BoneExtraction.primaryCategory(entry.bones());
|
||||||
|
return cat != null ? cat : FALLBACK_SHARED_CATEGORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteShared(String hash) {
|
||||||
|
// Server drops the hash from this player's library; the local call releases the
|
||||||
|
// baked model + texture and deletes the local blob cache file.
|
||||||
|
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
|
||||||
|
ClientSharedCosmeticCache.delete(hash);
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total vertical extent of the shared grid, counting only entries that pass the filter. */
|
||||||
|
private int sharedGridContentHeight() {
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return 0;
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
|
||||||
|
int gridLeft = COSMETIC_ROW_LEFT;
|
||||||
|
int gridRight = this.width - 10;
|
||||||
|
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
|
||||||
|
int visible = 0;
|
||||||
|
for (CosmeticLibraryEntry e : lib.entries()) {
|
||||||
|
if (entryMatchesSharedFilter(e)) visible++;
|
||||||
|
}
|
||||||
|
int rows = (visible + boxesPerRow - 1) / boxesPerRow;
|
||||||
|
return rows * stride;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clampSharedScroll() {
|
||||||
|
int viewport = (this.height - SHARED_GRID_BOTTOM_PAD) - SHARED_GRID_TOP;
|
||||||
|
int max = Math.max(0, sharedGridContentHeight() - viewport);
|
||||||
|
if (sharedVerticalScroll < 0) sharedVerticalScroll = 0;
|
||||||
|
if (sharedVerticalScroll > max) sharedVerticalScroll = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String titleCaseAscii(String s) {
|
||||||
|
if (s.isEmpty()) return s;
|
||||||
|
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride}
|
* Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride}
|
||||||
* immediately so the dispatcher picks the right renderer this frame, not next tick.
|
* immediately so the dispatcher picks the right renderer this frame, not next tick.
|
||||||
@@ -341,22 +654,24 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void deleteCachedSkin(String hash) {
|
private void deleteCachedSkin(String hash) {
|
||||||
|
// Tell the server to drop it from this player's library (and reset their active
|
||||||
|
// SkinData if they were wearing it). The server broadcasts nothing to other
|
||||||
|
// players beyond the SkinData reset — other players' libraries aren't touched.
|
||||||
|
ClientSkinCache.sendToServer(new DeleteSkin(hash));
|
||||||
ClientSkinCache.delete(hash);
|
ClientSkinCache.delete(hash);
|
||||||
// If the player was wearing this skin, reset both sides of the sync:
|
|
||||||
// - send AssignSkin("") so other clients + the server record the reset
|
|
||||||
// - setData(EMPTY) locally so the SkinInfoOverride tick doesn't re-request the
|
|
||||||
// bytes before the server sync round-trips back (which would re-cache the
|
|
||||||
// skin we just deleted and require a second click to actually get rid of it).
|
|
||||||
LocalPlayer player = Minecraft.getInstance().player;
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
|
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
||||||
|
player.setData(SkinAttachments.LIBRARY.get(), lib.remove(hash));
|
||||||
|
|
||||||
SkinData current = player.getData(SkinAttachments.DATA.get());
|
SkinData current = player.getData(SkinAttachments.DATA.get());
|
||||||
if (current != null && hash.equals(current.hash())) {
|
if (current != null && hash.equals(current.hash())) {
|
||||||
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
||||||
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
|
|
||||||
SkinInfoOverride.syncAll();
|
SkinInfoOverride.syncAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastKnownSkinCount = -1; // force the skin-count poll to re-pick up the change
|
lastKnownSkinCount = -1;
|
||||||
rebuildCosmeticRow();
|
rebuildCosmeticRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,14 +709,244 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Shared-cosmetic upload form ----
|
||||||
|
|
||||||
|
private void openShareForm() {
|
||||||
|
showingShareForm = true;
|
||||||
|
rebuildWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeShareForm() {
|
||||||
|
showingShareForm = false;
|
||||||
|
shareTextureField = null;
|
||||||
|
shareModelField = null;
|
||||||
|
shareNameField = null;
|
||||||
|
shareStatusLabel = null;
|
||||||
|
shareStatusMessage = "";
|
||||||
|
rebuildWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the modal form. EditBox contents are plain user text — every byte is re-validated
|
||||||
|
* by {@link #submitShareForm} before anything reaches the uploader, and the uploader
|
||||||
|
* itself does the real sanitization (file-size gates, {@code SafePngReader},
|
||||||
|
* {@code BbModelParser}, {@code BbmodelCodec}). This UI layer just provides friendly
|
||||||
|
* early rejection so the user sees a clear error before the upload attempt.
|
||||||
|
*/
|
||||||
|
private void buildShareForm() {
|
||||||
|
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("Share Cosmetic"),
|
||||||
|
this.font
|
||||||
|
);
|
||||||
|
addRenderableWidget(title);
|
||||||
|
y += 22;
|
||||||
|
|
||||||
|
// Texture path row: EditBox + Browse.
|
||||||
|
int browseW = 60;
|
||||||
|
shareTextureField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
|
||||||
|
Component.literal("Texture path"));
|
||||||
|
shareTextureField.setMaxLength(SHARE_PATH_MAX_LEN);
|
||||||
|
shareTextureField.setHint(Component.literal("Path to .png"));
|
||||||
|
addRenderableWidget(shareTextureField);
|
||||||
|
addRenderableWidget(Button.builder(
|
||||||
|
Component.literal("Browse"),
|
||||||
|
b -> pickShareTexturePath()
|
||||||
|
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
|
||||||
|
y += fieldH + rowGap;
|
||||||
|
|
||||||
|
// Model path row: EditBox + Browse.
|
||||||
|
shareModelField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
|
||||||
|
Component.literal("Model path"));
|
||||||
|
shareModelField.setMaxLength(SHARE_PATH_MAX_LEN);
|
||||||
|
shareModelField.setHint(Component.literal("Path to .bbmodel"));
|
||||||
|
addRenderableWidget(shareModelField);
|
||||||
|
addRenderableWidget(Button.builder(
|
||||||
|
Component.literal("Browse"),
|
||||||
|
b -> pickShareModelPath()
|
||||||
|
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
|
||||||
|
y += fieldH + rowGap;
|
||||||
|
|
||||||
|
// Name (optional).
|
||||||
|
shareNameField = new EditBox(this.font, x0 + 10, y, fieldW, fieldH,
|
||||||
|
Component.literal("Display name"));
|
||||||
|
shareNameField.setMaxLength(SHARE_NAME_MAX_LEN);
|
||||||
|
shareNameField.setHint(Component.literal("Name (optional — defaults to texture filename)"));
|
||||||
|
addRenderableWidget(shareNameField);
|
||||||
|
y += fieldH + rowGap;
|
||||||
|
|
||||||
|
// Status line (updated after submit).
|
||||||
|
shareStatusLabel = new StringWidget(
|
||||||
|
x0 + 10, y, fieldW, 15,
|
||||||
|
Component.literal(shareStatusMessage),
|
||||||
|
this.font
|
||||||
|
);
|
||||||
|
addRenderableWidget(shareStatusLabel);
|
||||||
|
y += 22;
|
||||||
|
|
||||||
|
// Submit / Cancel.
|
||||||
|
int btnW = (fieldW - 10) / 2;
|
||||||
|
addRenderableWidget(Button.builder(
|
||||||
|
Component.literal("Submit"),
|
||||||
|
b -> submitShareForm()
|
||||||
|
).bounds(x0 + 10, y, btnW, fieldH).build());
|
||||||
|
addRenderableWidget(Button.builder(
|
||||||
|
Component.literal("Cancel"),
|
||||||
|
b -> closeShareForm()
|
||||||
|
).bounds(x0 + 10 + btnW + 10, y, btnW, fieldH).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pickShareTexturePath() {
|
||||||
|
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||||
|
PointerBuffer filters = stack.mallocPointer(1);
|
||||||
|
filters.put(stack.UTF8("*.png"));
|
||||||
|
filters.flip();
|
||||||
|
String path = TinyFileDialogs.tinyfd_openFileDialog(
|
||||||
|
"Select texture PNG", "", filters, "PNG images", false);
|
||||||
|
if (path != null && !path.isEmpty() && shareTextureField != null) {
|
||||||
|
shareTextureField.setValue(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pickShareModelPath() {
|
||||||
|
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||||
|
PointerBuffer filters = stack.mallocPointer(1);
|
||||||
|
filters.put(stack.UTF8("*.bbmodel"));
|
||||||
|
filters.flip();
|
||||||
|
String path = TinyFileDialogs.tinyfd_openFileDialog(
|
||||||
|
"Select .bbmodel", "", filters, "bbmodel files", false);
|
||||||
|
if (path != null && !path.isEmpty() && shareModelField != null) {
|
||||||
|
shareModelField.setValue(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize every field, reject obvious bad input early with clear messages, and then
|
||||||
|
* hand off to {@link ClientSharedCosmeticUploader} which runs the actual security
|
||||||
|
* pipeline (file-size gates, {@code SafePngReader}, {@code BbModelParser},
|
||||||
|
* {@code BbmodelCodec}, library-dedup check, chunking).
|
||||||
|
*/
|
||||||
|
private void submitShareForm() {
|
||||||
|
if (shareTextureField == null || shareModelField == null || shareNameField == null) return;
|
||||||
|
|
||||||
|
String texturePath = sanitizeSharePath(shareTextureField.getValue());
|
||||||
|
String modelPath = sanitizeSharePath(shareModelField.getValue());
|
||||||
|
String name = sanitizeShareName(shareNameField.getValue());
|
||||||
|
|
||||||
|
if (texturePath.isEmpty()) { setShareStatus("Texture path required"); return; }
|
||||||
|
if (modelPath.isEmpty()) { setShareStatus("Model path required"); return; }
|
||||||
|
|
||||||
|
Path texture;
|
||||||
|
Path model;
|
||||||
|
try {
|
||||||
|
texture = Paths.get(texturePath);
|
||||||
|
model = Paths.get(modelPath);
|
||||||
|
} catch (InvalidPathException ex) {
|
||||||
|
setShareStatus("Invalid path: " + ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Files.isRegularFile(texture)) { setShareStatus("Texture file not found"); return; }
|
||||||
|
if (!Files.isRegularFile(model)) { setShareStatus("Model file not found"); return; }
|
||||||
|
|
||||||
|
String texLower = texturePath.toLowerCase(Locale.ROOT);
|
||||||
|
String mdlLower = modelPath.toLowerCase(Locale.ROOT);
|
||||||
|
if (!texLower.endsWith(".png")) { setShareStatus("Texture must be .png"); return; }
|
||||||
|
if (!mdlLower.endsWith(".bbmodel")) { setShareStatus("Model must be .bbmodel"); return; }
|
||||||
|
|
||||||
|
if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString());
|
||||||
|
|
||||||
|
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
|
||||||
|
setShareStatus(result);
|
||||||
|
|
||||||
|
// Clear the fields on successful submission so a second upload starts fresh.
|
||||||
|
// The uploader's status string is the only signal of outcome — "Uploading " means
|
||||||
|
// chunks went to the server, "Already in library" means dedup short-circuited
|
||||||
|
// (still a valid state, nothing to retry). Failures keep the fields populated so
|
||||||
|
// the user can correct and resubmit without re-entering paths.
|
||||||
|
if (result != null
|
||||||
|
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
|
||||||
|
shareTextureField.setValue("");
|
||||||
|
shareModelField.setValue("");
|
||||||
|
shareNameField.setValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setShareStatus(String msg) {
|
||||||
|
shareStatusMessage = msg == null ? "" : msg;
|
||||||
|
if (shareStatusLabel != null) shareStatusLabel.setMessage(Component.literal(shareStatusMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */
|
||||||
|
private static String sanitizeSharePath(String raw) {
|
||||||
|
if (raw == null) return "";
|
||||||
|
String s = raw.trim();
|
||||||
|
if (s.length() >= 2
|
||||||
|
&& ((s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"')
|
||||||
|
|| (s.charAt(0) == '\'' && s.charAt(s.length() - 1) == '\''))) {
|
||||||
|
s = s.substring(1, s.length() - 1).trim();
|
||||||
|
}
|
||||||
|
if (s.indexOf('\0') >= 0) return "";
|
||||||
|
if (s.length() > SHARE_PATH_MAX_LEN) return "";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */
|
||||||
|
private static String sanitizeShareName(String raw) {
|
||||||
|
if (raw == null) return "";
|
||||||
|
String s = raw.trim();
|
||||||
|
StringBuilder sb = new StringBuilder(s.length());
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c >= 0x20 && c != 0x7F) sb.append(c);
|
||||||
|
}
|
||||||
|
String out = sb.toString();
|
||||||
|
if (out.length() > SHARE_NAME_MAX_LEN) out = out.substring(0, SHARE_NAME_MAX_LEN);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "red_hat.png" → "Red Hat". Used when the user leaves the name field blank. */
|
||||||
|
private static String titleCaseFilenameStem(String filename) {
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
String stem = dot < 0 ? filename : filename.substring(0, dot);
|
||||||
|
StringBuilder out = new StringBuilder(stem.length());
|
||||||
|
boolean capitalize = true;
|
||||||
|
for (int i = 0; i < stem.length(); i++) {
|
||||||
|
char c = stem.charAt(i);
|
||||||
|
if (c == '_' || c == '-' || c == ' ') {
|
||||||
|
out.append(' ');
|
||||||
|
capitalize = true;
|
||||||
|
} else if (capitalize) {
|
||||||
|
out.append(Character.toUpperCase(c));
|
||||||
|
capitalize = false;
|
||||||
|
} else {
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private void equip(String category, Identifier cosmeticId) {
|
private void equip(String category, Identifier cosmeticId) {
|
||||||
Minecraft mc = Minecraft.getInstance();
|
Minecraft mc = Minecraft.getInstance();
|
||||||
LocalPlayer player = mc.player;
|
LocalPlayer player = mc.player;
|
||||||
if (player == null || player.connection == null) return;
|
if (player == null || player.connection == null) return;
|
||||||
|
|
||||||
// Toggle: if this cosmetic is already equipped in that category, unequip.
|
// Toggle: if this cosmetic is already equipped in that category, unequip.
|
||||||
Map<String, Identifier> currentEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> currentEquipped =
|
||||||
String cmd = cosmeticId.equals(currentEquipped.get(category))
|
player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
com.razz.dfashion.cosmetic.CosmeticRef current = currentEquipped.get(category);
|
||||||
|
boolean alreadyEquipped = current instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
|
||||||
|
&& l.id().equals(cosmeticId);
|
||||||
|
String cmd = alreadyEquipped
|
||||||
? "decofashion unequip " + category
|
? "decofashion unequip " + category
|
||||||
: "decofashion equip " + category + " " + cosmeticId;
|
: "decofashion equip " + category + " " + cosmeticId;
|
||||||
player.connection.sendUnattendedCommand(cmd, this);
|
player.connection.sendUnattendedCommand(cmd, this);
|
||||||
@@ -423,6 +968,16 @@ public class ClosetScreen extends Screen {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
|
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
|
||||||
|
// Shared tab uses the full right-side region as a vertical scroll area.
|
||||||
|
if (SHARED_TAB.equals(selectedCategory)
|
||||||
|
&& my >= SHARED_GRID_TOP
|
||||||
|
&& my <= this.height - SHARED_GRID_BOTTOM_PAD
|
||||||
|
&& mx >= SKIN_GRID_START_X) {
|
||||||
|
sharedVerticalScroll -= (int) (deltaY * 20);
|
||||||
|
clampSharedScroll();
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10
|
if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10
|
||||||
&& my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) {
|
&& my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) {
|
||||||
cosmeticScroll -= (int) (deltaY * 20);
|
cosmeticScroll -= (int) (deltaY * 20);
|
||||||
@@ -445,12 +1000,20 @@ public class ClosetScreen extends Screen {
|
|||||||
// covers uploads (which add a new entry asynchronously after the server echo arrives)
|
// covers uploads (which add a new entry asynchronously after the server echo arrives)
|
||||||
// and any new skin received from another player.
|
// and any new skin received from another player.
|
||||||
if (SKIN_TAB.equals(selectedCategory)) {
|
if (SKIN_TAB.equals(selectedCategory)) {
|
||||||
int count = ClientSkinCache.snapshotRegistered().size();
|
int count = player.getData(SkinAttachments.LIBRARY.get()).entries().size();
|
||||||
if (count != lastKnownSkinCount) {
|
if (count != lastKnownSkinCount) {
|
||||||
lastKnownSkinCount = count;
|
lastKnownSkinCount = count;
|
||||||
rebuildCosmeticRow();
|
rebuildCosmeticRow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (SHARED_TAB.equals(selectedCategory)) {
|
||||||
|
int count = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()).entries().size();
|
||||||
|
if (count != lastKnownSharedCount) {
|
||||||
|
lastKnownSharedCount = count;
|
||||||
|
clampSharedScroll();
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
long handle = mc.getWindow().handle();
|
long handle = mc.getWindow().handle();
|
||||||
boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
|
boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
|
||||||
@@ -481,8 +1044,11 @@ public class ClosetScreen extends Screen {
|
|||||||
String hit = findClickedEquippedCategory(player, mx, my);
|
String hit = findClickedEquippedCategory(player, mx, my);
|
||||||
if (hit != null) {
|
if (hit != null) {
|
||||||
selectCategory(hit);
|
selectCategory(hit);
|
||||||
Identifier current = player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
|
com.razz.dfashion.cosmetic.CosmeticRef ref =
|
||||||
if (current != null) scrollToCosmetic(hit, current);
|
player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
|
||||||
|
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) {
|
||||||
|
scrollToCosmetic(hit, l.id());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,7 +1107,8 @@ public class ClosetScreen extends Screen {
|
|||||||
* camera-local space (subtract {@code camera.position()}).
|
* camera-local space (subtract {@code camera.position()}).
|
||||||
*/
|
*/
|
||||||
private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) {
|
private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) {
|
||||||
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> equipped =
|
||||||
|
player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
if (equipped.isEmpty()) return null;
|
if (equipped.isEmpty()) return null;
|
||||||
|
|
||||||
Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera();
|
Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera();
|
||||||
@@ -554,7 +1121,7 @@ public class ClosetScreen extends Screen {
|
|||||||
String best = null;
|
String best = null;
|
||||||
double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX;
|
double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX;
|
||||||
|
|
||||||
for (Map.Entry<String, Identifier> entry : equipped.entrySet()) {
|
for (Map.Entry<String, com.razz.dfashion.cosmetic.CosmeticRef> entry : equipped.entrySet()) {
|
||||||
String category = entry.getKey();
|
String category = entry.getKey();
|
||||||
Vec3 bonePos = approximateBoneWorldPos(player, category);
|
Vec3 bonePos = approximateBoneWorldPos(player, category);
|
||||||
|
|
||||||
@@ -603,9 +1170,10 @@ public class ClosetScreen extends Screen {
|
|||||||
int count;
|
int count;
|
||||||
int rowLeft;
|
int rowLeft;
|
||||||
if (SKIN_TAB.equals(selectedCategory)) {
|
if (SKIN_TAB.equals(selectedCategory)) {
|
||||||
// Skin grid: rows come from the client skin cache, and they start past the
|
// Skin grid: rows come from the server-authoritative player library (not the
|
||||||
// vertical action column, not at COSMETIC_ROW_LEFT.
|
// client's content-addressed blob pool), so only this player's 5 slots count.
|
||||||
count = ClientSkinCache.snapshotRegistered().size();
|
LocalPlayer p = Minecraft.getInstance().player;
|
||||||
|
count = p != null ? p.getData(SkinAttachments.LIBRARY.get()).entries().size() : 0;
|
||||||
rowLeft = SKIN_GRID_START_X;
|
rowLeft = SKIN_GRID_START_X;
|
||||||
} else {
|
} else {
|
||||||
count = 0;
|
count = 0;
|
||||||
@@ -666,11 +1234,18 @@ public class ClosetScreen extends Screen {
|
|||||||
renderSkinPreviews(graphics, player);
|
renderSkinPreviews(graphics, player);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (SHARED_TAB.equals(selectedCategory)) {
|
||||||
|
renderSharedPreviews(graphics, player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
|
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
|
||||||
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
|
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
|
||||||
Map<String, Identifier> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
|
||||||
|
player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
|
||||||
|
// Authored loop first. Shared entries appended to this category tab render via
|
||||||
|
// renderSharedPreviews below — they're not matched by the catalog iteration.
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
||||||
CosmeticDefinition def = entry.getValue();
|
CosmeticDefinition def = entry.getValue();
|
||||||
@@ -688,7 +1263,9 @@ public class ClosetScreen extends Screen {
|
|||||||
// Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic).
|
// Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic).
|
||||||
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
|
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
|
||||||
|
|
||||||
boolean isEquipped = entry.getKey().equals(liveEquipped.get(def.category()));
|
com.razz.dfashion.cosmetic.CosmeticRef liveRef = liveEquipped.get(def.category());
|
||||||
|
boolean isEquipped = liveRef instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
|
||||||
|
&& entry.getKey().equals(l.id());
|
||||||
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
|
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
|
||||||
|
|
||||||
// Frame (green if currently equipped).
|
// Frame (green if currently equipped).
|
||||||
@@ -715,7 +1292,7 @@ public class ClosetScreen extends Screen {
|
|||||||
// Register the override — layer will read this instead of the live player's
|
// Register the override — layer will read this instead of the live player's
|
||||||
// equipped attachment when rendering this specific render state.
|
// equipped attachment when rendering this specific render state.
|
||||||
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
|
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
|
||||||
Map.of(def.category(), entry.getKey()));
|
Map.of(def.category(), new com.razz.dfashion.cosmetic.CosmeticRef.Local(entry.getKey())));
|
||||||
|
|
||||||
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
|
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
|
||||||
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
|
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
|
||||||
@@ -724,6 +1301,80 @@ public class ClosetScreen extends Screen {
|
|||||||
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
|
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
|
||||||
x0, y0, x1, y1);
|
x0, y0, x1, y1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared entries that `rebuildCosmeticRow` appended to this category tab render
|
||||||
|
// via the shared preview path — they're not in the catalog.
|
||||||
|
renderSharedPreviews(graphics, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D-mini-player preview per shared-library entry. Per-box we inject a
|
||||||
|
* {@link CosmeticRef.Shared} override so the cosmetic renders in the category derived
|
||||||
|
* from the entry's bones. Boxes that are scrolled off the grid are skipped. Green frame
|
||||||
|
* when equipped; amber frame for un-equipped shared entries so they're distinguishable
|
||||||
|
* from authored ones (white frame).
|
||||||
|
*/
|
||||||
|
private void renderSharedPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
|
||||||
|
if (sharedButtonHashes.isEmpty()) return;
|
||||||
|
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
|
||||||
|
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
|
||||||
|
Map<String, CosmeticRef> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
|
||||||
|
// Vertical clip only matters on the shared tab (vertical scroll grid).
|
||||||
|
// On authored category tabs the shared buttons live in the bottom row alongside
|
||||||
|
// authored ones, so we use the full-screen range and rely on the button's visible
|
||||||
|
// flag + horizontal clip.
|
||||||
|
boolean onSharedTab = SHARED_TAB.equals(selectedCategory);
|
||||||
|
int gridTop = onSharedTab ? SHARED_GRID_TOP : 0;
|
||||||
|
int gridBottom = onSharedTab ? (this.height - SHARED_GRID_BOTTOM_PAD) : this.height;
|
||||||
|
|
||||||
|
for (Map.Entry<Button, String> e : sharedButtonHashes.entrySet()) {
|
||||||
|
Button btn = e.getKey();
|
||||||
|
String hash = e.getValue();
|
||||||
|
if (!btn.visible) continue;
|
||||||
|
|
||||||
|
int fx0 = btn.getX();
|
||||||
|
int fy0 = btn.getY();
|
||||||
|
int fx1 = fx0 + btn.getWidth();
|
||||||
|
int fy1 = fy0 + btn.getHeight();
|
||||||
|
// Clip against vertical-scroll viewport.
|
||||||
|
if (fy1 <= gridTop || fy0 >= gridBottom) continue;
|
||||||
|
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
|
||||||
|
|
||||||
|
// Baked entry may not have arrived yet (requestIfMissing sent above).
|
||||||
|
if (ClientSharedCosmeticCache.getBaked(hash) == null) continue;
|
||||||
|
|
||||||
|
String previewCategory = sharedCategoryForHash(hash);
|
||||||
|
CosmeticRef liveRef = liveEquipped.get(previewCategory);
|
||||||
|
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash());
|
||||||
|
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
|
||||||
|
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
|
||||||
|
|
||||||
|
int x0 = fx0 + COSMETIC_INNER_PADDING;
|
||||||
|
int y0 = fy0 + COSMETIC_INNER_PADDING;
|
||||||
|
int x1 = fx1 - COSMETIC_INNER_PADDING;
|
||||||
|
int y1 = fy1 - COSMETIC_INNER_PADDING;
|
||||||
|
|
||||||
|
EntityRenderState state = renderer.createRenderState(player, 1.0F);
|
||||||
|
if (!(state instanceof AvatarRenderState avatar)) continue;
|
||||||
|
|
||||||
|
avatar.shadowPieces.clear();
|
||||||
|
avatar.outlineColor = 0;
|
||||||
|
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
|
||||||
|
avatar.yRot = COSMETIC_PREVIEW_YAW;
|
||||||
|
avatar.xRot = 0f;
|
||||||
|
|
||||||
|
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
|
||||||
|
Map.of(previewCategory, new CosmeticRef.Shared(hash)));
|
||||||
|
|
||||||
|
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
|
||||||
|
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
|
||||||
|
Quaternionf xRotation = new Quaternionf();
|
||||||
|
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
|
||||||
|
x0, y0, x1, y1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package com.razz.dfashion.cosmetic;
|
|||||||
|
|
||||||
import com.mojang.serialization.Codec;
|
import com.mojang.serialization.Codec;
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
|
||||||
|
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
import net.minecraft.network.codec.ByteBufCodecs;
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
import net.minecraft.network.codec.StreamCodec;
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.neoforge.attachment.AttachmentType;
|
import net.neoforged.neoforge.attachment.AttachmentType;
|
||||||
import net.neoforged.neoforge.registries.DeferredHolder;
|
import net.neoforged.neoforge.registries.DeferredHolder;
|
||||||
import net.neoforged.neoforge.registries.DeferredRegister;
|
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||||
@@ -20,22 +21,40 @@ public final class CosmeticAttachments {
|
|||||||
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
|
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
|
||||||
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
|
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
|
||||||
|
|
||||||
private static final Codec<Map<String, Identifier>> MAP_CODEC =
|
private static final Codec<Map<String, CosmeticRef>> MAP_CODEC =
|
||||||
Codec.unboundedMap(Codec.STRING, Identifier.CODEC);
|
Codec.unboundedMap(Codec.STRING, CosmeticRef.CODEC);
|
||||||
|
|
||||||
private static final StreamCodec<RegistryFriendlyByteBuf, Map<String, Identifier>> STREAM_CODEC =
|
private static final StreamCodec<RegistryFriendlyByteBuf, Map<String, CosmeticRef>> STREAM_CODEC =
|
||||||
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, Identifier.STREAM_CODEC);
|
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.stringUtf8(64), CosmeticRef.STREAM_CODEC);
|
||||||
|
|
||||||
/** Per-player map of category -> equipped cosmetic id. */
|
/** Per-player map of category -> equipped cosmetic ref. A ref is either a
|
||||||
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, Identifier>>> EQUIPPED =
|
* {@link CosmeticRef.Local} (built-in / user-folder cosmetic) or a
|
||||||
|
* {@link CosmeticRef.Shared} (server-stored blob by content hash). */
|
||||||
|
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, CosmeticRef>>> EQUIPPED =
|
||||||
ATTACHMENT_TYPES.register(
|
ATTACHMENT_TYPES.register(
|
||||||
"equipped_cosmetics",
|
"equipped_cosmetics",
|
||||||
() -> AttachmentType.<Map<String, Identifier>>builder(() -> new HashMap<>())
|
() -> AttachmentType.<Map<String, CosmeticRef>>builder(() -> new HashMap<>())
|
||||||
.serialize(MAP_CODEC.fieldOf("equipped"))
|
.serialize(MAP_CODEC.fieldOf("equipped"))
|
||||||
.sync(STREAM_CODEC)
|
.sync(STREAM_CODEC)
|
||||||
.copyOnDeath()
|
.copyOnDeath()
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Personal library of shared-cosmetic hashes. Synced <b>only to the owning player</b>
|
||||||
|
* so nobody can enumerate another player's uploaded cosmetics. */
|
||||||
|
public static final DeferredHolder<AttachmentType<?>, AttachmentType<CosmeticLibrary>> SHARED_LIBRARY =
|
||||||
|
ATTACHMENT_TYPES.register(
|
||||||
|
"shared_cosmetic_library",
|
||||||
|
() -> AttachmentType.<CosmeticLibrary>builder(() -> CosmeticLibrary.EMPTY)
|
||||||
|
.serialize(CosmeticLibrary.CODEC.fieldOf("shared_library"))
|
||||||
|
.sync(
|
||||||
|
(holder, to) -> holder instanceof ServerPlayer sp
|
||||||
|
&& sp.getUUID().equals(to.getUUID()),
|
||||||
|
CosmeticLibrary.STREAM_CODEC
|
||||||
|
)
|
||||||
|
.copyOnDeath()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
private CosmeticAttachments() {}
|
private CosmeticAttachments() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ public final class CosmeticCommands {
|
|||||||
String idStr = StringArgumentType.getString(ctx, "id");
|
String idStr = StringArgumentType.getString(ctx, "id");
|
||||||
Identifier id = Identifier.parse(idStr);
|
Identifier id = Identifier.parse(idStr);
|
||||||
|
|
||||||
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
||||||
equipped.put(category, id);
|
equipped.put(category, new CosmeticRef.Local(id));
|
||||||
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||||
|
|
||||||
ctx.getSource().sendSuccess(
|
ctx.getSource().sendSuccess(
|
||||||
@@ -67,13 +67,13 @@ public final class CosmeticCommands {
|
|||||||
}
|
}
|
||||||
String category = StringArgumentType.getString(ctx, "category");
|
String category = StringArgumentType.getString(ctx, "category");
|
||||||
|
|
||||||
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
||||||
Identifier removed = equipped.remove(category);
|
CosmeticRef removed = equipped.remove(category);
|
||||||
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||||
|
|
||||||
ctx.getSource().sendSuccess(
|
ctx.getSource().sendSuccess(
|
||||||
() -> Component.literal(removed != null
|
() -> Component.literal(removed != null
|
||||||
? "Unequipped " + removed + " from '" + category + "'"
|
? "Unequipped " + formatRef(removed) + " from '" + category + "'"
|
||||||
: "Nothing was equipped in '" + category + "'"),
|
: "Nothing was equipped in '" + category + "'"),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -86,16 +86,23 @@ public final class CosmeticCommands {
|
|||||||
ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
|
ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
if (equipped.isEmpty()) {
|
if (equipped.isEmpty()) {
|
||||||
ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false);
|
ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false);
|
||||||
} else {
|
} else {
|
||||||
equipped.forEach((cat, id) ->
|
equipped.forEach((cat, ref) ->
|
||||||
ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + id), false)
|
ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + formatRef(ref)), false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String formatRef(CosmeticRef ref) {
|
||||||
|
return switch (ref) {
|
||||||
|
case CosmeticRef.Local l -> l.id().toString();
|
||||||
|
case CosmeticRef.Shared s -> "shared:" + s.hash().substring(0, 8) + "…";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private CosmeticCommands() {}
|
private CosmeticCommands() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.razz.dfashion.cosmetic;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.handler.codec.DecoderException;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of two ways an equipped cosmetic slot can reference its content:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Local} — a built-in or user-folder cosmetic addressed by {@link Identifier}.
|
||||||
|
* Resolved against {@code CosmeticCache.cosmetics}.</li>
|
||||||
|
* <li>{@link Shared} — a server-side blob addressed by its 64-char content hash.
|
||||||
|
* Resolved against {@code ClientSharedCosmeticCache}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Wire codec tags the variant with a single byte (0 = local, 1 = shared) and caps each
|
||||||
|
* inner string length. Invalid shared hashes are rejected via
|
||||||
|
* {@link SharedCosmeticCache#isValidHash} on decode.
|
||||||
|
*/
|
||||||
|
public sealed interface CosmeticRef permits CosmeticRef.Local, CosmeticRef.Shared {
|
||||||
|
|
||||||
|
record Local(Identifier id) implements CosmeticRef {}
|
||||||
|
record Shared(String hash) implements CosmeticRef {}
|
||||||
|
|
||||||
|
byte TAG_LOCAL = 0;
|
||||||
|
byte TAG_SHARED = 1;
|
||||||
|
|
||||||
|
Codec<CosmeticRef> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||||
|
Codec.BOOL.fieldOf("shared").forGetter(r -> r instanceof Shared),
|
||||||
|
Codec.STRING.fieldOf("value").forGetter(r -> switch (r) {
|
||||||
|
case Local l -> l.id().toString();
|
||||||
|
case Shared s -> s.hash();
|
||||||
|
})
|
||||||
|
).apply(inst, (shared, value) -> shared
|
||||||
|
? new Shared(value)
|
||||||
|
: new Local(Identifier.parse(value))));
|
||||||
|
|
||||||
|
StreamCodec<ByteBuf, CosmeticRef> STREAM_CODEC = new StreamCodec<>() {
|
||||||
|
private final StreamCodec<ByteBuf, String> LOCAL_STR = ByteBufCodecs.stringUtf8(256);
|
||||||
|
private final StreamCodec<ByteBuf, String> SHARED_STR = ByteBufCodecs.stringUtf8(64);
|
||||||
|
|
||||||
|
@Override public void encode(ByteBuf buf, CosmeticRef ref) {
|
||||||
|
switch (ref) {
|
||||||
|
case Local l -> {
|
||||||
|
buf.writeByte(TAG_LOCAL);
|
||||||
|
LOCAL_STR.encode(buf, l.id().toString());
|
||||||
|
}
|
||||||
|
case Shared s -> {
|
||||||
|
buf.writeByte(TAG_SHARED);
|
||||||
|
SHARED_STR.encode(buf, s.hash());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public CosmeticRef decode(ByteBuf buf) {
|
||||||
|
byte tag = buf.readByte();
|
||||||
|
return switch (tag) {
|
||||||
|
case TAG_LOCAL -> {
|
||||||
|
String s = LOCAL_STR.decode(buf);
|
||||||
|
Identifier id = Identifier.tryParse(s);
|
||||||
|
if (id == null) throw new DecoderException("bad Identifier in CosmeticRef.Local: " + s);
|
||||||
|
yield new Local(id);
|
||||||
|
}
|
||||||
|
case TAG_SHARED -> {
|
||||||
|
String hash = SHARED_STR.decode(buf);
|
||||||
|
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||||
|
throw new DecoderException("invalid hash in CosmeticRef.Shared");
|
||||||
|
}
|
||||||
|
yield new Shared(hash);
|
||||||
|
}
|
||||||
|
default -> throw new DecoderException("bad CosmeticRef tag " + tag);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.razz.dfashion.bbmodel.BbCube;
|
||||||
|
import com.razz.dfashion.bbmodel.BbFace;
|
||||||
|
import com.razz.dfashion.bbmodel.BbGroup;
|
||||||
|
import com.razz.dfashion.bbmodel.BbLocator;
|
||||||
|
import com.razz.dfashion.bbmodel.BbModelParser;
|
||||||
|
import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.handler.codec.DecoderException;
|
||||||
|
import net.minecraft.network.VarInt;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary wire format for a {@link Bbmodel}. The author's client parses their local JSON
|
||||||
|
* with {@link BbModelParser} (the only JSON parser anywhere in the mod's untrusted-bytes
|
||||||
|
* path), encodes the vetted record via this codec, and everything downstream — server,
|
||||||
|
* receivers — only ever decodes via this codec. GSON is never fed wire bytes.
|
||||||
|
*
|
||||||
|
* <p>Defense layers:
|
||||||
|
* <ul>
|
||||||
|
* <li>Every integer length is VAR_INT and bounded: strings ≤ {@value #MAX_STRING_LEN} chars,
|
||||||
|
* lists ≤ {@value #MAX_LIST_ELEMENTS} elements, face UVs ≤ {@value #MAX_UV_LEN} floats.
|
||||||
|
* A malicious {@code VAR_INT = 2^30} is rejected before any allocation.</li>
|
||||||
|
* <li>Outliner recursion is bounded by {@link BbModelParser#MAX_OUTLINER_DEPTH} because
|
||||||
|
* {@link BbModelParser#validate} re-runs on decoded output.</li>
|
||||||
|
* <li>After decoding, the full semantic validator fires again. Two separate code paths
|
||||||
|
* (codec + validator) must both miss a bug for anything malformed to reach the baker.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class BbmodelCodec {
|
||||||
|
|
||||||
|
public static final int MAX_STRING_LEN = 256;
|
||||||
|
/** Per-list element cap. Bounds up-front allocation before ByteBuf consumption catches up. */
|
||||||
|
public static final int MAX_LIST_ELEMENTS = 65536;
|
||||||
|
/** Per-face UV length cap. Real bbmodel faces use 4 floats; 32 is generous slack. */
|
||||||
|
public static final int MAX_UV_LEN = 32;
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, Bbmodel> CODEC = new StreamCodec<>() {
|
||||||
|
@Override public void encode(ByteBuf buf, Bbmodel m) { writeBbmodel(buf, m); }
|
||||||
|
@Override public Bbmodel decode(ByteBuf buf) {
|
||||||
|
Bbmodel m = readBbmodel(buf);
|
||||||
|
try {
|
||||||
|
BbModelParser.validate(m);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
throw new DecoderException("bbmodel failed wire-side validation: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private BbmodelCodec() {}
|
||||||
|
|
||||||
|
// ---- Bbmodel ----
|
||||||
|
|
||||||
|
private static void writeBbmodel(ByteBuf buf, Bbmodel m) {
|
||||||
|
VarInt.write(buf, m.resolutionWidth());
|
||||||
|
VarInt.write(buf, m.resolutionHeight());
|
||||||
|
writeList(buf, m.elements(), BbmodelCodec::writeCube);
|
||||||
|
writeList(buf, m.locators(), BbmodelCodec::writeLocator);
|
||||||
|
writeList(buf, m.groups(), BbmodelCodec::writeGroup);
|
||||||
|
writeList(buf, m.outliner(), BbmodelCodec::writeOutlinerNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bbmodel readBbmodel(ByteBuf buf) {
|
||||||
|
int w = VarInt.read(buf);
|
||||||
|
int h = VarInt.read(buf);
|
||||||
|
List<BbCube> elements = readList(buf, BbmodelCodec::readCube);
|
||||||
|
List<BbLocator> locators = readList(buf, BbmodelCodec::readLocator);
|
||||||
|
List<BbGroup> groups = readList(buf, BbmodelCodec::readGroup);
|
||||||
|
List<BbOutlinerNode> outliner = readList(buf, BbmodelCodec::readOutlinerNode);
|
||||||
|
return new Bbmodel(w, h, elements, locators, groups, outliner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BbCube ----
|
||||||
|
|
||||||
|
private static void writeCube(ByteBuf buf, BbCube c) {
|
||||||
|
writeString(buf, c.uuid());
|
||||||
|
writeString(buf, c.name());
|
||||||
|
writeVec3f(buf, c.from());
|
||||||
|
writeVec3f(buf, c.to());
|
||||||
|
writeVec3f(buf, c.origin());
|
||||||
|
writeVec3f(buf, c.rotation());
|
||||||
|
buf.writeFloat(c.inflate());
|
||||||
|
Map<String, BbFace> faces = c.faces() != null ? c.faces() : Map.of();
|
||||||
|
if (faces.size() > MAX_LIST_ELEMENTS) {
|
||||||
|
throw new DecoderException("face map too large: " + faces.size());
|
||||||
|
}
|
||||||
|
// Canonical order so authors on different JVMs / Map implementations produce identical
|
||||||
|
// binary and therefore identical content hashes. Breaks dedup if this ever changes.
|
||||||
|
List<Map.Entry<String, BbFace>> sortedFaces = new ArrayList<>(faces.entrySet());
|
||||||
|
sortedFaces.sort(Map.Entry.comparingByKey());
|
||||||
|
VarInt.write(buf, sortedFaces.size());
|
||||||
|
for (Map.Entry<String, BbFace> e : sortedFaces) {
|
||||||
|
writeString(buf, e.getKey());
|
||||||
|
writeFace(buf, e.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BbCube readCube(ByteBuf buf) {
|
||||||
|
String uuid = readString(buf);
|
||||||
|
String name = readString(buf);
|
||||||
|
Vector3f from = readVec3f(buf);
|
||||||
|
Vector3f to = readVec3f(buf);
|
||||||
|
Vector3f origin = readVec3f(buf);
|
||||||
|
Vector3f rotation = readVec3f(buf);
|
||||||
|
float inflate = buf.readFloat();
|
||||||
|
int fcount = VarInt.read(buf);
|
||||||
|
if (fcount < 0 || fcount > MAX_LIST_ELEMENTS) {
|
||||||
|
throw new DecoderException("face count out of range: " + fcount);
|
||||||
|
}
|
||||||
|
Map<String, BbFace> faces = new HashMap<>(Math.min(fcount, 16));
|
||||||
|
for (int i = 0; i < fcount; i++) {
|
||||||
|
String key = readString(buf);
|
||||||
|
faces.put(key, readFace(buf));
|
||||||
|
}
|
||||||
|
return new BbCube(uuid, name, from, to, inflate, origin, rotation, faces);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BbFace ----
|
||||||
|
|
||||||
|
private static void writeFace(ByteBuf buf, BbFace f) {
|
||||||
|
float[] uv = f.uv() != null ? f.uv() : new float[0];
|
||||||
|
if (uv.length > MAX_UV_LEN) throw new DecoderException("uv length too large: " + uv.length);
|
||||||
|
VarInt.write(buf, uv.length);
|
||||||
|
for (float v : uv) buf.writeFloat(v);
|
||||||
|
Integer texture = f.texture();
|
||||||
|
if (texture == null) {
|
||||||
|
buf.writeByte(0);
|
||||||
|
} else {
|
||||||
|
buf.writeByte(1);
|
||||||
|
VarInt.write(buf, texture);
|
||||||
|
}
|
||||||
|
VarInt.write(buf, f.rotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BbFace readFace(ByteBuf buf) {
|
||||||
|
int n = VarInt.read(buf);
|
||||||
|
if (n < 0 || n > MAX_UV_LEN) throw new DecoderException("uv length out of range: " + n);
|
||||||
|
float[] uv = new float[n];
|
||||||
|
for (int i = 0; i < n; i++) uv[i] = buf.readFloat();
|
||||||
|
byte flag = buf.readByte();
|
||||||
|
Integer texture;
|
||||||
|
if (flag == 0) texture = null;
|
||||||
|
else if (flag == 1) texture = VarInt.read(buf);
|
||||||
|
else throw new DecoderException("bad texture flag " + flag);
|
||||||
|
int rotation = VarInt.read(buf);
|
||||||
|
return new BbFace(uv, texture, rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BbLocator / BbGroup ----
|
||||||
|
|
||||||
|
private static void writeLocator(ByteBuf buf, BbLocator loc) {
|
||||||
|
writeString(buf, loc.uuid());
|
||||||
|
writeString(buf, loc.name());
|
||||||
|
writeVec3f(buf, loc.position());
|
||||||
|
writeVec3f(buf, loc.rotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BbLocator readLocator(ByteBuf buf) {
|
||||||
|
return new BbLocator(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeGroup(ByteBuf buf, BbGroup g) {
|
||||||
|
writeString(buf, g.uuid());
|
||||||
|
writeString(buf, g.name());
|
||||||
|
writeVec3f(buf, g.origin());
|
||||||
|
writeVec3f(buf, g.rotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BbGroup readGroup(ByteBuf buf) {
|
||||||
|
return new BbGroup(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BbOutlinerNode (recursive) ----
|
||||||
|
|
||||||
|
private static final byte TAG_ELEMENT = 0;
|
||||||
|
private static final byte TAG_GROUP = 1;
|
||||||
|
|
||||||
|
private static void writeOutlinerNode(ByteBuf buf, BbOutlinerNode node) {
|
||||||
|
switch (node) {
|
||||||
|
case BbOutlinerNode.ElementRef er -> {
|
||||||
|
buf.writeByte(TAG_ELEMENT);
|
||||||
|
writeString(buf, er.uuid());
|
||||||
|
}
|
||||||
|
case BbOutlinerNode.GroupRef gr -> {
|
||||||
|
buf.writeByte(TAG_GROUP);
|
||||||
|
writeString(buf, gr.uuid());
|
||||||
|
writeList(buf, gr.children(), BbmodelCodec::writeOutlinerNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BbOutlinerNode readOutlinerNode(ByteBuf buf) {
|
||||||
|
return readOutlinerNode(buf, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth-tracked recursive decode. Caps at {@link BbModelParser#MAX_OUTLINER_DEPTH}
|
||||||
|
* <b>during decode</b> because the semantic validator in {@link BbModelParser#validate}
|
||||||
|
* only runs after full construction — a malicious payload with unbounded nesting would
|
||||||
|
* otherwise blow the thread's stack before validation ever got a chance to fire.
|
||||||
|
* One GroupRef nesting = ~2 wire bytes, so 16 MB of payload is more than enough to
|
||||||
|
* overflow a typical ~512 KB JVM stack without this cap.
|
||||||
|
*/
|
||||||
|
private static BbOutlinerNode readOutlinerNode(ByteBuf buf, int depth) {
|
||||||
|
if (depth > BbModelParser.MAX_OUTLINER_DEPTH) {
|
||||||
|
throw new DecoderException(
|
||||||
|
"outliner nesting exceeds decode-time depth cap (" + BbModelParser.MAX_OUTLINER_DEPTH + ")");
|
||||||
|
}
|
||||||
|
byte tag = buf.readByte();
|
||||||
|
return switch (tag) {
|
||||||
|
case TAG_ELEMENT -> new BbOutlinerNode.ElementRef(readString(buf));
|
||||||
|
case TAG_GROUP -> {
|
||||||
|
String uuid = readString(buf);
|
||||||
|
int n = VarInt.read(buf);
|
||||||
|
if (n < 0 || n > MAX_LIST_ELEMENTS) {
|
||||||
|
throw new DecoderException("outliner children count out of range: " + n);
|
||||||
|
}
|
||||||
|
List<BbOutlinerNode> children = new ArrayList<>(Math.min(n, 1024));
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
children.add(readOutlinerNode(buf, depth + 1));
|
||||||
|
}
|
||||||
|
yield new BbOutlinerNode.GroupRef(uuid, children);
|
||||||
|
}
|
||||||
|
default -> throw new DecoderException("bad outliner node tag " + tag);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- primitives ----
|
||||||
|
|
||||||
|
private static void writeVec3f(ByteBuf buf, Vector3f v) {
|
||||||
|
Vector3f safe = v != null ? v : new Vector3f();
|
||||||
|
buf.writeFloat(safe.x);
|
||||||
|
buf.writeFloat(safe.y);
|
||||||
|
buf.writeFloat(safe.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector3f readVec3f(ByteBuf buf) {
|
||||||
|
return new Vector3f(buf.readFloat(), buf.readFloat(), buf.readFloat());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeString(ByteBuf buf, String s) {
|
||||||
|
if (s == null) s = "";
|
||||||
|
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length > MAX_STRING_LEN * 4) {
|
||||||
|
throw new DecoderException("string too long on encode: " + bytes.length);
|
||||||
|
}
|
||||||
|
VarInt.write(buf, bytes.length);
|
||||||
|
buf.writeBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readString(ByteBuf buf) {
|
||||||
|
int n = VarInt.read(buf);
|
||||||
|
// UTF-8 string of N chars can take up to 4N bytes; cap the byte length at 4× the char cap.
|
||||||
|
if (n < 0 || n > MAX_STRING_LEN * 4) {
|
||||||
|
throw new DecoderException("string byte length out of range: " + n);
|
||||||
|
}
|
||||||
|
byte[] bytes = new byte[n];
|
||||||
|
buf.readBytes(bytes);
|
||||||
|
String s = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
if (s.length() > MAX_STRING_LEN) {
|
||||||
|
throw new DecoderException("string too long after decode: " + s.length());
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- list helpers ----
|
||||||
|
|
||||||
|
@FunctionalInterface private interface Writer<T> { void write(ByteBuf buf, T t); }
|
||||||
|
@FunctionalInterface private interface Reader<T> { T read(ByteBuf buf); }
|
||||||
|
|
||||||
|
private static <T> void writeList(ByteBuf buf, List<T> list, Writer<T> w) {
|
||||||
|
List<T> safe = list != null ? list : List.of();
|
||||||
|
if (safe.size() > MAX_LIST_ELEMENTS) {
|
||||||
|
throw new DecoderException("list too large: " + safe.size());
|
||||||
|
}
|
||||||
|
VarInt.write(buf, safe.size());
|
||||||
|
for (T t : safe) w.write(buf, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> readList(ByteBuf buf, Reader<T> r) {
|
||||||
|
int n = VarInt.read(buf);
|
||||||
|
if (n < 0 || n > MAX_LIST_ELEMENTS) {
|
||||||
|
throw new DecoderException("list length out of range: " + n);
|
||||||
|
}
|
||||||
|
List<T> out = new ArrayList<>(Math.min(n, 1024));
|
||||||
|
for (int i = 0; i < n; i++) out.add(r.read(buf));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.razz.dfashion.bbmodel.BbGroup;
|
||||||
|
import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the top-level bone set from a parsed bbmodel, and map bones to wardrobe
|
||||||
|
* categories for filtering + auto-equip.
|
||||||
|
*
|
||||||
|
* <p>Per the "bone parenting" convention: the top-level {@code GroupRef}s in
|
||||||
|
* {@link Bbmodel#outliner()} are the cosmetic's attachment bones — their names match
|
||||||
|
* player-model bones like {@code Head}, {@code Body}, {@code LeftArm}, etc. We resolve
|
||||||
|
* each top-level group ref to its {@link BbGroup}, take the group's name, and normalize
|
||||||
|
* to a canonical key. The bone set is stored on the cosmetic library entry and drives:
|
||||||
|
* <ol>
|
||||||
|
* <li>wardrobe filtering — a cosmetic with {@code Head} shows on hat / head tabs,</li>
|
||||||
|
* <li>auto-equip — {@link #primaryCategory} picks the default slot when the user
|
||||||
|
* clicks equip without choosing a category.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class BoneExtraction {
|
||||||
|
|
||||||
|
private BoneExtraction() {}
|
||||||
|
|
||||||
|
/** Canonical player-bone keys. Matches vanilla {@code HumanoidModel} part names. */
|
||||||
|
public static final String HEAD = "Head";
|
||||||
|
public static final String BODY = "Body";
|
||||||
|
public static final String LEFT_ARM = "LeftArm";
|
||||||
|
public static final String RIGHT_ARM = "RightArm";
|
||||||
|
public static final String LEFT_LEG = "LeftLeg";
|
||||||
|
public static final String RIGHT_LEG = "RightLeg";
|
||||||
|
/** Unrecognized bbmodel group name — cosmetic is still renderable but filters won't match it. */
|
||||||
|
public static final String OTHER = "Other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk {@code bbmodel.outliner()} and return canonical bone names ordered by how
|
||||||
|
* much geometry (recursive element count) each bone contains — most first. Ties
|
||||||
|
* break by the priority order body → head → arms → legs for determinism.
|
||||||
|
*
|
||||||
|
* <p>This lets auto-equip pick the <b>dominant</b> bone as the cosmetic's primary
|
||||||
|
* category: a hat with 10 cubes on {@code Head} and 1 stray on {@code Body} still
|
||||||
|
* equips as a hat; a full outfit with 20 cubes on {@code Body} and 6 each on arms/
|
||||||
|
* legs still equips as torso. The author doesn't need a slot field.
|
||||||
|
*
|
||||||
|
* <p>{@link #OTHER} bones contribute no count and aren't stored — they'd never
|
||||||
|
* resolve to a category anyway.
|
||||||
|
*/
|
||||||
|
public static List<String> fromBbmodel(Bbmodel bbmodel) {
|
||||||
|
Map<String, BbGroup> groupsByUuid = new HashMap<>(bbmodel.groups().size());
|
||||||
|
for (BbGroup g : bbmodel.groups()) {
|
||||||
|
groupsByUuid.put(g.uuid(), g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate canonical-bone → recursive element count. Top-level groups with
|
||||||
|
// the same canonical name (e.g. two "arm_l" typos) merge their counts. Empty
|
||||||
|
// bones (rigged groups with no geometry) are discarded — common authoring
|
||||||
|
// pattern is to rig all six player bones and drop cubes into just one.
|
||||||
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
|
for (BbOutlinerNode node : bbmodel.outliner()) {
|
||||||
|
if (!(node instanceof BbOutlinerNode.GroupRef gr)) continue;
|
||||||
|
BbGroup g = groupsByUuid.get(gr.uuid());
|
||||||
|
if (g == null) continue;
|
||||||
|
String bone = canonical(g.name());
|
||||||
|
if (OTHER.equals(bone)) continue;
|
||||||
|
int n = countElementsRecursive(gr);
|
||||||
|
if (n <= 0) continue;
|
||||||
|
counts.merge(bone, n, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map.Entry<String, Integer>> sorted = new ArrayList<>(counts.entrySet());
|
||||||
|
sorted.sort((a, b) -> {
|
||||||
|
int byCount = Integer.compare(b.getValue(), a.getValue()); // count desc
|
||||||
|
if (byCount != 0) return byCount;
|
||||||
|
return Integer.compare(priority(a.getKey()), priority(b.getKey())); // priority asc
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> result = new ArrayList<>(sorted.size());
|
||||||
|
for (Map.Entry<String, Integer> e : sorted) result.add(e.getKey());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursive element (cube) count under a group, following nested groups. */
|
||||||
|
private static int countElementsRecursive(BbOutlinerNode.GroupRef groupRef) {
|
||||||
|
int count = 0;
|
||||||
|
for (BbOutlinerNode child : groupRef.children()) {
|
||||||
|
if (child instanceof BbOutlinerNode.ElementRef) {
|
||||||
|
count++;
|
||||||
|
} else if (child instanceof BbOutlinerNode.GroupRef cgr) {
|
||||||
|
count += countElementsRecursive(cgr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tie-break priority for equal geometry counts (lower index wins). */
|
||||||
|
private static int priority(String bone) {
|
||||||
|
return switch (bone) {
|
||||||
|
case BODY -> 0;
|
||||||
|
case HEAD -> 1;
|
||||||
|
case LEFT_ARM -> 2;
|
||||||
|
case RIGHT_ARM -> 3;
|
||||||
|
case LEFT_LEG -> 4;
|
||||||
|
case RIGHT_LEG -> 5;
|
||||||
|
default -> Integer.MAX_VALUE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive name normalization with common aliases ({@code torso} → {@code Body},
|
||||||
|
* {@code arml} → {@code LeftArm}, etc.). Returns {@link #OTHER} for anything we don't
|
||||||
|
* recognize — the cosmetic still bakes and renders, it just won't appear in bone-filtered
|
||||||
|
* views.
|
||||||
|
*/
|
||||||
|
public static String canonical(String name) {
|
||||||
|
if (name == null) return OTHER;
|
||||||
|
String n = name.trim().toLowerCase().replace("_", "").replace(" ", "").replace("-", "");
|
||||||
|
return switch (n) {
|
||||||
|
case "head" -> HEAD;
|
||||||
|
case "body", "torso", "chest" -> BODY;
|
||||||
|
case "leftarm", "arml", "armleft", "larm" -> LEFT_ARM;
|
||||||
|
case "rightarm", "armr", "armright", "rarm" -> RIGHT_ARM;
|
||||||
|
case "leftleg", "legl", "legleft", "lleg" -> LEFT_LEG;
|
||||||
|
case "rightleg", "legr", "legright", "rleg" -> RIGHT_LEG;
|
||||||
|
default -> OTHER;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which wardrobe categories should show a cosmetic with these bones? Head bone →
|
||||||
|
* both {@code hat} and {@code head}; body → {@code torso} and {@code wings}; arm →
|
||||||
|
* {@code arms} and {@code wrist}; leg → {@code legs} and {@code feet}. Cosmetics with
|
||||||
|
* only {@link #OTHER} bones fall into no category and won't appear in filtered views.
|
||||||
|
*/
|
||||||
|
public static Set<String> categoriesFor(List<String> bones) {
|
||||||
|
Set<String> cats = new HashSet<>();
|
||||||
|
if (bones == null) return cats;
|
||||||
|
for (String bone : bones) {
|
||||||
|
switch (bone) {
|
||||||
|
case HEAD -> {
|
||||||
|
cats.add("hat");
|
||||||
|
cats.add("head");
|
||||||
|
}
|
||||||
|
case BODY -> {
|
||||||
|
cats.add("torso");
|
||||||
|
cats.add("wings");
|
||||||
|
}
|
||||||
|
case LEFT_ARM, RIGHT_ARM -> {
|
||||||
|
cats.add("arms");
|
||||||
|
cats.add("wrist");
|
||||||
|
}
|
||||||
|
case LEFT_LEG, RIGHT_LEG -> {
|
||||||
|
cats.add("legs");
|
||||||
|
cats.add("feet");
|
||||||
|
}
|
||||||
|
default -> { /* OTHER — no category */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the default equip category for a cosmetic with these bones. {@link #fromBbmodel}
|
||||||
|
* orders bones by geometry count (most first), so the first bone is the cosmetic's
|
||||||
|
* dominant attachment — that's the slot it lands in.
|
||||||
|
*
|
||||||
|
* <p>Returns {@code null} if no canonical bone is present; the caller should fall
|
||||||
|
* back to a sentinel category (e.g. {@code "particle"}) so the cosmetic remains
|
||||||
|
* equippable.
|
||||||
|
*/
|
||||||
|
public static String primaryCategory(List<String> bones) {
|
||||||
|
if (bones == null || bones.isEmpty()) return null;
|
||||||
|
return switch (bones.get(0)) {
|
||||||
|
case BODY -> "torso";
|
||||||
|
case HEAD -> "hat";
|
||||||
|
case LEFT_ARM, RIGHT_ARM -> "arms";
|
||||||
|
case LEFT_LEG, RIGHT_LEG -> "legs";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's personal shared-cosmetic library — a bounded list of {@link CosmeticLibraryEntry}s.
|
||||||
|
*
|
||||||
|
* <p>Persisted on the player via an attachment and synced only to the owning client (like
|
||||||
|
* {@code SkinLibrary}). Other players never receive anyone else's library. Each entry's
|
||||||
|
* {@code hash} resolves to a {@code .dfcos} blob in {@code SharedCosmeticCache}.
|
||||||
|
*/
|
||||||
|
public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
|
||||||
|
|
||||||
|
/** Per-player cap. Cosmetics have multiple slots and benefit from more variants than skins. */
|
||||||
|
public static final int MAX_ENTRIES = 15;
|
||||||
|
|
||||||
|
public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of());
|
||||||
|
|
||||||
|
public static final Codec<CosmeticLibrary> CODEC =
|
||||||
|
CosmeticLibraryEntry.CODEC.listOf().xmap(CosmeticLibrary::new, CosmeticLibrary::entries);
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, CosmeticLibrary> STREAM_CODEC =
|
||||||
|
CosmeticLibraryEntry.STREAM_CODEC
|
||||||
|
.apply(ByteBufCodecs.list())
|
||||||
|
.map(CosmeticLibrary::new, CosmeticLibrary::entries);
|
||||||
|
|
||||||
|
public boolean contains(String hash) {
|
||||||
|
for (CosmeticLibraryEntry e : entries) {
|
||||||
|
if (e.hash().equals(hash)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFull() {
|
||||||
|
return entries.size() >= MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CosmeticLibrary add(CosmeticLibraryEntry entry) {
|
||||||
|
List<CosmeticLibraryEntry> out = new ArrayList<>(entries.size() + 1);
|
||||||
|
out.addAll(entries);
|
||||||
|
out.add(entry);
|
||||||
|
return new CosmeticLibrary(List.copyOf(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CosmeticLibrary remove(String hash) {
|
||||||
|
List<CosmeticLibraryEntry> out = new ArrayList<>(entries.size());
|
||||||
|
for (CosmeticLibraryEntry e : entries) {
|
||||||
|
if (!e.hash().equals(hash)) out.add(e);
|
||||||
|
}
|
||||||
|
return new CosmeticLibrary(List.copyOf(out));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a player's personal shared-cosmetic library. {@code hash} points at a
|
||||||
|
* {@code .dfcos} blob the server stores under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
|
||||||
|
* {@code displayName} is a user-visible label; {@code uploadedAt} is epoch millis so the
|
||||||
|
* wardrobe can sort without touching the filesystem. {@code bones} is the canonical set
|
||||||
|
* of player bones the cosmetic parents to — drives wardrobe filtering and auto-equip
|
||||||
|
* category, extracted from the bbmodel at upload finalization via {@link BoneExtraction}.
|
||||||
|
*/
|
||||||
|
public record CosmeticLibraryEntry(
|
||||||
|
String hash,
|
||||||
|
String displayName,
|
||||||
|
long uploadedAt,
|
||||||
|
List<String> bones
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Cap on bone list size. Real cosmetics declare ≤ 6 top-level bones; 16 is slack. */
|
||||||
|
public static final int MAX_BONES = 16;
|
||||||
|
/** Cap on a single bone key's length. Canonical keys are ≤ 8 chars; 32 is slack. */
|
||||||
|
public static final int MAX_BONE_LEN = 32;
|
||||||
|
|
||||||
|
public CosmeticLibraryEntry {
|
||||||
|
bones = bones == null ? List.of() : List.copyOf(bones);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Codec<CosmeticLibraryEntry> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||||
|
Codec.STRING.fieldOf("hash").forGetter(CosmeticLibraryEntry::hash),
|
||||||
|
Codec.STRING.optionalFieldOf("name", "").forGetter(CosmeticLibraryEntry::displayName),
|
||||||
|
Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(CosmeticLibraryEntry::uploadedAt),
|
||||||
|
Codec.STRING.listOf().optionalFieldOf("bones", List.of()).forGetter(CosmeticLibraryEntry::bones)
|
||||||
|
).apply(inst, CosmeticLibraryEntry::new));
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, CosmeticLibraryEntry> STREAM_CODEC = StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), CosmeticLibraryEntry::hash,
|
||||||
|
ByteBufCodecs.stringUtf8(128), CosmeticLibraryEntry::displayName,
|
||||||
|
ByteBufCodecs.VAR_LONG, CosmeticLibraryEntry::uploadedAt,
|
||||||
|
ByteBufCodecs.stringUtf8(MAX_BONE_LEN).apply(ByteBufCodecs.list(MAX_BONES)),
|
||||||
|
CosmeticLibraryEntry::bones,
|
||||||
|
CosmeticLibraryEntry::new
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
||||||
|
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.api.distmarker.Dist;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
|
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||||
|
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||||
|
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires up the shared-cosmetic payloads.
|
||||||
|
*
|
||||||
|
* <p>Server side is authoritative on everything: hash validity, library membership,
|
||||||
|
* content decoding, and the final content hash. Clients never supply a hash on upload —
|
||||||
|
* the server computes it from the decoded content. Every inbound path validates the hash
|
||||||
|
* against {@link SharedCosmeticCache#isValidHash} before any filesystem access.
|
||||||
|
*/
|
||||||
|
@EventBusSubscriber(modid = DecoFashion.MODID)
|
||||||
|
public final class CosmeticShareNetwork {
|
||||||
|
|
||||||
|
/** Bumped on wire-breaking changes. Separate from the skin channel's version. */
|
||||||
|
private static final String VERSION = "1";
|
||||||
|
|
||||||
|
private CosmeticShareNetwork() {}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onRegister(RegisterPayloadHandlersEvent event) {
|
||||||
|
PayloadRegistrar registrar = event.registrar(VERSION);
|
||||||
|
|
||||||
|
registrar.playToServer(
|
||||||
|
UploadCosmeticChunk.TYPE, UploadCosmeticChunk.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onUploadChunk
|
||||||
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
RequestCosmetic.TYPE, RequestCosmetic.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onRequestCosmetic
|
||||||
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
DeleteCosmetic.TYPE, DeleteCosmetic.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onDeleteCosmetic
|
||||||
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onAssignShared
|
||||||
|
);
|
||||||
|
registrar.playToClient(
|
||||||
|
CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC,
|
||||||
|
CosmeticShareNetwork::onCosmeticChunk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear any in-flight upload state when a player disconnects so buffers don't leak. */
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||||
|
if (event.getEntity() instanceof ServerPlayer sp) {
|
||||||
|
SharedCosmeticCache.cancelInFlight(sp.getUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-extract bones for every library entry on login. Cheap — a handful of blob
|
||||||
|
* reads + bbmodel decodes, capped at {@link CosmeticLibrary#MAX_ENTRIES} — and
|
||||||
|
* self-healing: any entry persisted under older/buggier bone-extraction logic gets
|
||||||
|
* corrected automatically. If re-extraction fails (blob missing, decode error) the
|
||||||
|
* entry is left untouched.
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
|
||||||
|
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (lib.entries().isEmpty()) return;
|
||||||
|
|
||||||
|
boolean anyChanged = false;
|
||||||
|
List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size());
|
||||||
|
for (CosmeticLibraryEntry entry : lib.entries()) {
|
||||||
|
List<String> extracted = extractBonesFromStoredBlob(server, entry.hash());
|
||||||
|
if (extracted == null) {
|
||||||
|
migrated.add(entry); // read/decode failed — leave untouched
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (extracted.equals(entry.bones())) {
|
||||||
|
migrated.add(entry); // already correct
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
migrated.add(new CosmeticLibraryEntry(
|
||||||
|
entry.hash(), entry.displayName(), entry.uploadedAt(), extracted));
|
||||||
|
anyChanged = true;
|
||||||
|
DecoFashion.LOGGER.info("Cosmetic bones refreshed: player={} hash={} old={} new={}",
|
||||||
|
player.getUUID(), entry.hash(), entry.bones(), extracted);
|
||||||
|
}
|
||||||
|
CosmeticLibrary finalLib = anyChanged ? new CosmeticLibrary(migrated) : lib;
|
||||||
|
if (anyChanged) {
|
||||||
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), finalLib);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second audit: drop any CosmeticRef.Shared in EQUIPPED whose hash isn't in the
|
||||||
|
// current library. Catches zombies from older client/server builds where a delete
|
||||||
|
// removed the library entry but left the equipped slot pointing at the dead hash.
|
||||||
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
Map<String, CosmeticRef> cleaned = null;
|
||||||
|
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||||
|
if (!(e.getValue() instanceof CosmeticRef.Shared s)) continue;
|
||||||
|
if (!finalLib.contains(s.hash())) {
|
||||||
|
if (cleaned == null) cleaned = new HashMap<>(equipped);
|
||||||
|
cleaned.remove(e.getKey());
|
||||||
|
DecoFashion.LOGGER.info("Cosmetic equipped-orphan cleared: player={} slot={} hash={}",
|
||||||
|
player.getUUID(), e.getKey(), s.hash());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned != null) {
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a stored {@code .dfcos} blob, decode its bbmodel section, and extract the
|
||||||
|
* top-level bone set. Returns {@code null} on any read/decode failure so the caller
|
||||||
|
* leaves the entry unchanged.
|
||||||
|
*/
|
||||||
|
private static List<String> extractBonesFromStoredBlob(MinecraftServer server, String hash) {
|
||||||
|
if (!SharedCosmeticCache.has(server, hash)) return null;
|
||||||
|
byte[] blob;
|
||||||
|
try {
|
||||||
|
blob = SharedCosmeticCache.read(server, hash);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic bones backfill: read failed for {}: {}", hash, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||||
|
if (view == null) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic bones backfill: blob corrupt for {}", hash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ByteBuf in = Unpooled.wrappedBuffer(view.bbmodelBinary());
|
||||||
|
try {
|
||||||
|
Bbmodel bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||||
|
return BoneExtraction.fromBbmodel(bbmodel);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic bones backfill: decode failed for {}: {}", hash, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
in.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- server-side handlers ----
|
||||||
|
|
||||||
|
private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
|
||||||
|
SharedCosmeticCache.Finalized finalized = SharedCosmeticCache.acceptChunk(
|
||||||
|
server, player.getUUID(),
|
||||||
|
msg.index(), msg.total(),
|
||||||
|
msg.bbmodelLen(),
|
||||||
|
msg.width(), msg.height(),
|
||||||
|
msg.data()
|
||||||
|
);
|
||||||
|
if (finalized == null) return;
|
||||||
|
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (!lib.contains(finalized.hash())) {
|
||||||
|
int n = lib.entries().size() + 1;
|
||||||
|
CosmeticLibraryEntry entry = new CosmeticLibraryEntry(
|
||||||
|
finalized.hash(),
|
||||||
|
"Cosmetic " + n,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
finalized.bones()
|
||||||
|
);
|
||||||
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.add(entry));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onRequestCosmetic(RequestCosmetic msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic request from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!SharedCosmeticCache.has(server, msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic request from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] blob;
|
||||||
|
try {
|
||||||
|
blob = SharedCosmeticCache.read(server, msg.hash());
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Cosmetic request {}: read failed", msg.hash(), ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||||
|
if (view == null) {
|
||||||
|
DecoFashion.LOGGER.error("Cosmetic request {}: stored blob corrupt", msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire payload (per-chunk) is [bbmodel binary][deflated RGBA], same shape as upload.
|
||||||
|
int bbmodelLen = view.bbmodelBinary().length;
|
||||||
|
int deflatedLen = view.deflatedRgba().length;
|
||||||
|
int payloadLen = bbmodelLen + deflatedLen;
|
||||||
|
int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES;
|
||||||
|
int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize);
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
int off = i * chunkSize;
|
||||||
|
int len = Math.min(chunkSize, payloadLen - off);
|
||||||
|
byte[] slice = new byte[len];
|
||||||
|
// Copy from (bbmodelBinary || deflatedRgba) virtual buffer without concatenating.
|
||||||
|
copySlice(view.bbmodelBinary(), view.deflatedRgba(), off, slice);
|
||||||
|
player.connection.send(new ClientboundCustomPayloadPacket(
|
||||||
|
new CosmeticChunk(
|
||||||
|
msg.hash(),
|
||||||
|
i, total, bbmodelLen,
|
||||||
|
view.width(), view.height(),
|
||||||
|
slice
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
String slot = msg.slot();
|
||||||
|
if (slot == null || slot.isEmpty() || slot.length() > 64) {
|
||||||
|
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
||||||
|
|
||||||
|
// Empty hash = clear this slot.
|
||||||
|
if (msg.hash().isEmpty()) {
|
||||||
|
equipped.remove(slot);
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: invalid hash", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (!lib.contains(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: hash {} not in caller's library",
|
||||||
|
player.getUUID(), msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
equipped.put(slot, new CosmeticRef.Shared(msg.hash()));
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onDeleteCosmetic(DeleteCosmetic msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (!lib.contains(msg.hash())) return;
|
||||||
|
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash()));
|
||||||
|
|
||||||
|
// Also unequip any slots still pointing at this hash — otherwise the render
|
||||||
|
// layer's requestIfMissing re-hydrates the blob from the server and the player
|
||||||
|
// keeps wearing a cosmetic they just removed.
|
||||||
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
Map<String, CosmeticRef> updated = null;
|
||||||
|
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||||
|
if (e.getValue() instanceof CosmeticRef.Shared s && msg.hash().equals(s.hash())) {
|
||||||
|
if (updated == null) updated = new HashMap<>(equipped);
|
||||||
|
updated.remove(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated != null) {
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
DecoFashion.LOGGER.info("Cosmetic delete: player={} hash={} unequipped={}",
|
||||||
|
player.getUUID(), msg.hash(), updated != null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> ClientReceiver.accept(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-only trampoline. Kept separate so the server-side handler routing can be
|
||||||
|
* dist-gated without pulling in any client-only classes on dedicated servers.
|
||||||
|
* {@link com.razz.dfashion.client.ClientSharedCosmeticCache#onChunk} re-validates
|
||||||
|
* every field — this trampoline just delegates.
|
||||||
|
*/
|
||||||
|
private static final class ClientReceiver {
|
||||||
|
static void accept(CosmeticChunk msg) {
|
||||||
|
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
||||||
|
com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk(
|
||||||
|
msg.hash(), msg.index(), msg.total(),
|
||||||
|
msg.bbmodelLen(), msg.width(), msg.height(),
|
||||||
|
msg.data()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */
|
||||||
|
private static void copySlice(byte[] a, byte[] b, int off, byte[] out) {
|
||||||
|
int dst = 0;
|
||||||
|
int len = out.length;
|
||||||
|
if (off < a.length) {
|
||||||
|
int take = Math.min(len, a.length - off);
|
||||||
|
System.arraycopy(a, off, out, dst, take);
|
||||||
|
dst += take;
|
||||||
|
len -= take;
|
||||||
|
off += take;
|
||||||
|
}
|
||||||
|
if (len > 0) {
|
||||||
|
int bOff = off - a.length;
|
||||||
|
System.arraycopy(b, bOff, out, dst, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.skin.SkinCache;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side shared-cosmetic store. Holds content-addressed <b>{@code .dfcos} blobs</b>
|
||||||
|
* under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
|
||||||
|
*
|
||||||
|
* <p>Blob layout (big-endian everywhere):
|
||||||
|
* <pre>
|
||||||
|
* [4 bytes] magic "DFCO"
|
||||||
|
* [1 byte] version (currently 1)
|
||||||
|
* [4 bytes] u32 bbmodel-binary length
|
||||||
|
* [N bytes] bbmodel binary (per {@link BbmodelCodec})
|
||||||
|
* [2 bytes] u16 texture width
|
||||||
|
* [2 bytes] u16 texture height
|
||||||
|
* [M bytes] deflated RGBA pixels
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>The server never parses JSON or PNG on this path. Uploads arrive as already-encoded
|
||||||
|
* bbmodel-binary + already-decoded-and-deflated RGBA from the authoring client. Every
|
||||||
|
* read path validates the magic header before trusting a byte.
|
||||||
|
*/
|
||||||
|
public final class SharedCosmeticCache {
|
||||||
|
|
||||||
|
public static final String FILE_EXT = ".dfcos";
|
||||||
|
public static final byte[] MAGIC = { 'D', 'F', 'C', 'O' };
|
||||||
|
public static final byte VERSION = 1;
|
||||||
|
|
||||||
|
/** magic (4) + version (1) + bbmodelLen (4) + w (2) + h (2) = 13, plus the bbmodel body. */
|
||||||
|
public static final int HEADER_FIXED_SIZE = 13;
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
public static final int MAX_BBMODEL_BIN_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Re-exports texture caps from the skin pipeline — same pixel-budget math applies here. */
|
||||||
|
public static final int MAX_RAW_BYTES = SkinCache.MAX_RAW_BYTES;
|
||||||
|
public static final int MAX_DEFLATED_BYTES = SkinCache.MAX_DEFLATED_BYTES;
|
||||||
|
public static final int MAX_CHUNK_BYTES = SkinCache.MAX_CHUNK_BYTES;
|
||||||
|
public static final int MAX_DIM = SkinCache.MAX_DIM;
|
||||||
|
|
||||||
|
private static final String SUBDIR = "decofashion/cosmetics";
|
||||||
|
|
||||||
|
private static final Map<UUID, Assembly> IN_FLIGHT = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private SharedCosmeticCache() {}
|
||||||
|
|
||||||
|
/** Parsed view of a {@code .dfcos} blob. */
|
||||||
|
public record BlobView(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {}
|
||||||
|
|
||||||
|
/** Result of a successful upload — the canonical hash, dims, the canonicalized
|
||||||
|
* bbmodel binary as stored on disk, and the extracted bone set for wardrobe
|
||||||
|
* filtering + auto-equip category selection. */
|
||||||
|
public record Finalized(
|
||||||
|
String hash,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
byte[] canonicalBbmodelBinary,
|
||||||
|
List<String> bones
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Per-player upload assembly state. Cleared on error, on finalize, or on explicit cancel. */
|
||||||
|
private static final class Assembly {
|
||||||
|
int expectedTotal;
|
||||||
|
int nextIndex;
|
||||||
|
int bbmodelLen;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
ByteArrayOutputStream buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path root(MinecraftServer server) {
|
||||||
|
return server.getServerDirectory().resolve(SUBDIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same 64-char lowercase-hex rule as skins — rejects {@code ".."}, slashes, null bytes. */
|
||||||
|
public static boolean isValidHash(String hash) {
|
||||||
|
if (hash == null || hash.length() != 64) return false;
|
||||||
|
for (int i = 0; i < 64; i++) {
|
||||||
|
char c = hash.charAt(i);
|
||||||
|
boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path fileFor(MinecraftServer server, String hash) {
|
||||||
|
return root(server).resolve(hash + FILE_EXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean has(MinecraftServer server, String hash) {
|
||||||
|
return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] read(MinecraftServer server, String hash) throws IOException {
|
||||||
|
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||||
|
return Files.readAllBytes(fileFor(server, hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate magic + parse header off a blob. Returns a {@link BlobView} on success,
|
||||||
|
* {@code null} if the blob is short, has bad magic, or claims lengths that overflow
|
||||||
|
* the buffer. No trust is extended to the body sections until this passes.
|
||||||
|
*/
|
||||||
|
public static BlobView readBlob(byte[] blob) {
|
||||||
|
if (blob == null || blob.length < HEADER_FIXED_SIZE) return null;
|
||||||
|
for (int i = 0; i < MAGIC.length; i++) {
|
||||||
|
if (blob[i] != MAGIC[i]) return null;
|
||||||
|
}
|
||||||
|
if (blob[4] != VERSION) return null;
|
||||||
|
|
||||||
|
int bbmodelLen = readU32(blob, 5);
|
||||||
|
if (bbmodelLen < 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) return null;
|
||||||
|
|
||||||
|
int widthOff = 9 + bbmodelLen;
|
||||||
|
if (widthOff + 4 > blob.length) return null;
|
||||||
|
|
||||||
|
int w = ((blob[widthOff] & 0xFF) << 8) | (blob[widthOff + 1] & 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;
|
||||||
|
|
||||||
|
byte[] bbmodel = new byte[bbmodelLen];
|
||||||
|
System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen);
|
||||||
|
|
||||||
|
int deflatedStart = widthOff + 4;
|
||||||
|
int deflatedLen = blob.length - deflatedStart;
|
||||||
|
if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null;
|
||||||
|
byte[] deflated = new byte[deflatedLen];
|
||||||
|
System.arraycopy(blob, deflatedStart, deflated, 0, deflatedLen);
|
||||||
|
|
||||||
|
return new BlobView(bbmodel, w, h, deflated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble a wire-format blob. Reverse of {@link #readBlob}. */
|
||||||
|
public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {
|
||||||
|
if (bbmodelBinary.length > MAX_BBMODEL_BIN_BYTES) {
|
||||||
|
throw new IllegalArgumentException("bbmodel binary too large: " + bbmodelBinary.length);
|
||||||
|
}
|
||||||
|
if (deflatedRgba.length > MAX_DEFLATED_BYTES) {
|
||||||
|
throw new IllegalArgumentException("deflated RGBA too large: " + deflatedRgba.length);
|
||||||
|
}
|
||||||
|
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
|
||||||
|
throw new IllegalArgumentException("bad dims " + width + "x" + height);
|
||||||
|
}
|
||||||
|
int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length;
|
||||||
|
byte[] out = new byte[size];
|
||||||
|
System.arraycopy(MAGIC, 0, out, 0, 4);
|
||||||
|
out[4] = VERSION;
|
||||||
|
writeU32(out, 5, bbmodelBinary.length);
|
||||||
|
System.arraycopy(bbmodelBinary, 0, out, 9, bbmodelBinary.length);
|
||||||
|
int widthOff = 9 + bbmodelBinary.length;
|
||||||
|
out[widthOff] = (byte) ((width >>> 8) & 0xFF);
|
||||||
|
out[widthOff + 1] = (byte) (width & 0xFF);
|
||||||
|
out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF);
|
||||||
|
out[widthOff + 3] = (byte) (height & 0xFF);
|
||||||
|
System.arraycopy(deflatedRgba, 0, out, widthOff + 4, deflatedRgba.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash over {@code [bbmodelBinary || u16 w || u16 h || raw RGBA]}. Canonical across
|
||||||
|
* deflate levels so identical content dedups regardless of who encoded it. Pass raw
|
||||||
|
* (inflated) pixels, not the deflate stream.
|
||||||
|
*/
|
||||||
|
public static String hashContent(byte[] bbmodelBinary, int width, int height, byte[] rawRgba)
|
||||||
|
throws NoSuchAlgorithmException {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
md.update(bbmodelBinary);
|
||||||
|
md.update((byte) ((width >>> 8) & 0xFF));
|
||||||
|
md.update((byte) (width & 0xFF));
|
||||||
|
md.update((byte) ((height >>> 8) & 0xFF));
|
||||||
|
md.update((byte) (height & 0xFF));
|
||||||
|
md.update(rawRgba);
|
||||||
|
byte[] digest = md.digest();
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a finalized blob to disk. Idempotent: if the hash already exists, no-op.
|
||||||
|
* Caller is responsible for having already content-hashed and validated the inputs.
|
||||||
|
*/
|
||||||
|
public static void writeIfAbsent(MinecraftServer server, String hash, byte[] blob) throws IOException {
|
||||||
|
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||||
|
Files.createDirectories(root(server));
|
||||||
|
Path target = fileFor(server, hash);
|
||||||
|
if (!Files.isRegularFile(target)) {
|
||||||
|
Files.write(target, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept one chunk of an upload from the player. Returns the {@link Finalized} result
|
||||||
|
* once the last chunk lands, or {@code null} while more chunks are expected or if the
|
||||||
|
* upload was rejected. In-flight state is cleared on any error.
|
||||||
|
*
|
||||||
|
* <p>Security posture per field:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code index}/{@code total}: in range, sequential, no skipping.</li>
|
||||||
|
* <li>{@code bbmodelLen}: bounded by {@link #MAX_BBMODEL_BIN_BYTES}; must match chunk 0.</li>
|
||||||
|
* <li>{@code width}/{@code height}: bounded by {@link #MAX_DIM}; must match chunk 0.</li>
|
||||||
|
* <li>{@code data.length}: bounded by {@link #MAX_CHUNK_BYTES} (redundant with the wire
|
||||||
|
* codec cap, kept as defense in depth).</li>
|
||||||
|
* <li>Cumulative accumulated bytes: bounded by
|
||||||
|
* {@link #MAX_BBMODEL_BIN_BYTES} + {@link #MAX_DEFLATED_BYTES}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>On the final chunk the payload is split at {@code bbmodelLen}, the bbmodel half is
|
||||||
|
* decoded via {@link BbmodelCodec#CODEC} (which re-runs the full validator) and then
|
||||||
|
* <b>re-encoded canonically</b> so dedup is stable across authoring clients. The RGBA
|
||||||
|
* half is inflated under a cap set to {@code w*h*4}. The content hash is computed from
|
||||||
|
* the canonical bbmodel + dims + raw RGBA and is authoritative — any hash the client
|
||||||
|
* might separately claim is ignored.
|
||||||
|
*/
|
||||||
|
public static Finalized acceptChunk(
|
||||||
|
MinecraftServer server, UUID playerId,
|
||||||
|
int index, int total, int bbmodelLen,
|
||||||
|
int width, int height, byte[] data
|
||||||
|
) {
|
||||||
|
if (total <= 0 || index < 0 || index >= total) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: invalid chunk {}/{}", playerId, index, total);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (bbmodelLen <= 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad bbmodelLen {}", playerId, bbmodelLen);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad dims {}x{}", playerId, width, height);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data == null || data.length > MAX_CHUNK_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})",
|
||||||
|
playerId, data == null ? -1 : data.length);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assembly asm;
|
||||||
|
if (index == 0) {
|
||||||
|
asm = new Assembly();
|
||||||
|
asm.expectedTotal = total;
|
||||||
|
asm.nextIndex = 0;
|
||||||
|
asm.bbmodelLen = bbmodelLen;
|
||||||
|
asm.width = width;
|
||||||
|
asm.height = height;
|
||||||
|
asm.buffer = new ByteArrayOutputStream(Math.min(
|
||||||
|
total * MAX_CHUNK_BYTES,
|
||||||
|
MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES));
|
||||||
|
IN_FLIGHT.put(playerId, asm);
|
||||||
|
} else {
|
||||||
|
asm = IN_FLIGHT.get(playerId);
|
||||||
|
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
|
||||||
|
|| asm.bbmodelLen != bbmodelLen
|
||||||
|
|| asm.width != width || asm.height != height) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk mismatch at {}/{}",
|
||||||
|
playerId, index, total);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxTotal = MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES;
|
||||||
|
if (asm.buffer.size() + data.length > maxTotal) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: total cap exceeded", playerId);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
asm.buffer.write(data);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Cosmetic upload from {}: buffer write failed", playerId, ex);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
asm.nextIndex = index + 1;
|
||||||
|
|
||||||
|
if (index + 1 < total) return null;
|
||||||
|
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return finalizeUpload(server, playerId, asm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void cancelInFlight(UUID playerId) {
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Finalized finalizeUpload(MinecraftServer server, UUID playerId, Assembly asm) {
|
||||||
|
byte[] payload = asm.buffer.toByteArray();
|
||||||
|
if (payload.length < asm.bbmodelLen) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: payload {} shorter than declared bbmodel {}",
|
||||||
|
playerId, payload.length, asm.bbmodelLen);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bbmodelBinReceived = new byte[asm.bbmodelLen];
|
||||||
|
System.arraycopy(payload, 0, bbmodelBinReceived, 0, asm.bbmodelLen);
|
||||||
|
byte[] deflatedRgba = new byte[payload.length - asm.bbmodelLen];
|
||||||
|
System.arraycopy(payload, asm.bbmodelLen, deflatedRgba, 0, deflatedRgba.length);
|
||||||
|
|
||||||
|
// Decode + validate (BbmodelCodec re-runs BbModelParser.validate internally).
|
||||||
|
Bbmodel bbmodel;
|
||||||
|
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBinReceived);
|
||||||
|
try {
|
||||||
|
bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||||
|
if (in.readableBytes() != 0) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: {} trailing bytes after bbmodel decode",
|
||||||
|
playerId, in.readableBytes());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bbmodel rejected ({})",
|
||||||
|
playerId, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
in.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical re-encode so dedup is stable across authoring clients.
|
||||||
|
byte[] canonicalBbmodelBin;
|
||||||
|
ByteBuf out = Unpooled.buffer(Math.max(64, bbmodelBinReceived.length));
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.encode(out, bbmodel);
|
||||||
|
if (out.readableBytes() > MAX_BBMODEL_BIN_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: canonical bbmodel too large ({})",
|
||||||
|
playerId, out.readableBytes());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
canonicalBbmodelBin = new byte[out.readableBytes()];
|
||||||
|
out.readBytes(canonicalBbmodelBin);
|
||||||
|
} finally {
|
||||||
|
out.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
int expectedRaw;
|
||||||
|
try {
|
||||||
|
expectedRaw = SkinCache.rgbaByteCount(asm.width, asm.height);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: {}", playerId, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] rgba;
|
||||||
|
try {
|
||||||
|
rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: inflate failed ({})",
|
||||||
|
playerId, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String hash;
|
||||||
|
try {
|
||||||
|
hash = hashContent(canonicalBbmodelBin, asm.width, asm.height, rgba);
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library-size gate — duplicates are free, new entries respect MAX_ENTRIES.
|
||||||
|
ServerPlayer player = server.getPlayerList().getPlayer(playerId);
|
||||||
|
if (player == null) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: player disconnected before finalize", playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
|
if (!lib.contains(hash) && lib.isFull()) {
|
||||||
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: library full ({}); rejecting {}",
|
||||||
|
playerId, CosmeticLibrary.MAX_ENTRIES, hash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] blob = buildBlob(canonicalBbmodelBin, asm.width, asm.height, deflatedRgba);
|
||||||
|
try {
|
||||||
|
writeIfAbsent(server, hash, blob);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Cosmetic upload from {}: disk write failed for {}",
|
||||||
|
playerId, hash, ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> bones = BoneExtraction.fromBbmodel(bbmodel);
|
||||||
|
|
||||||
|
DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} bbmodel={}B deflated={}B bones={}",
|
||||||
|
playerId, hash, asm.width, asm.height, canonicalBbmodelBin.length, deflatedRgba.length, bones);
|
||||||
|
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */
|
||||||
|
public static Map<String, Long> listCached(MinecraftServer server) {
|
||||||
|
Map<String, Long> out = new HashMap<>();
|
||||||
|
Path dir = root(server);
|
||||||
|
if (!Files.isDirectory(dir)) return out;
|
||||||
|
try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) {
|
||||||
|
for (Path p : stream) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
String hash = name.substring(0, name.length() - FILE_EXT.length());
|
||||||
|
if (!isValidHash(hash)) continue;
|
||||||
|
try {
|
||||||
|
out.put(hash, Files.size(p));
|
||||||
|
} catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("SharedCosmeticCache.listCached failed: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static int readU32(byte[] b, int p) {
|
||||||
|
return ((b[p] & 0xFF) << 24)
|
||||||
|
| ((b[p + 1] & 0xFF) << 16)
|
||||||
|
| ((b[p + 2] & 0xFF) << 8)
|
||||||
|
| (b[p + 3] & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeU32(byte[] b, int p, int v) {
|
||||||
|
b[p] = (byte) ((v >>> 24) & 0xFF);
|
||||||
|
b[p + 1] = (byte) ((v >>> 16) & 0xFF);
|
||||||
|
b[p + 2] = (byte) ((v >>> 8) & 0xFF);
|
||||||
|
b[p + 3] = (byte) (v & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Equip a shared-library cosmetic in {@code slot}. Server validates:
|
||||||
|
* hash shape, hash membership in the caller's own library, and then writes a
|
||||||
|
* {@code CosmeticRef.Shared} into the player's EQUIPPED attachment. An empty hash
|
||||||
|
* unsets the slot.
|
||||||
|
*/
|
||||||
|
public record AssignSharedCosmetic(String slot, String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<AssignSharedCosmetic> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_shared_cosmetic"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, AssignSharedCosmetic> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::slot,
|
||||||
|
ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::hash,
|
||||||
|
AssignSharedCosmetic::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<AssignSharedCosmetic> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client. One chunk of a stored shared cosmetic being streamed down in response
|
||||||
|
* to a {@link RequestCosmetic}. Shape mirrors {@link UploadCosmeticChunk} with an extra
|
||||||
|
* {@code hash} field so the receiving client can route to the right in-flight download.
|
||||||
|
*
|
||||||
|
* <p>Same wire-safety properties as the upload path: no PNG, no JSON. Just
|
||||||
|
* {@code [bbmodel binary][deflated RGBA]} with dims metadata per chunk.
|
||||||
|
*/
|
||||||
|
public record CosmeticChunk(
|
||||||
|
String hash,
|
||||||
|
int index,
|
||||||
|
int total,
|
||||||
|
int bbmodelLen,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
byte[] data
|
||||||
|
) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<CosmeticChunk> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_chunk"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, CosmeticChunk> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), CosmeticChunk::hash,
|
||||||
|
ByteBufCodecs.VAR_INT, CosmeticChunk::index,
|
||||||
|
ByteBufCodecs.VAR_INT, CosmeticChunk::total,
|
||||||
|
ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen,
|
||||||
|
ByteBufCodecs.VAR_INT, CosmeticChunk::width,
|
||||||
|
ByteBufCodecs.VAR_INT, CosmeticChunk::height,
|
||||||
|
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data,
|
||||||
|
CosmeticChunk::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<CosmeticChunk> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Remove {@code hash} from the caller's personal shared-cosmetic library.
|
||||||
|
* Only affects the caller's own library — cannot reach into other players' libraries.
|
||||||
|
* Unknown hashes are silently ignored (idempotent).
|
||||||
|
*/
|
||||||
|
public record DeleteCosmetic(String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<DeleteCosmetic> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_cosmetic"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, DeleteCosmetic> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), DeleteCosmetic::hash,
|
||||||
|
DeleteCosmetic::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<DeleteCosmetic> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Asks the server to stream back the blob for {@code hash}. Server
|
||||||
|
* rejects on invalid or unknown hashes — the hash is validated via
|
||||||
|
* {@code SharedCosmeticCache.isValidHash} before any filesystem access.
|
||||||
|
*/
|
||||||
|
public record RequestCosmetic(String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<RequestCosmetic> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, RequestCosmetic> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), RequestCosmetic::hash,
|
||||||
|
RequestCosmetic::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<RequestCosmetic> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.razz.dfashion.cosmetic.share.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. One chunk of a shared-cosmetic upload. The payload bytes are the
|
||||||
|
* concatenation {@code [bbmodel binary][deflated RGBA]}; the authoring client knows both
|
||||||
|
* halves up-front so {@code bbmodelLen}, {@code width}, and {@code height} are repeated
|
||||||
|
* on every chunk for defense-in-depth mismatch detection on the server.
|
||||||
|
*
|
||||||
|
* <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
|
||||||
|
* RGBA has already been decoded by {@code SafePngReader} and deflated locally.
|
||||||
|
*/
|
||||||
|
public record UploadCosmeticChunk(
|
||||||
|
int index,
|
||||||
|
int total,
|
||||||
|
int bbmodelLen,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
byte[] data
|
||||||
|
) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<UploadCosmeticChunk> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "upload_cosmetic_chunk"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, UploadCosmeticChunk> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::index,
|
||||||
|
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::total,
|
||||||
|
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen,
|
||||||
|
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width,
|
||||||
|
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height,
|
||||||
|
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data,
|
||||||
|
UploadCosmeticChunk::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<UploadCosmeticChunk> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
import java.util.zip.DataFormatException;
|
||||||
|
import java.util.zip.Inflater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory-safe, pure-Java PNG decoder restricted to 8-bit color-type-6 (RGBA) images.
|
||||||
|
*
|
||||||
|
* <p>Verifies the PNG magic, parses IHDR, enforces a hard dimension cap, whitelists only
|
||||||
|
* {@code IHDR/IDAT/IEND} chunks, checks every CRC-32, rejects any trailing bytes past IEND,
|
||||||
|
* inflates the IDAT stream with a cumulative output cap equal to the declared pixel budget,
|
||||||
|
* and applies the standard PNG unfilter (None/Sub/Up/Average/Paeth).
|
||||||
|
*
|
||||||
|
* <p>This is the only PNG parser on the sync path — called on the uploading client to turn
|
||||||
|
* a user-supplied {@code .png} into raw RGBA pixels that then travel over the wire. STB
|
||||||
|
* (via {@code NativeImage.read}) is never invoked with untrusted bytes. The rejected-chunk
|
||||||
|
* whitelist, CRC check, and trailing-byte rule together eliminate pdvzip-style polyglot
|
||||||
|
* PNG/ZIP/JAR payloads: any byte that isn't part of a valid {@code IHDR/IDAT/IEND} chunk
|
||||||
|
* triggers a {@link BadPngException}.
|
||||||
|
*
|
||||||
|
* <p>Zero dependencies beyond {@code java.util.zip}.
|
||||||
|
*/
|
||||||
|
public final class SafePngReader {
|
||||||
|
|
||||||
|
public static final int MAX_DIM = 4096;
|
||||||
|
|
||||||
|
/** 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 byte[] PNG_SIGNATURE =
|
||||||
|
{ (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||||
|
|
||||||
|
private static final int TYPE_IHDR = 0x49484452;
|
||||||
|
private static final int TYPE_IDAT = 0x49444154;
|
||||||
|
private static final int TYPE_IEND = 0x49454E44;
|
||||||
|
private static final int TYPE_PLTE = 0x504C5445;
|
||||||
|
private static final int TYPE_tRNS = 0x74524E53;
|
||||||
|
|
||||||
|
private static final int COLOR_GRAYSCALE = 0;
|
||||||
|
private static final int COLOR_RGB = 2;
|
||||||
|
private static final int COLOR_PALETTE = 3;
|
||||||
|
private static final int COLOR_GRAYSCALE_ALPHA = 4;
|
||||||
|
private static final int COLOR_RGBA = 6;
|
||||||
|
|
||||||
|
public static final class Image {
|
||||||
|
public final int width;
|
||||||
|
public final int height;
|
||||||
|
/** Tightly packed RGBA, length == width * height * 4. */
|
||||||
|
public final byte[] rgba;
|
||||||
|
|
||||||
|
public Image(int width, int height, byte[] rgba) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.rgba = rgba;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class BadPngException extends IOException {
|
||||||
|
public BadPngException(String message) { super(message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private SafePngReader() {}
|
||||||
|
|
||||||
|
public static Image decode(byte[] src) throws IOException {
|
||||||
|
if (src == null || src.length < PNG_SIGNATURE.length) {
|
||||||
|
throw new BadPngException("too short");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < PNG_SIGNATURE.length; i++) {
|
||||||
|
if (src[i] != PNG_SIGNATURE[i]) throw new BadPngException("bad PNG signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = -1, height = -1, bitDepth = 0, colorType = -1;
|
||||||
|
byte[] palette = null; // PLTE: length is multiple of 3, up to 768 bytes (256 × RGB)
|
||||||
|
byte[] trnsAlpha = null; // tRNS: up to paletteEntries bytes (1 alpha per index)
|
||||||
|
boolean sawIHDR = false, sawIEND = false, sawIDAT = false;
|
||||||
|
ByteArrayOutputStream idat = new ByteArrayOutputStream();
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
|
||||||
|
int p = PNG_SIGNATURE.length;
|
||||||
|
while (p < src.length) {
|
||||||
|
if (p + 8 > src.length) throw new BadPngException("truncated chunk header");
|
||||||
|
|
||||||
|
int len = readU32(src, p);
|
||||||
|
int type = readU32(src, p + 4);
|
||||||
|
if (len < 0 || len > MAX_CHUNK_BYTES) {
|
||||||
|
throw new BadPngException("chunk length out of range: " + (len & 0xFFFFFFFFL));
|
||||||
|
}
|
||||||
|
int dataStart = p + 8;
|
||||||
|
int dataEnd = dataStart + len;
|
||||||
|
int crcEnd = dataEnd + 4;
|
||||||
|
if (crcEnd > src.length) throw new BadPngException("truncated chunk body");
|
||||||
|
|
||||||
|
crc.reset();
|
||||||
|
crc.update(src, p + 4, 4 + len);
|
||||||
|
int actual = (int) crc.getValue();
|
||||||
|
int declared = readU32(src, dataEnd);
|
||||||
|
if (actual != declared) {
|
||||||
|
throw new BadPngException("bad CRC on chunk " + chunkName(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawIEND) throw new BadPngException("trailing chunk after IEND");
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TYPE_IHDR: {
|
||||||
|
if (sawIHDR) throw new BadPngException("duplicate IHDR");
|
||||||
|
if (len != 13) throw new BadPngException("bad IHDR length");
|
||||||
|
width = readU32(src, dataStart);
|
||||||
|
height = readU32(src, dataStart + 4);
|
||||||
|
bitDepth = src[dataStart + 8] & 0xFF;
|
||||||
|
colorType = src[dataStart + 9] & 0xFF;
|
||||||
|
int compression = src[dataStart + 10] & 0xFF;
|
||||||
|
int filter = src[dataStart + 11] & 0xFF;
|
||||||
|
int interlace = src[dataStart + 12] & 0xFF;
|
||||||
|
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
|
||||||
|
throw new BadPngException("bad dimensions " + width + "x" + height);
|
||||||
|
}
|
||||||
|
// Accept the three color types common in authored skins/cosmetics:
|
||||||
|
// - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255
|
||||||
|
// - Type 3 (palette/indexed) depths 1/2/4/8 — PLTE + optional tRNS
|
||||||
|
// - Type 6 (RGBA) depths 8 / 16 — native
|
||||||
|
// Types 0 (grayscale) and 4 (grayscale+alpha) are rejected — rare for
|
||||||
|
// textures, and supporting them would add parsing for edge cases no
|
||||||
|
// skin editor actually produces. 16-bit channels are downconverted per
|
||||||
|
// row during unfilter by keeping the high byte of each 16-bit sample.
|
||||||
|
if (!isSupportedDepthForColor(colorType, bitDepth)) {
|
||||||
|
throw new BadPngException(
|
||||||
|
"unsupported PNG format: color type " + colorType
|
||||||
|
+ " at " + bitDepth + "-bit depth");
|
||||||
|
}
|
||||||
|
if (compression != 0) throw new BadPngException("bad compression method");
|
||||||
|
if (filter != 0) throw new BadPngException("bad filter method");
|
||||||
|
if (interlace != 0) throw new BadPngException("interlaced PNGs not supported");
|
||||||
|
sawIHDR = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_PLTE: {
|
||||||
|
if (!sawIHDR) throw new BadPngException("PLTE before IHDR");
|
||||||
|
if (sawIDAT) throw new BadPngException("PLTE after IDAT");
|
||||||
|
if (palette != null) throw new BadPngException("duplicate PLTE");
|
||||||
|
if (len == 0 || len > 768 || (len % 3) != 0) {
|
||||||
|
throw new BadPngException("bad PLTE length: " + len);
|
||||||
|
}
|
||||||
|
palette = new byte[len];
|
||||||
|
System.arraycopy(src, dataStart, palette, 0, len);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_tRNS: {
|
||||||
|
if (!sawIHDR) throw new BadPngException("tRNS before IHDR");
|
||||||
|
if (sawIDAT) throw new BadPngException("tRNS after IDAT");
|
||||||
|
if (colorType != COLOR_PALETTE) {
|
||||||
|
// tRNS is also defined for types 0 and 2 (single transparent value),
|
||||||
|
// but we don't support those color types, so the chunk is nonsense.
|
||||||
|
throw new BadPngException("tRNS only supported on palette images");
|
||||||
|
}
|
||||||
|
if (palette == null) throw new BadPngException("tRNS before PLTE");
|
||||||
|
int paletteEntries = palette.length / 3;
|
||||||
|
if (len > paletteEntries) {
|
||||||
|
throw new BadPngException("tRNS longer than palette");
|
||||||
|
}
|
||||||
|
trnsAlpha = new byte[len];
|
||||||
|
System.arraycopy(src, dataStart, trnsAlpha, 0, len);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_IDAT: {
|
||||||
|
if (!sawIHDR) throw new BadPngException("IDAT before IHDR");
|
||||||
|
if (colorType == COLOR_PALETTE && palette == null) {
|
||||||
|
throw new BadPngException("IDAT before PLTE on palette image");
|
||||||
|
}
|
||||||
|
sawIDAT = true;
|
||||||
|
idat.write(src, dataStart, len);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_IEND: {
|
||||||
|
if (!sawIHDR) throw new BadPngException("IEND before IHDR");
|
||||||
|
if (len != 0) throw new BadPngException("IEND must be empty");
|
||||||
|
sawIEND = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Ancillary chunks (per PNG spec: first letter lowercase, bit 5 of high byte set)
|
||||||
|
// are safe to skip. Their bytes never leave the uploading client — only pixels do —
|
||||||
|
// so polyglot/iCCP/trailing-IDAT payloads can't cross the wire regardless. Reject
|
||||||
|
// only unknown *critical* chunks.
|
||||||
|
if ((type & 0x20000000) == 0) {
|
||||||
|
throw new BadPngException("disallowed critical chunk " + chunkName(type));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
p = crcEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawIHDR) throw new BadPngException("missing IHDR");
|
||||||
|
if (!sawIEND) throw new BadPngException("missing IEND");
|
||||||
|
if (p != src.length) throw new BadPngException("trailing bytes after IEND");
|
||||||
|
|
||||||
|
if (colorType == COLOR_PALETTE && palette == null) {
|
||||||
|
throw new BadPngException("palette image missing PLTE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG filtering is byte-level and uses ceil(bits-per-pixel / 8), min 1, as the
|
||||||
|
// stride for Sub/Paeth/Average. Scanline byte count is ceil(width * bits-per-pixel / 8).
|
||||||
|
int filterBpp = filterBpp(colorType, bitDepth);
|
||||||
|
long rowBytes = scanlineBytes(width, colorType, bitDepth);
|
||||||
|
|
||||||
|
// width/height are both validated to MAX_DIM above; 64-bit math keeps a future
|
||||||
|
// MAX_DIM bump from silently overflowing int into a negative allocation.
|
||||||
|
long rawLenL = (long) height * (1L + rowBytes);
|
||||||
|
if (rawLenL <= 0 || rawLenL > MAX_RAW_SCANLINE_BYTES) {
|
||||||
|
throw new BadPngException("raw scanline budget out of range: " + rawLenL);
|
||||||
|
}
|
||||||
|
int rawLen = (int) rawLenL;
|
||||||
|
|
||||||
|
byte[] raw = inflateBounded(idat.toByteArray(), rawLen);
|
||||||
|
byte[] rgba = unfilter(raw, width, height,
|
||||||
|
filterBpp, (int) rowBytes,
|
||||||
|
colorType, bitDepth, palette, trnsAlpha);
|
||||||
|
return new Image(width, height, rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSupportedDepthForColor(int colorType, int bitDepth) {
|
||||||
|
return switch (colorType) {
|
||||||
|
case COLOR_GRAYSCALE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4
|
||||||
|
|| bitDepth == 8 || bitDepth == 16;
|
||||||
|
case COLOR_RGB, COLOR_RGBA, COLOR_GRAYSCALE_ALPHA -> bitDepth == 8 || bitDepth == 16;
|
||||||
|
case COLOR_PALETTE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4 || bitDepth == 8;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int filterBpp(int colorType, int bitDepth) {
|
||||||
|
// ceil(bits-per-pixel / 8), min 1. Palette and sub-byte grayscale both use 1.
|
||||||
|
return switch (colorType) {
|
||||||
|
case COLOR_GRAYSCALE -> Math.max(1, bitDepth / 8); // 1 for 1/2/4/8, 2 for 16
|
||||||
|
case COLOR_GRAYSCALE_ALPHA -> 2 * (bitDepth / 8); // 2 or 4
|
||||||
|
case COLOR_RGB -> 3 * (bitDepth / 8); // 3 or 6
|
||||||
|
case COLOR_RGBA -> 4 * (bitDepth / 8); // 4 or 8
|
||||||
|
case COLOR_PALETTE -> 1;
|
||||||
|
default -> throw new IllegalStateException("unreachable");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long scanlineBytes(int width, int colorType, int bitDepth) {
|
||||||
|
return switch (colorType) {
|
||||||
|
case COLOR_GRAYSCALE -> ((long) width * bitDepth + 7L) / 8L;
|
||||||
|
case COLOR_GRAYSCALE_ALPHA -> (long) width * 2L * (bitDepth / 8);
|
||||||
|
case COLOR_RGB -> (long) width * 3L * (bitDepth / 8);
|
||||||
|
case COLOR_RGBA -> (long) width * 4L * (bitDepth / 8);
|
||||||
|
case COLOR_PALETTE -> ((long) width * bitDepth + 7L) / 8L;
|
||||||
|
default -> throw new IllegalStateException("unreachable");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code MAX_DIM * (1 + MAX_DIM * 8)} — the largest legal {@code rawLen} across both
|
||||||
|
* supported bit depths (8 = 4 bpp, 16 = 8 bpp). */
|
||||||
|
private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_DIM * (1L + (long) MAX_DIM * 8L);
|
||||||
|
|
||||||
|
private static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
|
||||||
|
Inflater inf = new Inflater();
|
||||||
|
try {
|
||||||
|
inf.setInput(compressed);
|
||||||
|
byte[] out = new byte[expected];
|
||||||
|
int written = 0;
|
||||||
|
byte[] scratch = new byte[64 * 1024];
|
||||||
|
while (true) {
|
||||||
|
int n;
|
||||||
|
try {
|
||||||
|
n = inf.inflate(scratch);
|
||||||
|
} catch (DataFormatException ex) {
|
||||||
|
throw new BadPngException("zlib decode error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
if (n == 0) {
|
||||||
|
if (inf.finished()) break;
|
||||||
|
if (inf.needsInput() || inf.needsDictionary()) {
|
||||||
|
throw new BadPngException("zlib stream truncated");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (written + n > expected) {
|
||||||
|
throw new BadPngException("inflated size exceeds pixel budget");
|
||||||
|
}
|
||||||
|
System.arraycopy(scratch, 0, out, written, n);
|
||||||
|
written += n;
|
||||||
|
}
|
||||||
|
if (written != expected) {
|
||||||
|
throw new BadPngException("inflated size mismatch: " + written + " != " + expected);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
inf.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unfilter each scanline and emit 8-bit RGBA. The PNG filter algorithm is purely
|
||||||
|
* byte-level and uses {@code filterBpp} (ceil of bits-per-pixel / 8) as the stride
|
||||||
|
* for Sub/Average/Paeth — it's indifferent to the color interpretation. The per-row
|
||||||
|
* conversion from raw bytes to 8-bit RGBA is where color type, bit depth, palette,
|
||||||
|
* and transparency come in. We do that conversion inline against a reusable row
|
||||||
|
* buffer so the peak allocation is the final RGBA output plus two scanline buffers.
|
||||||
|
*/
|
||||||
|
private static byte[] unfilter(byte[] raw, int width, int height,
|
||||||
|
int filterBpp, int rowBytes,
|
||||||
|
int colorType, int bitDepth,
|
||||||
|
byte[] palette, byte[] trnsAlpha) throws IOException {
|
||||||
|
long outLenL = (long) width * (long) height * 4L;
|
||||||
|
if (outLenL <= 0 || outLenL > (long) MAX_DIM * (long) MAX_DIM * 4L) {
|
||||||
|
throw new BadPngException("unfilter output size out of range: " + outLenL);
|
||||||
|
}
|
||||||
|
byte[] out = new byte[(int) outLenL];
|
||||||
|
byte[] prev = new byte[rowBytes];
|
||||||
|
byte[] curr = new byte[rowBytes];
|
||||||
|
|
||||||
|
int rp = 0;
|
||||||
|
int outPos = 0;
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
int filter = raw[rp++] & 0xFF;
|
||||||
|
System.arraycopy(raw, rp, curr, 0, rowBytes);
|
||||||
|
rp += rowBytes;
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 0:
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
for (int x = filterBpp; x < rowBytes; x++) {
|
||||||
|
curr[x] = (byte) ((curr[x] & 0xFF) + (curr[x - filterBpp] & 0xFF));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
for (int x = 0; x < rowBytes; x++) {
|
||||||
|
curr[x] = (byte) ((curr[x] & 0xFF) + (prev[x] & 0xFF));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
for (int x = 0; x < rowBytes; x++) {
|
||||||
|
int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0;
|
||||||
|
int up = prev[x] & 0xFF;
|
||||||
|
curr[x] = (byte) ((curr[x] & 0xFF) + ((left + up) >>> 1));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
for (int x = 0; x < rowBytes; x++) {
|
||||||
|
int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0;
|
||||||
|
int up = prev[x] & 0xFF;
|
||||||
|
int upLeft = (x >= filterBpp) ? (prev[x - filterBpp] & 0xFF) : 0;
|
||||||
|
curr[x] = (byte) ((curr[x] & 0xFF) + paeth(left, up, upLeft));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new BadPngException("unknown filter " + filter + " at row " + y);
|
||||||
|
}
|
||||||
|
|
||||||
|
outPos = emitRowAsRgba(curr, width, colorType, bitDepth, palette, trnsAlpha, out, outPos);
|
||||||
|
byte[] swap = prev; prev = curr; curr = swap;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one unfiltered scanline {@code curr} into {@code width} RGBA pixels and
|
||||||
|
* write them into {@code out} starting at {@code outPos}. Returns the new {@code outPos}.
|
||||||
|
*/
|
||||||
|
private static int emitRowAsRgba(byte[] curr, int width,
|
||||||
|
int colorType, int bitDepth,
|
||||||
|
byte[] palette, byte[] trnsAlpha,
|
||||||
|
byte[] out, int outPos) throws IOException {
|
||||||
|
switch (colorType) {
|
||||||
|
case COLOR_RGBA -> {
|
||||||
|
if (bitDepth == 8) {
|
||||||
|
System.arraycopy(curr, 0, out, outPos, width * 4);
|
||||||
|
outPos += width * 4;
|
||||||
|
} else {
|
||||||
|
// 16-bit: high byte of each 2-byte big-endian sample.
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case COLOR_RGB -> {
|
||||||
|
if (bitDepth == 8) {
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
out[outPos++] = curr[src++];
|
||||||
|
out[outPos++] = curr[src++];
|
||||||
|
out[outPos++] = curr[src++];
|
||||||
|
out[outPos++] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = curr[src]; src += 2;
|
||||||
|
out[outPos++] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case COLOR_PALETTE -> {
|
||||||
|
int paletteEntries = palette.length / 3;
|
||||||
|
int trnsLen = trnsAlpha != null ? trnsAlpha.length : 0;
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int index = readPackedSample(curr, x, bitDepth);
|
||||||
|
if (index >= paletteEntries) {
|
||||||
|
throw new BadPngException(
|
||||||
|
"palette index " + index + " out of range (" + paletteEntries + ")");
|
||||||
|
}
|
||||||
|
int p = index * 3;
|
||||||
|
out[outPos++] = palette[p];
|
||||||
|
out[outPos++] = palette[p + 1];
|
||||||
|
out[outPos++] = palette[p + 2];
|
||||||
|
out[outPos++] = (index < trnsLen) ? trnsAlpha[index] : (byte) 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case COLOR_GRAYSCALE -> {
|
||||||
|
if (bitDepth == 16) {
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
byte g = curr[src]; src += 2; // high byte of 16-bit sample
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
} else if (bitDepth == 8) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
byte g = curr[x];
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 1/2/4-bit: scale sub-byte value to full 8-bit range via bit replication.
|
||||||
|
// 1-bit → ×0xFF, 2-bit → ×0x55, 4-bit → ×0x11. Equivalent to
|
||||||
|
// ((1 << 8) - 1) / ((1 << bitDepth) - 1) — fills with repeated pattern.
|
||||||
|
int scale = 0xFF / ((1 << bitDepth) - 1);
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
byte g = (byte) (readPackedSample(curr, x, bitDepth) * scale);
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case COLOR_GRAYSCALE_ALPHA -> {
|
||||||
|
if (bitDepth == 8) {
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
byte g = curr[src++];
|
||||||
|
byte a = curr[src++];
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = a;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 16-bit: high byte of each 2-byte sample.
|
||||||
|
for (int x = 0, src = 0; x < width; x++) {
|
||||||
|
byte g = curr[src]; src += 2;
|
||||||
|
byte a = curr[src]; src += 2;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = g;
|
||||||
|
out[outPos++] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new IllegalStateException("unreachable");
|
||||||
|
}
|
||||||
|
return outPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an MSB-first packed sample at pixel {@code x} for bit depths 1/2/4/8.
|
||||||
|
* Used for both palette indices and sub-byte grayscale.
|
||||||
|
*/
|
||||||
|
private static int readPackedSample(byte[] row, int x, int bitDepth) {
|
||||||
|
if (bitDepth == 8) {
|
||||||
|
return row[x] & 0xFF;
|
||||||
|
}
|
||||||
|
int pixelsPerByte = 8 / bitDepth;
|
||||||
|
int byteIndex = x / pixelsPerByte;
|
||||||
|
int withinByte = x % pixelsPerByte;
|
||||||
|
int shift = (pixelsPerByte - 1 - withinByte) * bitDepth;
|
||||||
|
int mask = (1 << bitDepth) - 1;
|
||||||
|
return (row[byteIndex] >> shift) & mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int paeth(int a, int b, int c) {
|
||||||
|
int p = a + b - c;
|
||||||
|
int pa = Math.abs(p - a);
|
||||||
|
int pb = Math.abs(p - b);
|
||||||
|
int pc = Math.abs(p - c);
|
||||||
|
if (pa <= pb && pa <= pc) return a;
|
||||||
|
if (pb <= pc) return b;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readU32(byte[] b, int p) {
|
||||||
|
return ((b[p] & 0xFF) << 24)
|
||||||
|
| ((b[p + 1] & 0xFF) << 16)
|
||||||
|
| ((b[p + 2] & 0xFF) << 8)
|
||||||
|
| (b[p + 3] & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String chunkName(int type) {
|
||||||
|
return new String(new char[] {
|
||||||
|
(char) ((type >> 24) & 0xFF),
|
||||||
|
(char) ((type >> 16) & 0xFF),
|
||||||
|
(char) ((type >> 8) & 0xFF),
|
||||||
|
(char) (type & 0xFF)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.razz.dfashion.skin;
|
|||||||
|
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.neoforge.attachment.AttachmentType;
|
import net.neoforged.neoforge.attachment.AttachmentType;
|
||||||
import net.neoforged.neoforge.registries.DeferredHolder;
|
import net.neoforged.neoforge.registries.DeferredHolder;
|
||||||
import net.neoforged.neoforge.registries.DeferredRegister;
|
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||||
@@ -12,6 +13,7 @@ public final class SkinAttachments {
|
|||||||
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
|
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
|
||||||
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
|
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
|
||||||
|
|
||||||
|
/** Currently-equipped skin — visible to all trackers so everyone can render it. */
|
||||||
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA =
|
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA =
|
||||||
ATTACHMENT_TYPES.register(
|
ATTACHMENT_TYPES.register(
|
||||||
"skin_data",
|
"skin_data",
|
||||||
@@ -22,5 +24,21 @@ public final class SkinAttachments {
|
|||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Personal library of up to 5 uploaded skins. Synced <b>only to the owning player</b>
|
||||||
|
* so other clients can't see what's in someone else's wardrobe. */
|
||||||
|
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinLibrary>> LIBRARY =
|
||||||
|
ATTACHMENT_TYPES.register(
|
||||||
|
"skin_library",
|
||||||
|
() -> AttachmentType.<SkinLibrary>builder(() -> SkinLibrary.EMPTY)
|
||||||
|
.serialize(SkinLibrary.CODEC.fieldOf("library"))
|
||||||
|
.sync(
|
||||||
|
(holder, to) -> holder instanceof ServerPlayer sp
|
||||||
|
&& sp.getUUID().equals(to.getUUID()),
|
||||||
|
SkinLibrary.STREAM_CODEC
|
||||||
|
)
|
||||||
|
.copyOnDeath()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
private SkinAttachments() {}
|
private SkinAttachments() {}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.razz.dfashion.skin;
|
|||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -14,15 +15,44 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.zip.DataFormatException;
|
||||||
|
import java.util.zip.Inflater;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side skin store. Keeps PNG bytes on disk under {@code <serverDir>/decofashion/skins/<hash>.png},
|
* Server-side skin store. Holds <b>pixel blobs</b> on disk under
|
||||||
* and tracks in-flight multi-chunk uploads per player.
|
* {@code <serverDir>/decofashion/skins/<hash>.dfskin} with an 8-byte header
|
||||||
|
* {@code [4-byte magic "DFSK"][u16 width][u16 height]} followed by the deflated RGBA payload.
|
||||||
|
*
|
||||||
|
* <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
|
||||||
|
* chunks, stream-inflates with a cumulative cap to compute the content hash, and writes the
|
||||||
|
* blob. Dedicated servers are therefore immune to PNG CVEs by construction, and the stored
|
||||||
|
* bytes aren't a valid PNG/ZIP/JAR under any rename — there is no container. The
|
||||||
|
* {@code .dfskin} extension has no OS handler, and the magic header lets every read path
|
||||||
|
* reject anything that isn't our exact format.
|
||||||
*/
|
*/
|
||||||
public final class SkinCache {
|
public final class SkinCache {
|
||||||
|
|
||||||
public static final int MAX_BYTES = 32 * 1024 * 1024; // 32 MB
|
/** Hard ceiling on raw RGBA size (4096² × 4 bytes = 64 MB). Per-image cap derives from dims. */
|
||||||
public static final int MAX_CHUNK_BYTES = 512 * 1024; // 512 KB
|
public static final int MAX_RAW_BYTES = 4096 * 4096 * 4;
|
||||||
|
|
||||||
|
/** Per-wire-packet cap: keeps single packets well under the channel MTU. */
|
||||||
|
public static final int MAX_CHUNK_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
/** Total deflated bytes we'll accept for a single upload/download. */
|
||||||
|
public static final int MAX_DEFLATED_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Matches {@link SafePngReader#MAX_DIM}. */
|
||||||
|
public static final int MAX_DIM = SafePngReader.MAX_DIM;
|
||||||
|
|
||||||
|
/** Proprietary file-format extension. No OS or tool has a handler for it. */
|
||||||
|
public static final String FILE_EXT = ".dfskin";
|
||||||
|
|
||||||
|
/** "DFSK" in ASCII — prepended to every blob; every read validates it before trusting bytes. */
|
||||||
|
public static final byte[] MAGIC = { 'D', 'F', 'S', 'K' };
|
||||||
|
|
||||||
|
/** Magic (4) + width u16 (2) + height u16 (2) = 8. */
|
||||||
|
public static final int HEADER_SIZE = 8;
|
||||||
|
|
||||||
private static final String SUBDIR = "decofashion/skins";
|
private static final String SUBDIR = "decofashion/skins";
|
||||||
|
|
||||||
@@ -34,6 +64,8 @@ public final class SkinCache {
|
|||||||
private static final class Assembly {
|
private static final class Assembly {
|
||||||
int expectedTotal;
|
int expectedTotal;
|
||||||
int nextIndex;
|
int nextIndex;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
SkinModel model;
|
SkinModel model;
|
||||||
ByteArrayOutputStream buffer;
|
ByteArrayOutputStream buffer;
|
||||||
}
|
}
|
||||||
@@ -42,26 +74,58 @@ public final class SkinCache {
|
|||||||
return server.getServerDirectory().resolve(SUBDIR);
|
return server.getServerDirectory().resolve(SUBDIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelist check on hashes coming off the wire. Rejects anything that isn't exactly
|
||||||
|
* 64 lowercase hex chars — which means {@code ".."}, slashes, null-bytes, and every
|
||||||
|
* other path-traversal trick can't even reach the filesystem.
|
||||||
|
*/
|
||||||
|
public static boolean isValidHash(String hash) {
|
||||||
|
if (hash == null || hash.length() != 64) return false;
|
||||||
|
for (int i = 0; i < 64; i++) {
|
||||||
|
char c = hash.charAt(i);
|
||||||
|
boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static Path fileFor(MinecraftServer server, String hash) {
|
public static Path fileFor(MinecraftServer server, String hash) {
|
||||||
return root(server).resolve(hash + ".png");
|
return root(server).resolve(hash + FILE_EXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean has(MinecraftServer server, String hash) {
|
public static boolean has(MinecraftServer server, String hash) {
|
||||||
return Files.isRegularFile(fileFor(server, hash));
|
return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read an entire skin blob from disk (header + deflated body). */
|
||||||
public static byte[] read(MinecraftServer server, String hash) throws IOException {
|
public static byte[] read(MinecraftServer server, String hash) throws IOException {
|
||||||
|
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||||
return Files.readAllBytes(fileFor(server, hash));
|
return Files.readAllBytes(fileFor(server, hash));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept one chunk from a player. Returns the finalized {@link SkinData} with the
|
* Validate the magic + parse the {@code (u16 w, u16 h)} header off a blob. Returns
|
||||||
* computed hash once the last chunk lands, or {@code null} if more chunks are expected
|
* {@code [w, h]} on success, or {@code null} if the magic doesn't match or the blob
|
||||||
* (or the upload was rejected — check logs).
|
* is too short. No trust extended to the deflated body until this passes.
|
||||||
|
*/
|
||||||
|
public static int[] readHeader(byte[] blob) {
|
||||||
|
if (blob == null || blob.length < HEADER_SIZE) return null;
|
||||||
|
for (int i = 0; i < MAGIC.length; i++) {
|
||||||
|
if (blob[i] != MAGIC[i]) return null;
|
||||||
|
}
|
||||||
|
int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF);
|
||||||
|
int h = ((blob[6] & 0xFF) << 8) | (blob[7] & 0xFF);
|
||||||
|
return new int[] { w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept one chunk of a deflated-RGBA upload from a player. Returns the finalized
|
||||||
|
* {@link SkinData} once the last chunk lands, or {@code null} if more chunks are expected
|
||||||
|
* or the upload was rejected.
|
||||||
*/
|
*/
|
||||||
public static SkinData acceptChunk(
|
public static SkinData acceptChunk(
|
||||||
MinecraftServer server, UUID playerId,
|
MinecraftServer server, UUID playerId,
|
||||||
int index, int total, SkinModel model, byte[] data
|
int index, int total, int width, int height, 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);
|
||||||
@@ -74,30 +138,35 @@ 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) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: bad dims {}x{}", playerId, width, height);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Assembly asm;
|
Assembly asm;
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
asm = new Assembly();
|
asm = new Assembly();
|
||||||
asm.expectedTotal = total;
|
asm.expectedTotal = total;
|
||||||
asm.nextIndex = 0;
|
asm.nextIndex = 0;
|
||||||
|
asm.width = width;
|
||||||
|
asm.height = height;
|
||||||
asm.model = model;
|
asm.model = model;
|
||||||
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_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
|
||||||
DecoFashion.LOGGER.warn("Skin upload from {}: out-of-order chunk (got {}/{}, expected {}/{})",
|
|| asm.width != width || asm.height != height) {
|
||||||
playerId, index, total,
|
DecoFashion.LOGGER.warn("Skin upload from {}: chunk mismatch at {}/{}", playerId, index, total);
|
||||||
asm == null ? -1 : asm.nextIndex,
|
|
||||||
asm == null ? -1 : asm.expectedTotal);
|
|
||||||
IN_FLIGHT.remove(playerId);
|
IN_FLIGHT.remove(playerId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (asm.buffer.size() + data.length > MAX_BYTES) {
|
if (asm.buffer.size() + data.length > MAX_DEFLATED_BYTES) {
|
||||||
DecoFashion.LOGGER.warn("Skin upload from {}: exceeds size cap ({})", playerId, MAX_BYTES);
|
DecoFashion.LOGGER.warn("Skin upload from {}: exceeds deflated cap", playerId);
|
||||||
IN_FLIGHT.remove(playerId);
|
IN_FLIGHT.remove(playerId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -113,28 +182,59 @@ public final class SkinCache {
|
|||||||
|
|
||||||
IN_FLIGHT.remove(playerId);
|
IN_FLIGHT.remove(playerId);
|
||||||
|
|
||||||
byte[] png = asm.buffer.toByteArray();
|
byte[] deflated = asm.buffer.toByteArray();
|
||||||
|
int expectedRaw;
|
||||||
|
try {
|
||||||
|
expectedRaw = rgbaByteCount(asm.width, asm.height);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: {}", playerId, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] rgba;
|
||||||
|
try {
|
||||||
|
rgba = inflateBounded(deflated, expectedRaw);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: inflate failed ({})", playerId, ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
String hash;
|
String hash;
|
||||||
try {
|
try {
|
||||||
hash = sha256Hex(png);
|
hash = sha256HexOfPixels(asm.width, asm.height, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Library-size gate: if the player already has 5 skins and this hash isn't one of
|
||||||
|
// them, reject before touching disk. Prevents a malicious client from filling storage
|
||||||
|
// by streaming arbitrary distinct skins past the per-player cap.
|
||||||
|
ServerPlayer player = server.getPlayerList().getPlayer(playerId);
|
||||||
|
if (player == null) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: player disconnected before finalize", playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
||||||
|
if (!lib.contains(hash) && lib.isFull()) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: library full ({}); rejecting {}",
|
||||||
|
playerId, SkinLibrary.MAX_ENTRIES, hash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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, png);
|
Files.write(target, buildBlob(asm.width, asm.height, 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={} bytes={} model={}",
|
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} dims={}x{} deflated={} model={}",
|
||||||
playerId, hash, png.length, asm.model);
|
playerId, hash, asm.width, asm.height, deflated.length, asm.model);
|
||||||
return new SkinData(hash, asm.model);
|
return new SkinData(hash, asm.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +247,11 @@ public final class SkinCache {
|
|||||||
Map<String, Long> out = new HashMap<>();
|
Map<String, Long> out = new HashMap<>();
|
||||||
Path dir = root(server);
|
Path dir = root(server);
|
||||||
if (!Files.isDirectory(dir)) return out;
|
if (!Files.isDirectory(dir)) return out;
|
||||||
try (var stream = Files.newDirectoryStream(dir, "*.png")) {
|
try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) {
|
||||||
for (Path p : stream) {
|
for (Path p : stream) {
|
||||||
String name = p.getFileName().toString();
|
String name = p.getFileName().toString();
|
||||||
String hash = name.substring(0, name.length() - 4);
|
String hash = name.substring(0, name.length() - FILE_EXT.length());
|
||||||
|
if (!isValidHash(hash)) continue;
|
||||||
try {
|
try {
|
||||||
out.put(hash, Files.size(p));
|
out.put(hash, Files.size(p));
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
@@ -161,11 +262,84 @@ public final class SkinCache {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sha256Hex(byte[] in) throws NoSuchAlgorithmException {
|
/** Build a wire-format blob: magic + (u16 w, u16 h) + deflated pixels. */
|
||||||
|
public static byte[] buildBlob(int width, int height, byte[] deflated) {
|
||||||
|
byte[] blob = new byte[HEADER_SIZE + deflated.length];
|
||||||
|
System.arraycopy(MAGIC, 0, blob, 0, MAGIC.length);
|
||||||
|
blob[4] = (byte) ((width >>> 8) & 0xFF);
|
||||||
|
blob[5] = (byte) (width & 0xFF);
|
||||||
|
blob[6] = (byte) ((height >>> 8) & 0xFF);
|
||||||
|
blob[7] = (byte) (height & 0xFF);
|
||||||
|
System.arraycopy(deflated, 0, blob, HEADER_SIZE, deflated.length);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute {@code width * height * 4} in 64-bit arithmetic, rejecting overflow or
|
||||||
|
* over-budget sizes. Every pixel-buffer allocation and {@link #inflateBounded} call
|
||||||
|
* should derive its expected size through this helper so a future MAX_DIM bump can't
|
||||||
|
* silently overflow {@code int} and produce a negative allocation.
|
||||||
|
*/
|
||||||
|
public static int rgbaByteCount(int width, int height) {
|
||||||
|
long n = (long) width * (long) height * 4L;
|
||||||
|
if (n <= 0 || n > MAX_RAW_BYTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"rgba size out of range: " + n + " (dims " + width + "x" + height + ")");
|
||||||
|
}
|
||||||
|
return (int) n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
|
||||||
|
if (expected <= 0 || expected > MAX_RAW_BYTES) {
|
||||||
|
throw new IOException("bad expected inflated size: " + expected);
|
||||||
|
}
|
||||||
|
Inflater inf = new Inflater();
|
||||||
|
try {
|
||||||
|
inf.setInput(compressed);
|
||||||
|
byte[] out = new byte[expected];
|
||||||
|
int written = 0;
|
||||||
|
byte[] scratch = new byte[64 * 1024];
|
||||||
|
while (true) {
|
||||||
|
int n;
|
||||||
|
try {
|
||||||
|
n = inf.inflate(scratch);
|
||||||
|
} catch (DataFormatException ex) {
|
||||||
|
throw new IOException("zlib decode error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
if (n == 0) {
|
||||||
|
if (inf.finished()) break;
|
||||||
|
if (inf.needsInput() || inf.needsDictionary()) {
|
||||||
|
throw new IOException("zlib stream truncated");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (written + n > expected) {
|
||||||
|
throw new IOException("inflated size exceeds pixel budget");
|
||||||
|
}
|
||||||
|
System.arraycopy(scratch, 0, out, written, n);
|
||||||
|
written += n;
|
||||||
|
}
|
||||||
|
if (written != expected) {
|
||||||
|
throw new IOException("inflated size mismatch: " + written + " != " + expected);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
inf.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hash is computed over {@code [u16 w][u16 h][raw RGBA bytes]} so dedup is stable across
|
||||||
|
* deflate-level differences between clients. */
|
||||||
|
static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException {
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
byte[] out = md.digest(in);
|
md.update((byte) ((width >>> 8) & 0xFF));
|
||||||
StringBuilder sb = new StringBuilder(out.length * 2);
|
md.update((byte) (width & 0xFF));
|
||||||
for (byte b : out) sb.append(String.format("%02x", b));
|
md.update((byte) ((height >>> 8) & 0xFF));
|
||||||
|
md.update((byte) (height & 0xFF));
|
||||||
|
md.update(rgba);
|
||||||
|
byte[] digest = md.digest();
|
||||||
|
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's personal skin library — a bounded list of {@link SkinLibraryEntry}s.
|
||||||
|
*
|
||||||
|
* <p>Persisted on the player (via an attachment) and synced only to the owning client.
|
||||||
|
* The wardrobe UI renders this list for the local player; other players never receive
|
||||||
|
* anyone else's library. The local {@code ClientSkinCache} still holds a content-addressed
|
||||||
|
* pool of blobs for every skin this client has ever seen, but the wardrobe no longer
|
||||||
|
* treats that pool as "mine" — it renders the server-authoritative library instead.
|
||||||
|
*/
|
||||||
|
public record SkinLibrary(List<SkinLibraryEntry> entries) {
|
||||||
|
|
||||||
|
public static final int MAX_ENTRIES = 5;
|
||||||
|
public static final SkinLibrary EMPTY = new SkinLibrary(List.of());
|
||||||
|
|
||||||
|
public static final Codec<SkinLibrary> CODEC =
|
||||||
|
SkinLibraryEntry.CODEC.listOf().xmap(SkinLibrary::new, SkinLibrary::entries);
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, SkinLibrary> STREAM_CODEC =
|
||||||
|
SkinLibraryEntry.STREAM_CODEC
|
||||||
|
.apply(ByteBufCodecs.list())
|
||||||
|
.map(SkinLibrary::new, SkinLibrary::entries);
|
||||||
|
|
||||||
|
public boolean contains(String hash) {
|
||||||
|
for (SkinLibraryEntry e : entries) {
|
||||||
|
if (e.hash().equals(hash)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFull() {
|
||||||
|
return entries.size() >= MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SkinLibrary add(SkinLibraryEntry entry) {
|
||||||
|
List<SkinLibraryEntry> out = new ArrayList<>(entries.size() + 1);
|
||||||
|
out.addAll(entries);
|
||||||
|
out.add(entry);
|
||||||
|
return new SkinLibrary(List.copyOf(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SkinLibrary remove(String hash) {
|
||||||
|
List<SkinLibraryEntry> out = new ArrayList<>(entries.size());
|
||||||
|
for (SkinLibraryEntry e : entries) {
|
||||||
|
if (!e.hash().equals(hash)) out.add(e);
|
||||||
|
}
|
||||||
|
return new SkinLibrary(List.copyOf(out));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a player's personal skin library. {@code hash} points at a blob the
|
||||||
|
* server stores (or can fetch) under {@code <serverDir>/decofashion/skins/<hash>.bin}.
|
||||||
|
* {@code displayName} is a user-visible label shown in the wardrobe; {@code uploadedAt}
|
||||||
|
* is epoch millis at the time of upload, handy for sort-by-recent without needing to
|
||||||
|
* hit the filesystem.
|
||||||
|
*/
|
||||||
|
public record SkinLibraryEntry(String hash, String displayName, long uploadedAt) {
|
||||||
|
|
||||||
|
public static final Codec<SkinLibraryEntry> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||||
|
Codec.STRING.fieldOf("hash").forGetter(SkinLibraryEntry::hash),
|
||||||
|
Codec.STRING.optionalFieldOf("name", "").forGetter(SkinLibraryEntry::displayName),
|
||||||
|
Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(SkinLibraryEntry::uploadedAt)
|
||||||
|
).apply(inst, SkinLibraryEntry::new));
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, SkinLibraryEntry> STREAM_CODEC = StreamCodec.composite(
|
||||||
|
ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::hash,
|
||||||
|
ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::displayName,
|
||||||
|
ByteBufCodecs.VAR_LONG, SkinLibraryEntry::uploadedAt,
|
||||||
|
SkinLibraryEntry::new
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.razz.dfashion.skin;
|
|||||||
|
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
import com.razz.dfashion.skin.packet.AssignSkin;
|
import com.razz.dfashion.skin.packet.AssignSkin;
|
||||||
|
import com.razz.dfashion.skin.packet.DeleteSkin;
|
||||||
import com.razz.dfashion.skin.packet.RequestSkin;
|
import com.razz.dfashion.skin.packet.RequestSkin;
|
||||||
import com.razz.dfashion.skin.packet.SkinChunk;
|
import com.razz.dfashion.skin.packet.SkinChunk;
|
||||||
import com.razz.dfashion.skin.packet.UploadSkinChunk;
|
import com.razz.dfashion.skin.packet.UploadSkinChunk;
|
||||||
@@ -12,6 +13,7 @@ import net.minecraft.server.level.ServerPlayer;
|
|||||||
import net.neoforged.api.distmarker.Dist;
|
import net.neoforged.api.distmarker.Dist;
|
||||||
import net.neoforged.bus.api.SubscribeEvent;
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
import net.neoforged.fml.common.EventBusSubscriber;
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||||
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||||
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
|
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
|
||||||
@@ -21,7 +23,7 @@ import java.io.IOException;
|
|||||||
@EventBusSubscriber(modid = DecoFashion.MODID)
|
@EventBusSubscriber(modid = DecoFashion.MODID)
|
||||||
public final class SkinNetwork {
|
public final class SkinNetwork {
|
||||||
|
|
||||||
private static final String VERSION = "1";
|
private static final String VERSION = "2";
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
static void onRegister(RegisterPayloadHandlersEvent event) {
|
static void onRegister(RegisterPayloadHandlersEvent event) {
|
||||||
@@ -36,11 +38,43 @@ public final class SkinNetwork {
|
|||||||
registrar.playToServer(
|
registrar.playToServer(
|
||||||
AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin
|
AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin
|
||||||
);
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
DeleteSkin.TYPE, DeleteSkin.STREAM_CODEC, SkinNetwork::onDeleteSkin
|
||||||
|
);
|
||||||
registrar.playToClient(
|
registrar.playToClient(
|
||||||
SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk
|
SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear any in-flight upload assembly when a player disconnects so their reserved
|
||||||
|
* buffer (up to {@link SkinCache#MAX_DEFLATED_BYTES}) doesn't leak until GC. Mirrors
|
||||||
|
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork}'s handler. */
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||||
|
if (event.getEntity() instanceof ServerPlayer sp) {
|
||||||
|
SkinCache.cancelInFlight(sp.getUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onDeleteSkin(DeleteSkin msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
if (!SkinCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("DeleteSkin from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
||||||
|
if (!lib.contains(msg.hash())) return;
|
||||||
|
player.setData(SkinAttachments.LIBRARY.get(), lib.remove(msg.hash()));
|
||||||
|
// If the deleted skin was active, reset to vanilla.
|
||||||
|
SkinData current = player.getData(SkinAttachments.DATA.get());
|
||||||
|
if (current != null && msg.hash().equals(current.hash())) {
|
||||||
|
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
||||||
|
}
|
||||||
|
DecoFashion.LOGGER.info("Skin delete: player={} hash={}", player.getUUID(), msg.hash());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) {
|
private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) {
|
||||||
ctx.enqueueWork(() -> {
|
ctx.enqueueWork(() -> {
|
||||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
@@ -48,9 +82,22 @@ public final class SkinNetwork {
|
|||||||
if (server == null) return;
|
if (server == null) return;
|
||||||
|
|
||||||
SkinData finalized = SkinCache.acceptChunk(
|
SkinData finalized = SkinCache.acceptChunk(
|
||||||
server, player.getUUID(), msg.index(), msg.total(), msg.model(), msg.data()
|
server, player.getUUID(),
|
||||||
|
msg.index(), msg.total(),
|
||||||
|
msg.width(), msg.height(),
|
||||||
|
msg.model(), msg.data()
|
||||||
);
|
);
|
||||||
if (finalized != null) {
|
if (finalized != null) {
|
||||||
|
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
||||||
|
if (!lib.contains(finalized.hash())) {
|
||||||
|
int n = lib.entries().size() + 1;
|
||||||
|
SkinLibraryEntry entry = new SkinLibraryEntry(
|
||||||
|
finalized.hash(),
|
||||||
|
"Skin " + n,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
player.setData(SkinAttachments.LIBRARY.get(), lib.add(entry));
|
||||||
|
}
|
||||||
player.setData(SkinAttachments.DATA.get(), finalized);
|
player.setData(SkinAttachments.DATA.get(), finalized);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -66,6 +113,10 @@ public final class SkinNetwork {
|
|||||||
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!SkinCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("AssignSkin from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!SkinCache.has(server, msg.hash())) {
|
if (!SkinCache.has(server, msg.hash())) {
|
||||||
DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash());
|
DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||||
return;
|
return;
|
||||||
@@ -79,49 +130,65 @@ public final class SkinNetwork {
|
|||||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
MinecraftServer server = player.level().getServer();
|
MinecraftServer server = player.level().getServer();
|
||||||
if (server == null) return;
|
if (server == null) return;
|
||||||
|
if (!SkinCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin request from {}: invalid hash rejected", player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!SkinCache.has(server, msg.hash())) {
|
if (!SkinCache.has(server, msg.hash())) {
|
||||||
DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash());
|
DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] png;
|
byte[] blob;
|
||||||
try {
|
try {
|
||||||
png = SkinCache.read(server, msg.hash());
|
blob = SkinCache.read(server, msg.hash());
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex);
|
DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int[] dims = SkinCache.readHeader(blob);
|
||||||
|
if (dims == null || blob.length <= 4) {
|
||||||
|
DecoFashion.LOGGER.error("Skin request {}: blob missing header", msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int width = dims[0];
|
||||||
|
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, (png.length + chunkSize - 1) / chunkSize);
|
int total = Math.max(1, (bodyLen + chunkSize - 1) / chunkSize);
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
int off = i * chunkSize;
|
int off = 4 + i * chunkSize;
|
||||||
int len = Math.min(chunkSize, png.length - off);
|
int len = Math.min(chunkSize, blob.length - off);
|
||||||
byte[] slice = new byte[len];
|
byte[] slice = new byte[len];
|
||||||
System.arraycopy(png, off, slice, 0, len);
|
System.arraycopy(blob, off, slice, 0, len);
|
||||||
player.connection.send(new ClientboundCustomPayloadPacket(
|
player.connection.send(new ClientboundCustomPayloadPacket(
|
||||||
new SkinChunk(msg.hash(), i, total, slice)));
|
new SkinChunk(msg.hash(), i, total, width, height, slice)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) {
|
private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) {
|
||||||
// Defer client-side handling so we don't pull client classes into common code.
|
|
||||||
ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg));
|
ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small trampoline to keep the client-only receiver out of the common-side packet router;
|
* Small trampoline to keep the client-only receiver out of the common-side packet router;
|
||||||
* the {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class.
|
* {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class.
|
||||||
*/
|
*/
|
||||||
private static final class ClientSkinChunkReceiver {
|
private static final class ClientSkinChunkReceiver {
|
||||||
static void accept(SkinChunk msg) {
|
static void accept(SkinChunk msg) {
|
||||||
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
||||||
|
if (!SkinCache.isValidHash(msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("Received SkinChunk with invalid hash; ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
com.razz.dfashion.client.ClientSkinCache.onChunk(
|
com.razz.dfashion.client.ClientSkinCache.onChunk(
|
||||||
msg.hash(), msg.index(), msg.total(), msg.data()
|
msg.hash(), msg.index(), msg.total(),
|
||||||
|
msg.width(), msg.height(), msg.data()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SkinNetwork() {}
|
private SkinNetwork() {}
|
||||||
}
|
}
|
||||||
@@ -18,10 +18,13 @@ public record AssignSkin(String hash, SkinModel model) implements CustomPacketPa
|
|||||||
public static final Type<AssignSkin> TYPE =
|
public static final Type<AssignSkin> TYPE =
|
||||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin"));
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin"));
|
||||||
|
|
||||||
|
/** SHA-256 hex is exactly 64 chars; empty string (= "clear skin") also fits. */
|
||||||
|
private static final int MAX_HASH_LEN = 64;
|
||||||
|
|
||||||
public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC =
|
public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC =
|
||||||
StreamCodec.composite(
|
StreamCodec.composite(
|
||||||
ByteBufCodecs.STRING_UTF8, AssignSkin::hash,
|
ByteBufCodecs.stringUtf8(MAX_HASH_LEN), AssignSkin::hash,
|
||||||
SkinModel.STREAM_CODEC, AssignSkin::model,
|
SkinModel.STREAM_CODEC, AssignSkin::model,
|
||||||
AssignSkin::new
|
AssignSkin::new
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Remove {@code hash} from the player's personal skin library. If the
|
||||||
|
* hash was the currently-equipped skin, the server also resets {@code SkinData} back to
|
||||||
|
* vanilla. Unknown hashes are silently ignored (idempotent).
|
||||||
|
*/
|
||||||
|
public record DeleteSkin(String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<DeleteSkin> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_skin"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, DeleteSkin> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.stringUtf8(64), DeleteSkin::hash,
|
||||||
|
DeleteSkin::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<DeleteSkin> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public record RequestSkin(String hash) implements CustomPacketPayload {
|
|||||||
|
|
||||||
public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC =
|
public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC =
|
||||||
StreamCodec.composite(
|
StreamCodec.composite(
|
||||||
ByteBufCodecs.STRING_UTF8, RequestSkin::hash,
|
ByteBufCodecs.stringUtf8(64), RequestSkin::hash,
|
||||||
RequestSkin::new
|
RequestSkin::new
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.razz.dfashion.skin.packet;
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.SkinCache;
|
||||||
|
|
||||||
import net.minecraft.network.FriendlyByteBuf;
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
import net.minecraft.network.codec.ByteBufCodecs;
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
@@ -9,9 +10,14 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
|||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server → client. One chunk of the PNG bytes for a requested skin hash.
|
* Server → client. One chunk of a stored skin's <b>deflated RGBA pixels</b>, not PNG bytes.
|
||||||
|
*
|
||||||
|
* <p>Mirrors {@link UploadSkinChunk}'s shape in reverse. Receivers reassemble the deflated
|
||||||
|
* buffer, inflate it under a bounded cumulative output cap (= {@code width*height*4}), and
|
||||||
|
* build a {@code NativeImage} directly via {@code setPixelABGR} — no STB call anywhere in
|
||||||
|
* the download path.
|
||||||
*/
|
*/
|
||||||
public record SkinChunk(String hash, int index, int total, byte[] data)
|
public record SkinChunk(String hash, int index, int total, int width, int height, byte[] data)
|
||||||
implements CustomPacketPayload {
|
implements CustomPacketPayload {
|
||||||
|
|
||||||
public static final Type<SkinChunk> TYPE =
|
public static final Type<SkinChunk> TYPE =
|
||||||
@@ -19,10 +25,12 @@ public record SkinChunk(String hash, int index, int total, byte[] data)
|
|||||||
|
|
||||||
public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC =
|
public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC =
|
||||||
StreamCodec.composite(
|
StreamCodec.composite(
|
||||||
ByteBufCodecs.STRING_UTF8, SkinChunk::hash,
|
ByteBufCodecs.stringUtf8(64), SkinChunk::hash,
|
||||||
ByteBufCodecs.VAR_INT, SkinChunk::index,
|
ByteBufCodecs.VAR_INT, SkinChunk::index,
|
||||||
ByteBufCodecs.VAR_INT, SkinChunk::total,
|
ByteBufCodecs.VAR_INT, SkinChunk::total,
|
||||||
ByteBufCodecs.BYTE_ARRAY, SkinChunk::data,
|
ByteBufCodecs.VAR_INT, SkinChunk::width,
|
||||||
|
ByteBufCodecs.VAR_INT, SkinChunk::height,
|
||||||
|
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES),SkinChunk::data,
|
||||||
SkinChunk::new
|
SkinChunk::new
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -30,4 +38,4 @@ public record SkinChunk(String hash, int index, int total, byte[] data)
|
|||||||
public Type<SkinChunk> type() {
|
public Type<SkinChunk> type() {
|
||||||
return TYPE;
|
return TYPE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.razz.dfashion.skin.packet;
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.SkinCache;
|
||||||
import com.razz.dfashion.skin.SkinModel;
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
|
|
||||||
import net.minecraft.network.FriendlyByteBuf;
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
@@ -10,11 +11,16 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
|||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client → server. One chunk of a skin upload. When {@code index + 1 == total},
|
* Client → server. One chunk of a skin upload as <b>deflated RGBA pixels</b>, not PNG bytes.
|
||||||
* the server finalizes, hashes, writes to cache, and updates the player's SkinData.
|
*
|
||||||
* Chunk data is raw PNG bytes; the server reassembles in order.
|
* <p>The uploading client parses its source PNG locally with {@code SafePngReader} (pure Java,
|
||||||
|
* no STB), extracts raw RGBA, deflates it, and streams the deflated buffer in fixed-size chunks.
|
||||||
|
* {@code w} and {@code h} are repeated on every chunk so reassembly can enforce them before
|
||||||
|
* touching the payload; server validates that subsequent chunks carry the same dimensions as
|
||||||
|
* the first. No PNG container ever crosses the wire — polyglot/iCCP/trailing-IDAT attacks
|
||||||
|
* can't exist when the attack surface is two u16s and a deflate stream.
|
||||||
*/
|
*/
|
||||||
public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data)
|
public record UploadSkinChunk(int index, int total, int width, int height, SkinModel model, byte[] data)
|
||||||
implements CustomPacketPayload {
|
implements CustomPacketPayload {
|
||||||
|
|
||||||
public static final Type<UploadSkinChunk> TYPE =
|
public static final Type<UploadSkinChunk> TYPE =
|
||||||
@@ -22,10 +28,12 @@ public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data
|
|||||||
|
|
||||||
public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC =
|
public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC =
|
||||||
StreamCodec.composite(
|
StreamCodec.composite(
|
||||||
ByteBufCodecs.VAR_INT, UploadSkinChunk::index,
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::index,
|
||||||
ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
|
||||||
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::width,
|
||||||
ByteBufCodecs.BYTE_ARRAY, UploadSkinChunk::data,
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::height,
|
||||||
|
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
|
||||||
|
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data,
|
||||||
UploadSkinChunk::new
|
UploadSkinChunk::new
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,4 +41,4 @@ public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data
|
|||||||
public Type<UploadSkinChunk> type() {
|
public Type<UploadSkinChunk> type() {
|
||||||
return TYPE;
|
return TYPE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.razz.dfashion.security;
|
||||||
|
|
||||||
|
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.razz.dfashion.bbmodel.BbModelParser;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.StringReader;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security corpus + fuzz harness for {@link BbModelParser}. The parser runs only on the
|
||||||
|
* author's local disk, but still handles untrusted JSON (the author could have downloaded
|
||||||
|
* a malicious .bbmodel), so every rejection rule matters.
|
||||||
|
*
|
||||||
|
* <p>Fuzz inputs are fed as UTF-8 byte arrays. Legitimate rejections are any subtype of
|
||||||
|
* {@link JsonParseException} (including {@code BadBbmodelException}). A {@link StackOverflowError},
|
||||||
|
* {@link NullPointerException}, or any non-JsonParseException is a bug Jazzer should surface.
|
||||||
|
*/
|
||||||
|
class BbModelParserSecurityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsMinimalValidModel() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[]," +
|
||||||
|
"\"groups\":[]," +
|
||||||
|
"\"outliner\":[]" +
|
||||||
|
"}";
|
||||||
|
assertDoesNotThrow(() -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOldFormatVersion() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"4.5\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[],\"groups\":[],\"outliner\":[]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsResolutionOverCap() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":" + (BbModelParser.MAX_RESOLUTION + 1) + ",\"height\":64}," +
|
||||||
|
"\"elements\":[],\"groups\":[],\"outliner\":[]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNanFloat() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||||
|
"\"from\":[\"NaN\",0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," +
|
||||||
|
"\"inflate\":0,\"faces\":{}}]," +
|
||||||
|
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||||
|
"}";
|
||||||
|
// GSON parses NaN-as-string into Float.NaN (depending on lenient mode); post-parse
|
||||||
|
// validation rejects non-finite floats. Either way, a JsonParseException is expected.
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInfinityFloat() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||||
|
"\"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0]," +
|
||||||
|
"\"rotation\":[\"Infinity\",0,0]," +
|
||||||
|
"\"inflate\":0,\"faces\":{}}]," +
|
||||||
|
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCoordOutOfRange() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||||
|
"\"from\":[1e9,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," +
|
||||||
|
"\"inflate\":0,\"faces\":{}}]," +
|
||||||
|
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateElementUuid() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[" +
|
||||||
|
" {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," +
|
||||||
|
" \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}," +
|
||||||
|
" {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," +
|
||||||
|
" \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}" +
|
||||||
|
"]," +
|
||||||
|
"\"groups\":[],\"outliner\":[\"dup\"]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOutlinerRefToUnknownElement() {
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[]," +
|
||||||
|
"\"groups\":[]," +
|
||||||
|
"\"outliner\":[\"ghost-uuid\"]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOutlinerOverDepthCap() {
|
||||||
|
// Build a chain of groups deeper than MAX_OUTLINER_DEPTH. Each group is a GroupRef
|
||||||
|
// whose single child is the next GroupRef.
|
||||||
|
int depth = BbModelParser.MAX_OUTLINER_DEPTH + 5;
|
||||||
|
StringBuilder groups = new StringBuilder();
|
||||||
|
StringBuilder outliner = new StringBuilder();
|
||||||
|
for (int i = 0; i < depth; i++) {
|
||||||
|
if (i > 0) groups.append(',');
|
||||||
|
groups.append("{\"uuid\":\"g").append(i).append("\",\"name\":\"g\"," +
|
||||||
|
"\"origin\":[0,0,0],\"rotation\":[0,0,0]}");
|
||||||
|
}
|
||||||
|
// Outliner is a nested object tree referencing g0 → g1 → ... → g(depth-1).
|
||||||
|
for (int i = 0; i < depth; i++) {
|
||||||
|
outliner.append("{\"uuid\":\"g").append(i).append("\",\"children\":[");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < depth; i++) outliner.append("]}");
|
||||||
|
|
||||||
|
String json = "{" +
|
||||||
|
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||||
|
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||||
|
"\"elements\":[]," +
|
||||||
|
"\"groups\":[" + groups + "]," +
|
||||||
|
"\"outliner\":[" + outliner + "]" +
|
||||||
|
"}";
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMalformedJson() {
|
||||||
|
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader("{ not json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEmpty() {
|
||||||
|
assertThrows(Exception.class, () -> BbModelParser.parse(new StringReader("")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fuzz harness ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzz the JSON parser with random UTF-8. Legit rejection = anything JsonParseException-ish.
|
||||||
|
* Other exceptions point at missing defense in the parser.
|
||||||
|
*/
|
||||||
|
@FuzzTest(maxDuration = "30s")
|
||||||
|
void fuzzParse(byte[] input) {
|
||||||
|
try {
|
||||||
|
BbModelParser.parse(new StringReader(new String(input, java.nio.charset.StandardCharsets.UTF_8)));
|
||||||
|
} catch (JsonParseException | IllegalStateException | NumberFormatException expected) {
|
||||||
|
// All legitimate rejection paths from GSON + validator.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.razz.dfashion.security;
|
||||||
|
|
||||||
|
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||||
|
import com.razz.dfashion.bbmodel.BbCube;
|
||||||
|
import com.razz.dfashion.bbmodel.BbGroup;
|
||||||
|
import com.razz.dfashion.bbmodel.BbLocator;
|
||||||
|
import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
||||||
|
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||||
|
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security corpus + fuzz harness for {@link BbmodelCodec}. This codec is the only path
|
||||||
|
* from network bytes to a {@link Bbmodel} on server and on receiving clients, so every
|
||||||
|
* decode-time rejection is load-bearing.
|
||||||
|
*
|
||||||
|
* <p>The fuzz harness feeds random bytes to {@code decode}. The only acceptable outcome
|
||||||
|
* is a successful decode (arbitrarily unlikely from random bytes) or a
|
||||||
|
* {@link RuntimeException} subtype used for rejection ({@code DecoderException},
|
||||||
|
* {@code IndexOutOfBoundsException} from ByteBuf underflow, {@code JsonParseException}
|
||||||
|
* from validator). Other exceptions or errors indicate missing defense.
|
||||||
|
*/
|
||||||
|
class BbmodelCodecSecurityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripEmptyModel() {
|
||||||
|
Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of());
|
||||||
|
Bbmodel decoded = roundTrip(original);
|
||||||
|
assertEquals(64, decoded.resolutionWidth());
|
||||||
|
assertEquals(64, decoded.resolutionHeight());
|
||||||
|
assertTrue(decoded.elements().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripSingleCube() {
|
||||||
|
BbCube cube = new BbCube(
|
||||||
|
"uuid-1", "cube-name",
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||||
|
0f,
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0),
|
||||||
|
Map.of()
|
||||||
|
);
|
||||||
|
Bbmodel original = new Bbmodel(64, 64,
|
||||||
|
List.of(cube), List.of(), List.of(),
|
||||||
|
List.of(new BbOutlinerNode.ElementRef("uuid-1")));
|
||||||
|
Bbmodel decoded = roundTrip(original);
|
||||||
|
assertEquals(1, decoded.elements().size());
|
||||||
|
assertEquals("uuid-1", decoded.elements().get(0).uuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripWithGroupsAndLocators() {
|
||||||
|
BbCube cube = new BbCube("c", "cube",
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||||
|
0f,
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0),
|
||||||
|
Map.of());
|
||||||
|
BbLocator loc = new BbLocator("l", "loc",
|
||||||
|
new Vector3f(1, 2, 3), new Vector3f(0, 0, 0));
|
||||||
|
BbGroup grp = new BbGroup("g", "group",
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0));
|
||||||
|
Bbmodel original = new Bbmodel(64, 64,
|
||||||
|
List.of(cube), List.of(loc), List.of(grp),
|
||||||
|
List.of(new BbOutlinerNode.GroupRef("g", List.of(
|
||||||
|
new BbOutlinerNode.ElementRef("c"),
|
||||||
|
new BbOutlinerNode.ElementRef("l")
|
||||||
|
))));
|
||||||
|
Bbmodel decoded = roundTrip(original);
|
||||||
|
assertEquals(1, decoded.groups().size());
|
||||||
|
assertEquals(1, decoded.locators().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDecodeOfEmptyBuf() {
|
||||||
|
ByteBuf buf = Unpooled.buffer();
|
||||||
|
try {
|
||||||
|
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf));
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDecodeOfAllZeros() {
|
||||||
|
// Length-prefixed lists will read zero counts; resolution will be 0x0. Validator
|
||||||
|
// rejects because resolution < 1.
|
||||||
|
ByteBuf buf = Unpooled.wrappedBuffer(new byte[64]);
|
||||||
|
try {
|
||||||
|
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf));
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsExcessiveListCount() {
|
||||||
|
// Craft a bbmodel binary whose "elements" VarInt says MAX_LIST_ELEMENTS + 1.
|
||||||
|
ByteBuf buf = Unpooled.buffer();
|
||||||
|
try {
|
||||||
|
net.minecraft.network.VarInt.write(buf, 64); // width
|
||||||
|
net.minecraft.network.VarInt.write(buf, 64); // height
|
||||||
|
net.minecraft.network.VarInt.write(buf, BbmodelCodec.MAX_LIST_ELEMENTS + 1); // elements count
|
||||||
|
ByteBuf finalBuf = buf;
|
||||||
|
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(finalBuf));
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTrailingBytes() {
|
||||||
|
// A valid decode is followed by extra bytes — callers MUST verify
|
||||||
|
// readableBytes() == 0 after decode (we do so in SharedCosmeticCache/uploader).
|
||||||
|
// Codec itself doesn't enforce that; this test documents the contract.
|
||||||
|
Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of());
|
||||||
|
ByteBuf buf = Unpooled.buffer();
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.encode(buf, original);
|
||||||
|
buf.writeBytes(new byte[]{1, 2, 3, 4}); // trailing
|
||||||
|
BbmodelCodec.CODEC.decode(buf);
|
||||||
|
assertTrue(buf.readableBytes() > 0, "caller must observe trailing bytes");
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canonicalEncodeSortsFaceKeys() {
|
||||||
|
// If we encode the same cube twice but with different HashMap iteration orders
|
||||||
|
// for faces, the resulting binaries must be identical (canonicalness for dedup).
|
||||||
|
java.util.Map<String, com.razz.dfashion.bbmodel.BbFace> a = new java.util.LinkedHashMap<>();
|
||||||
|
a.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
a.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
a.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
java.util.Map<String, com.razz.dfashion.bbmodel.BbFace> b = new java.util.LinkedHashMap<>();
|
||||||
|
b.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
b.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
b.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||||
|
|
||||||
|
BbCube cubeA = new BbCube("u", "n",
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||||
|
0f,
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), a);
|
||||||
|
BbCube cubeB = new BbCube("u", "n",
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||||
|
0f,
|
||||||
|
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), b);
|
||||||
|
Bbmodel modelA = new Bbmodel(64, 64, List.of(cubeA), List.of(), List.of(),
|
||||||
|
List.of(new BbOutlinerNode.ElementRef("u")));
|
||||||
|
Bbmodel modelB = new Bbmodel(64, 64, List.of(cubeB), List.of(), List.of(),
|
||||||
|
List.of(new BbOutlinerNode.ElementRef("u")));
|
||||||
|
|
||||||
|
assertArrayEquals(encode(modelA), encode(modelB),
|
||||||
|
"Canonical encode must be order-independent over face maps");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fuzz harness ----
|
||||||
|
|
||||||
|
@FuzzTest(maxDuration = "30s")
|
||||||
|
void fuzzDecode(byte[] input) {
|
||||||
|
ByteBuf buf = Unpooled.wrappedBuffer(input);
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.decode(buf);
|
||||||
|
} catch (RuntimeException expected) {
|
||||||
|
// Every rejection path throws a runtime exception; that's intended.
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static Bbmodel roundTrip(Bbmodel m) {
|
||||||
|
ByteBuf buf = Unpooled.buffer();
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.encode(buf, m);
|
||||||
|
return BbmodelCodec.CODEC.decode(buf);
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] encode(Bbmodel m) {
|
||||||
|
ByteBuf buf = Unpooled.buffer();
|
||||||
|
try {
|
||||||
|
BbmodelCodec.CODEC.encode(buf, m);
|
||||||
|
byte[] out = new byte[buf.readableBytes()];
|
||||||
|
buf.readBytes(out);
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
buf.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.razz.dfashion.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-level guard against accidental reintroduction of dangerous APIs.
|
||||||
|
*
|
||||||
|
* <p>The mod deliberately avoids calling untrusted-bytes-eating APIs (STB PNG decoder,
|
||||||
|
* ImageIO, GSON's stream parsers, Java serialization) anywhere outside of a tiny pinned
|
||||||
|
* allowlist. Every entry in {@link #ALLOWLIST} is a place we've audited and know is safe
|
||||||
|
* — if you want to add a new call site, either it's a new trusted boundary (update this
|
||||||
|
* allowlist with a comment explaining why), or you need to route through the existing
|
||||||
|
* safe path ({@code SafePngReader}, {@code BbmodelCodec}).
|
||||||
|
*
|
||||||
|
* <p>Walking source files textually has false-positive risk (tokens appearing in comments
|
||||||
|
* or string literals). The fix for false positives is to allowlist the specific file,
|
||||||
|
* not to weaken the check.
|
||||||
|
*/
|
||||||
|
class ForbiddenApiGuardTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each key is a forbidden token; each value is the set of filenames (basenames) where
|
||||||
|
* it's legitimate. Empty set means "forbidden everywhere".
|
||||||
|
*
|
||||||
|
* <p><b>Maintenance rule:</b> when adding a filename here, also add a one-line comment
|
||||||
|
* explaining the trust boundary that makes the usage safe.
|
||||||
|
*/
|
||||||
|
private static final Map<String, Set<String>> ALLOWLIST = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
// STB-backed PNG decoder. The only sanctioned way to turn user PNGs into NativeImages
|
||||||
|
// is SafeImageLoader, which routes through SafePngReader. Both files reference the
|
||||||
|
// API name in documentation only (verified).
|
||||||
|
ALLOWLIST.put("NativeImage.read", Set.of("SafePngReader.java", "SafeImageLoader.java"));
|
||||||
|
|
||||||
|
// Java ImageIO — historically has CVEs. Never acceptable.
|
||||||
|
ALLOWLIST.put("ImageIO.read", Set.of());
|
||||||
|
|
||||||
|
// Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs
|
||||||
|
// GSON on untrusted bytes (and validates the result post-parse).
|
||||||
|
ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java"));
|
||||||
|
|
||||||
|
// Java serialization — RCE class on untrusted input. Never acceptable.
|
||||||
|
ALLOWLIST.put("ObjectInputStream", Set.of());
|
||||||
|
|
||||||
|
// Direct Gson construction. Prefer GsonBuilder so type adapters and defaults are
|
||||||
|
// explicit. Default-config Gson is banned everywhere.
|
||||||
|
ALLOWLIST.put("new Gson(", Set.of());
|
||||||
|
|
||||||
|
// GsonBuilder instances — allowed at the two trusted boundaries only:
|
||||||
|
// BbModelParser: author's local disk (validated post-parse).
|
||||||
|
// CosmeticCatalog: assets/decofashion/cosmetics.json from the resource pack (trusted).
|
||||||
|
ALLOWLIST.put("new GsonBuilder(", Set.of("BbModelParser.java", "CosmeticCatalog.java"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source root, resolved from the gradle test task's working directory (project root). */
|
||||||
|
private static final Path SRC_MAIN = Paths.get("src/main/java");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noForbiddenApiOutsideAllowlist() throws IOException {
|
||||||
|
if (!Files.isDirectory(SRC_MAIN)) {
|
||||||
|
fail("src/main/java not found at " + SRC_MAIN.toAbsolutePath() + " — wrong working dir?");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
try (Stream<Path> files = Files.walk(SRC_MAIN)) {
|
||||||
|
for (Path p : (Iterable<Path>) files.filter(f -> f.toString().endsWith(".java"))::iterator) {
|
||||||
|
String fileName = p.getFileName().toString();
|
||||||
|
String content = Files.readString(p);
|
||||||
|
for (Map.Entry<String, Set<String>> entry : ALLOWLIST.entrySet()) {
|
||||||
|
String token = entry.getKey();
|
||||||
|
Set<String> okFiles = entry.getValue();
|
||||||
|
if (content.contains(token) && !okFiles.contains(fileName)) {
|
||||||
|
violations.add(relativeOrName(p) + " uses forbidden token: '" + token + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Forbidden API usage detected:\n\n");
|
||||||
|
for (String v : violations) sb.append(" ").append(v).append('\n');
|
||||||
|
sb.append("\nIf the usage is legitimate, add the filename to ALLOWLIST in ")
|
||||||
|
.append(ForbiddenApiGuardTest.class.getSimpleName()).append(" with a one-line\n")
|
||||||
|
.append("comment explaining the trust boundary. Otherwise, route through the ")
|
||||||
|
.append("existing safe path\n(SafeImageLoader, SafePngReader, BbmodelCodec).\n");
|
||||||
|
fail(sb.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String relativeOrName(Path p) {
|
||||||
|
try {
|
||||||
|
return SRC_MAIN.relativize(p).toString();
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return p.getFileName().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.razz.dfashion.security;
|
||||||
|
|
||||||
|
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||||
|
import com.razz.dfashion.skin.SafePngReader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
import java.util.zip.Deflater;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security corpus + fuzz harness for {@link SafePngReader}.
|
||||||
|
*
|
||||||
|
* <p>The regression tests are a hand-curated set of "this MUST reject" inputs — malformed
|
||||||
|
* PNGs, polyglots, bomb shapes, dimension attacks — so a future refactor can't silently
|
||||||
|
* stop rejecting them. The fuzz harness feeds the parser random byte[] inputs; the only
|
||||||
|
* exceptions it's allowed to throw are {@link IOException} (BadPng / truncation). Any
|
||||||
|
* {@link RuntimeException} or {@link Error} from the parser is a bug Jazzer should find.
|
||||||
|
*
|
||||||
|
* <p>Run regression: {@code ./gradlew test}. Run fuzz: {@code ./gradlew test -PfuzzMinutes=5}.
|
||||||
|
*/
|
||||||
|
class SafePngReaderSecurityTest {
|
||||||
|
|
||||||
|
// ---- regression corpus ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minimalValidRgbaPngDecodes() throws IOException {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
SafePngReader.Image img = SafePngReader.decode(png);
|
||||||
|
assertEquals(1, img.width);
|
||||||
|
assertEquals(1, img.height);
|
||||||
|
assertEquals(4, img.rgba.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEmptyInput() {
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(new byte[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullInput() {
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsWrongMagic() {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
png[0] = 'X';
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTruncatedHeader() {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
byte[] truncated = new byte[png.length - 10];
|
||||||
|
System.arraycopy(png, 0, truncated, 0, truncated.length);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(truncated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsBadCrcInIhdr() {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
// IHDR CRC lives at byte offset 8 + 4 + 4 + 13 = 29. Flip the last CRC byte.
|
||||||
|
png[29] ^= 0x01;
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsColorTypeRgb() {
|
||||||
|
byte[] png = buildRawIhdrPng(1, 1, 8, /*colorType=*/2);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsBitDepth16() {
|
||||||
|
byte[] png = buildRawIhdrPng(1, 1, 16, /*colorType=*/6);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInterlaced() {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
// IHDR interlace byte is last of 13 IHDR bytes: offset 8 + 4 + 4 + 12 = 28.
|
||||||
|
png[28] = 1;
|
||||||
|
// Fix CRC for the mutated IHDR.
|
||||||
|
fixIhdrCrc(png);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDimensionOverMax() {
|
||||||
|
byte[] png = buildRawIhdrPng(SafePngReader.MAX_DIM + 1, 1, 8, 6);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsZeroDimension() {
|
||||||
|
byte[] png = buildRawIhdrPng(0, 1, 8, 6);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTrailingBytesAfterIend() {
|
||||||
|
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||||
|
byte[] withTrailer = new byte[png.length + 16];
|
||||||
|
System.arraycopy(png, 0, withTrailer, 0, png.length);
|
||||||
|
// Simulate a polyglot trailer (e.g., ZIP magic tacked on).
|
||||||
|
withTrailer[png.length] = 'P';
|
||||||
|
withTrailer[png.length + 1] = 'K';
|
||||||
|
withTrailer[png.length + 2] = 0x03;
|
||||||
|
withTrailer[png.length + 3] = 0x04;
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(withTrailer));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateIhdr() throws IOException {
|
||||||
|
// Build PNG with two IHDR chunks.
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownCriticalChunk() throws IOException {
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "XXXX", new byte[]{1, 2, 3}); // uppercase = critical, unknown
|
||||||
|
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsUnknownAncillaryChunk() throws IOException {
|
||||||
|
// Lowercase first letter = ancillary. Should be silently skipped, not rejected.
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "tEXt", "Author\0example".getBytes()); // ancillary
|
||||||
|
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
SafePngReader.Image img = SafePngReader.decode(out.toByteArray());
|
||||||
|
assertEquals(1, img.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingIend() throws IOException {
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsIdatBeforeIhdr() throws IOException {
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInflatedOversize() throws IOException {
|
||||||
|
// Declare a tiny image (1x1 = 4 bytes expected), but stuff IDAT with
|
||||||
|
// data that inflates to much more. The bounded inflate must reject.
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||||
|
writeChunk(out, "IDAT", idatPayload(16, 16)); // produces way more than 1x1's budget
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fuzz harness ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed arbitrary byte arrays to the decoder. Jazzer will flag any exception
|
||||||
|
* other than {@link IOException}, or any {@link Error}, as a finding. Mutates
|
||||||
|
* around a corpus of valid PNGs by default.
|
||||||
|
*/
|
||||||
|
@FuzzTest(maxDuration = "30s")
|
||||||
|
void fuzzDecode(byte[] input) {
|
||||||
|
try {
|
||||||
|
SafePngReader.decode(input);
|
||||||
|
} catch (IOException expected) {
|
||||||
|
// BadPngException, truncation, unsupported-format — all legitimate rejections.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- builders ----
|
||||||
|
|
||||||
|
/** Construct a minimally valid 8-bit RGBA PNG with a solid-red pixel at every position. */
|
||||||
|
private static byte[] buildMinimalRgbaPng(int w, int h) {
|
||||||
|
return buildRawIhdrPng(w, h, 8, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildRawIhdrPng(int w, int h, int bitDepth, int colorType) {
|
||||||
|
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
try {
|
||||||
|
out.write(sig);
|
||||||
|
writeChunk(out, "IHDR", ihdrPayload(w, h, bitDepth, colorType));
|
||||||
|
writeChunk(out, "IDAT", idatPayload(Math.max(w, 1), Math.max(h, 1)));
|
||||||
|
writeChunk(out, "IEND", new byte[0]);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new AssertionError(ex);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ihdrPayload(int w, int h, int bitDepth, int colorType) {
|
||||||
|
byte[] p = new byte[13];
|
||||||
|
writeU32(p, 0, w);
|
||||||
|
writeU32(p, 4, h);
|
||||||
|
p[8] = (byte) bitDepth;
|
||||||
|
p[9] = (byte) colorType;
|
||||||
|
p[10] = 0; // compression
|
||||||
|
p[11] = 0; // filter
|
||||||
|
p[12] = 0; // interlace
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construct a valid IDAT: per-row filter byte (0 = None) + RGBA pixels, deflated. */
|
||||||
|
private static byte[] idatPayload(int w, int h) {
|
||||||
|
int rowBytes = w * 4;
|
||||||
|
byte[] raw = new byte[h * (1 + rowBytes)];
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
int off = y * (1 + rowBytes);
|
||||||
|
raw[off] = 0; // filter: None
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
int px = off + 1 + x * 4;
|
||||||
|
raw[px] = (byte) 0xFF;
|
||||||
|
raw[px + 1] = 0;
|
||||||
|
raw[px + 2] = 0;
|
||||||
|
raw[px + 3] = (byte) 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Deflater d = new Deflater(Deflater.BEST_COMPRESSION);
|
||||||
|
try {
|
||||||
|
d.setInput(raw);
|
||||||
|
d.finish();
|
||||||
|
byte[] buf = new byte[raw.length * 2 + 64];
|
||||||
|
int n = d.deflate(buf);
|
||||||
|
byte[] out = new byte[n];
|
||||||
|
System.arraycopy(buf, 0, out, 0, n);
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
d.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeChunk(ByteArrayOutputStream out, String type, byte[] data) throws IOException {
|
||||||
|
byte[] typeBytes = type.getBytes();
|
||||||
|
if (typeBytes.length != 4) throw new AssertionError("bad chunk type");
|
||||||
|
byte[] lenBytes = new byte[4];
|
||||||
|
writeU32(lenBytes, 0, data.length);
|
||||||
|
out.write(lenBytes);
|
||||||
|
out.write(typeBytes);
|
||||||
|
out.write(data);
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
crc.update(typeBytes);
|
||||||
|
crc.update(data);
|
||||||
|
byte[] crcBytes = new byte[4];
|
||||||
|
writeU32(crcBytes, 0, (int) crc.getValue());
|
||||||
|
out.write(crcBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute and rewrite IHDR's CRC after mutating its payload. */
|
||||||
|
private static void fixIhdrCrc(byte[] png) {
|
||||||
|
// IHDR starts at offset 8 (after magic). Chunk: [len=4][type=4][data=13][crc=4].
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
crc.update(png, 8 + 4, 4 + 13); // type + data
|
||||||
|
writeU32(png, 8 + 4 + 4 + 13, (int) crc.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeU32(byte[] b, int p, int v) {
|
||||||
|
b[p] = (byte) ((v >>> 24) & 0xFF);
|
||||||
|
b[p + 1] = (byte) ((v >>> 16) & 0xFF);
|
||||||
|
b[p + 2] = (byte) ((v >>> 8) & 0xFF);
|
||||||
|
b[p + 3] = (byte) (v & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user