Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c8bb8493e | ||
|
|
5f02d9cbc5 | ||
|
|
82da98cc35 | ||
|
|
c73bb5b19a | ||
|
|
efac5d242b | ||
|
|
2034c75f7d |
No files matched your search
@@ -3,6 +3,9 @@
|
|||||||
Branch `multiloader` builds the mod for **Minecraft 1.20.1** on **Fabric** and **Forge** from one
|
Branch `multiloader` builds the mod for **Minecraft 1.20.1** on **Fabric** and **Forge** from one
|
||||||
shared `common` source set.
|
shared `common` source set.
|
||||||
|
|
||||||
|
The Forge artifact includes optional compatibility for **3D Skin Layers 1.9.2**. It is a
|
||||||
|
compile-only dependency and is not bundled into the resulting jar.
|
||||||
|
|
||||||
`main` remains the original single-loader NeoForge 1.21.1 line.
|
`main` remains the original single-loader NeoForge 1.21.1 line.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ This branch targets **Fabric and Forge 1.20.1** through an Architectury multiloa
|
|||||||
- HD PNG capes fall through to the vanilla renderer untouched.
|
- HD PNG capes fall through to the vanilla renderer untouched.
|
||||||
- Per-player control and persistence.
|
- Per-player control and persistence.
|
||||||
|
|
||||||
|
### 3D Skin Layers compatibility (Forge)
|
||||||
|
|
||||||
|
3D Skin Layers 1.9.2 is supported as an optional, non-bundled mod. Square HD skins from 128x128
|
||||||
|
through 2048x2048 retain their original texture resolution while a bounded 64x64 alpha map is used
|
||||||
|
to build the 3D geometry. Failures are logged with the `[SkinsG/3DSL Compat]` prefix and use safe
|
||||||
|
fallback geometry; if fallback construction also fails, only the affected 3D layer is disabled.
|
||||||
|
|
||||||
## Backend integration (easy)
|
## Backend integration (easy)
|
||||||
|
|
||||||
The mod talks to your existing site/launcher backend over plain HTTP — no server-side plugin needed.
|
The mod talks to your existing site/launcher backend over plain HTTP — no server-side plugin needed.
|
||||||
@@ -37,8 +44,8 @@ client's `mods/` folder. Client-side only.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
On first launch the mod writes `config/skinsg.json` with an **empty** `baseUrl`. Nothing is
|
On first launch the NightSync build writes `config/skinsg.json` with
|
||||||
fetched until you set it.
|
`https://nightsync.xyz` as `baseUrl`. Existing empty configs are migrated automatically.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ subprojects {
|
|||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven { url "https://maven.architectury.dev/" }
|
maven { url "https://maven.architectury.dev/" }
|
||||||
|
maven { url "https://api.modrinth.com/maven" }
|
||||||
maven { url "https://maven.neoforged.net/releases/" }
|
maven { url "https://maven.neoforged.net/releases/" }
|
||||||
maven { url "https://maven.minecraftforge.net/" }
|
maven { url "https://maven.minecraftforge.net/" }
|
||||||
maven { url "https://maven.parchmentmc.org" }
|
maven { url "https://maven.parchmentmc.org" }
|
||||||
@@ -56,6 +57,10 @@ subprojects {
|
|||||||
options.encoding = "UTF-8"
|
options.encoding = "UTF-8"
|
||||||
options.release = javaVer
|
options.release = javaVer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def runtimePlatforms = rootProject.enabled_platforms.split(",").collect { it.trim() }.findAll { !it.isEmpty() }
|
def runtimePlatforms = rootProject.enabled_platforms.split(",").collect { it.trim() }.findAll { !it.isEmpty() }
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ dependencies {
|
|||||||
// which Architectury remaps to the right annotation on each platform.
|
// which Architectury remaps to the right annotation on each platform.
|
||||||
modImplementation "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}"
|
modImplementation "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}"
|
||||||
modApi "dev.architectury:architectury:${rootProject.architectury_api_version}"
|
modApi "dev.architectury:architectury:${rootProject.architectury_api_version}"
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package xyz.nightsync.skinsg;
|
package xyz.nightsync.skinsg;
|
||||||
|
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||||
import xyz.nightsync.skinsg.client.CapeManager;
|
import xyz.nightsync.skinsg.client.CapeManager;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -13,6 +14,7 @@ import java.nio.file.Path;
|
|||||||
public final class SkinsgClient {
|
public final class SkinsgClient {
|
||||||
|
|
||||||
private static SkinsgConfig config;
|
private static SkinsgConfig config;
|
||||||
|
private static ClientPacketListener lastConnection;
|
||||||
|
|
||||||
private SkinsgClient() {}
|
private SkinsgClient() {}
|
||||||
|
|
||||||
@@ -30,6 +32,11 @@ public final class SkinsgClient {
|
|||||||
|
|
||||||
/** Call every client tick from the loader event. */
|
/** Call every client tick from the loader event. */
|
||||||
public static void tick() {
|
public static void tick() {
|
||||||
|
ClientPacketListener connection = Minecraft.getInstance().getConnection();
|
||||||
|
if (connection != lastConnection) {
|
||||||
|
CapeManager.releaseAll();
|
||||||
|
lastConnection = connection;
|
||||||
|
}
|
||||||
CapeManager.clientTick();
|
CapeManager.clientTick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,9 @@ public final class SkinsgConfig {
|
|||||||
|
|
||||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
public static final int DEFAULT_SPEED = 100;
|
public static final int DEFAULT_SPEED = 100;
|
||||||
|
public static final String DEFAULT_BASE_URL = "https://nightsync.xyz";
|
||||||
|
|
||||||
public String baseUrl = "";
|
public String baseUrl = DEFAULT_BASE_URL;
|
||||||
public Mode mode = Mode.ALL;
|
public Mode mode = Mode.ALL;
|
||||||
public Set<String> enabled = new LinkedHashSet<>();
|
public Set<String> enabled = new LinkedHashSet<>();
|
||||||
public Set<String> blocked = new LinkedHashSet<>();
|
public Set<String> blocked = new LinkedHashSet<>();
|
||||||
@@ -41,12 +42,31 @@ public final class SkinsgConfig {
|
|||||||
}
|
}
|
||||||
cfg.normalize();
|
cfg.normalize();
|
||||||
cfg.file = f;
|
cfg.file = f;
|
||||||
if (fresh) cfg.save();
|
cfg.save();
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized boolean reload() {
|
||||||
|
if (file == null || !Files.isRegularFile(file)) return false;
|
||||||
|
try {
|
||||||
|
SkinsgConfig loaded = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), SkinsgConfig.class);
|
||||||
|
if (loaded == null) return false;
|
||||||
|
loaded.normalize();
|
||||||
|
baseUrl = loaded.baseUrl;
|
||||||
|
mode = loaded.mode;
|
||||||
|
enabled = new LinkedHashSet<>(loaded.enabled);
|
||||||
|
blocked = new LinkedHashSet<>(loaded.blocked);
|
||||||
|
globalSpeed = loaded.globalSpeed;
|
||||||
|
speed = new HashMap<>(loaded.speed);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void normalize() {
|
private void normalize() {
|
||||||
if (baseUrl == null) baseUrl = "";
|
if (baseUrl == null || baseUrl.isBlank()) baseUrl = DEFAULT_BASE_URL;
|
||||||
baseUrl = baseUrl.trim();
|
baseUrl = baseUrl.trim();
|
||||||
while (baseUrl.endsWith("/")) baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
while (baseUrl.endsWith("/")) baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||||
if (mode == null) mode = Mode.ALL;
|
if (mode == null) mode = Mode.ALL;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import net.minecraft.client.Minecraft;
|
|||||||
import net.minecraft.client.renderer.texture.AbstractTexture;
|
import net.minecraft.client.renderer.texture.AbstractTexture;
|
||||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import xyz.nightsync.skinsg.SkinsgConfig;
|
import xyz.nightsync.skinsg.SkinsgConfig;
|
||||||
import xyz.nightsync.skinsg.gif.GifDecoder;
|
import xyz.nightsync.skinsg.gif.GifDecoder;
|
||||||
import xyz.nightsync.skinsg.gif.GifImage;
|
import xyz.nightsync.skinsg.gif.GifImage;
|
||||||
@@ -35,6 +37,10 @@ public final class CapeManager {
|
|||||||
private static final String MODID = "skinsg";
|
private static final String MODID = "skinsg";
|
||||||
private static final int MAX_CACHE = 96;
|
private static final int MAX_CACHE = 96;
|
||||||
private static final int MAX_BYTES = 12 * 1024 * 1024;
|
private static final int MAX_BYTES = 12 * 1024 * 1024;
|
||||||
|
private static final long RETRY_BASE_MS = 5_000L;
|
||||||
|
private static final long RETRY_MAX_MS = 5 * 60_000L;
|
||||||
|
private static final long STATIC_CAPE_RECHECK_MS = 15 * 60_000L;
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger("skinsg");
|
||||||
|
|
||||||
private static SkinsgConfig config;
|
private static SkinsgConfig config;
|
||||||
private static Supplier<UUID> selfProvider = () -> null;
|
private static Supplier<UUID> selfProvider = () -> null;
|
||||||
@@ -72,7 +78,9 @@ public final class CapeManager {
|
|||||||
releaseIfPresent(uuid);
|
releaseIfPresent(uuid);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return CACHE.computeIfAbsent(uuid, CapeManager::startLoad).texture;
|
Entry entry = CACHE.computeIfAbsent(uuid, Entry::new);
|
||||||
|
scheduleLoad(entry);
|
||||||
|
return entry.texture();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void clientTick() {
|
public static void clientTick() {
|
||||||
@@ -100,35 +108,94 @@ public final class CapeManager {
|
|||||||
if (e != null) e.release();
|
if (e != null) e.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Entry startLoad(UUID uuid) {
|
private static void scheduleLoad(Entry entry) {
|
||||||
Entry entry = new Entry(uuid);
|
if (!entry.beginLoad(System.currentTimeMillis())) return;
|
||||||
IO.submit(() -> {
|
SkinsgConfig currentConfig = config;
|
||||||
try {
|
String baseUrl = currentConfig == null ? "" : currentConfig.baseUrl;
|
||||||
byte[] bytes = httpGet(config.baseUrl + "/api/minecraft/cape/" + uuid + "?gif");
|
if (baseUrl == null || baseUrl.isBlank()) {
|
||||||
if (bytes == null || bytes.length < 6 || !isGif(bytes)) {
|
entry.defer(STATIC_CAPE_RECHECK_MS);
|
||||||
entry.noAnimation = true;
|
return;
|
||||||
return;
|
}
|
||||||
}
|
try {
|
||||||
buildTexture(entry, GifDecoder.decode(bytes, LIMITS));
|
IO.submit(() -> load(entry, baseUrl));
|
||||||
} catch (Throwable t) {
|
} catch (RuntimeException error) {
|
||||||
entry.noAnimation = true;
|
retry(entry, "schedule", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void load(Entry entry, String baseUrl) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = httpGet(baseUrl + "/api/minecraft/cape/" + entry.uuid + "?gif");
|
||||||
|
if (bytes.length < 6 || !isGif(bytes)) {
|
||||||
|
entry.defer(STATIC_CAPE_RECHECK_MS);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
buildTexture(entry, GifDecoder.decode(bytes, LIMITS));
|
||||||
return entry;
|
} catch (Exception | LinkageError error) {
|
||||||
|
retry(entry, "load", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void retry(Entry entry, String stage, Throwable error) {
|
||||||
|
long delay = entry.retry(System.currentTimeMillis());
|
||||||
|
if (delay >= 0) {
|
||||||
|
LOGGER.warn("[NightSkins/Cape] Animated cape {} failed for uuid={}; retrying in {} ms",
|
||||||
|
stage, entry.uuid, delay, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void buildTexture(Entry entry, GifImage gif) {
|
private static void buildTexture(Entry entry, GifImage gif) {
|
||||||
RenderSystem.recordRenderCall(() -> {
|
try {
|
||||||
try {
|
RenderSystem.recordRenderCall(() -> {
|
||||||
DynamicTexture tex = new DynamicTexture(gif.width, gif.height, true);
|
if (entry.isReleased()) return;
|
||||||
((AbstractTexture) tex).setFilter(false, false);
|
DynamicTexture tex = null;
|
||||||
ResourceLocation id = new ResourceLocation(MODID, "cape/" + entry.uuid + "/anim");
|
ResourceLocation id = new ResourceLocation(MODID, "cape/" + entry.uuid + "/anim");
|
||||||
Minecraft.getInstance().getTextureManager().register(id, tex);
|
boolean registered = false;
|
||||||
entry.attach(gif, tex, id);
|
try {
|
||||||
} catch (Throwable t) {
|
tex = new DynamicTexture(gif.width, gif.height, true);
|
||||||
entry.noAnimation = true;
|
((AbstractTexture) tex).setFilter(false, false);
|
||||||
|
Minecraft.getInstance().getTextureManager().register(id, tex);
|
||||||
|
registered = true;
|
||||||
|
if (!entry.attach(gif, tex, id)) {
|
||||||
|
releaseTexture(id, tex);
|
||||||
|
}
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
releaseTexture(registered ? id : null, tex);
|
||||||
|
retry(entry, "texture creation", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
retry(entry, "render scheduling", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void releaseTexture(ResourceLocation id, DynamicTexture tex) {
|
||||||
|
Runnable release = () -> {
|
||||||
|
if (id != null) {
|
||||||
|
try {
|
||||||
|
Minecraft.getInstance().getTextureManager().release(id);
|
||||||
|
return;
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
if (tex != null) {
|
||||||
|
try {
|
||||||
|
tex.close();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (RenderSystem.isOnRenderThread()) release.run();
|
||||||
|
else RenderSystem.recordRenderCall(release::run);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
if (tex != null) {
|
||||||
|
try {
|
||||||
|
tex.close();
|
||||||
|
} catch (Throwable ignoredClose) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isGif(byte[] b) {
|
private static boolean isGif(byte[] b) {
|
||||||
@@ -136,81 +203,171 @@ public final class CapeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] httpGet(String url) throws IOException {
|
private static byte[] httpGet(String url) throws IOException {
|
||||||
HttpURLConnection c = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||||
c.setConnectTimeout(7000);
|
connection.setConnectTimeout(7000);
|
||||||
c.setReadTimeout(10000);
|
connection.setReadTimeout(10000);
|
||||||
c.setRequestProperty("User-Agent", MODID + "/1.0");
|
connection.setRequestProperty("User-Agent", MODID + "/1.0");
|
||||||
if (c.getResponseCode() != 200) return null;
|
try {
|
||||||
int len = c.getContentLength();
|
int status = connection.getResponseCode();
|
||||||
if (len > MAX_BYTES) return null;
|
if (status != 200) throw new IOException("Cape endpoint returned HTTP " + status);
|
||||||
try (InputStream in = c.getInputStream()) {
|
int length = connection.getContentLength();
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(1024, Math.min(Math.max(len, 0), MAX_BYTES)));
|
if (length > MAX_BYTES) throw new IOException("Cape response exceeds " + MAX_BYTES + " bytes");
|
||||||
byte[] buf = new byte[8192];
|
try (InputStream in = connection.getInputStream()) {
|
||||||
int r, total = 0;
|
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||||
while ((r = in.read(buf)) != -1) {
|
Math.max(1024, Math.min(Math.max(length, 0), MAX_BYTES)));
|
||||||
total += r;
|
byte[] buffer = new byte[8192];
|
||||||
if (total > MAX_BYTES) return null;
|
int read;
|
||||||
out.write(buf, 0, r);
|
int total = 0;
|
||||||
|
while ((read = in.read(buffer)) != -1) {
|
||||||
|
total += read;
|
||||||
|
if (total > MAX_BYTES) throw new IOException("Cape response exceeds " + MAX_BYTES + " bytes");
|
||||||
|
out.write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
}
|
}
|
||||||
return out.toByteArray();
|
} finally {
|
||||||
|
connection.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Entry {
|
private static final class Entry {
|
||||||
final UUID uuid;
|
final UUID uuid;
|
||||||
volatile boolean noAnimation;
|
|
||||||
|
|
||||||
private GifImage gif;
|
private GifImage gif;
|
||||||
private DynamicTexture tex;
|
private DynamicTexture tex;
|
||||||
private volatile ResourceLocation texture;
|
private ResourceLocation texture;
|
||||||
private int frame = -1;
|
private int frame = -1;
|
||||||
private long nextAt;
|
private long nextAt;
|
||||||
|
private long nextLoadAt;
|
||||||
|
private int failures;
|
||||||
|
private boolean loading;
|
||||||
|
private boolean released;
|
||||||
|
|
||||||
Entry(UUID uuid) { this.uuid = uuid; }
|
Entry(UUID uuid) { this.uuid = uuid; }
|
||||||
|
|
||||||
void attach(GifImage gif, DynamicTexture tex, ResourceLocation id) {
|
synchronized ResourceLocation texture() {
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized boolean beginLoad(long now) {
|
||||||
|
if (released || loading || texture != null || now < nextLoadAt) return false;
|
||||||
|
loading = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void defer(long delay) {
|
||||||
|
if (released) return;
|
||||||
|
loading = false;
|
||||||
|
failures = 0;
|
||||||
|
nextLoadAt = System.currentTimeMillis() + delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized long retry(long now) {
|
||||||
|
if (released) return -1L;
|
||||||
|
loading = false;
|
||||||
|
failures++;
|
||||||
|
long delay = RetryBackoff.delayMillis(failures, RETRY_BASE_MS, RETRY_MAX_MS);
|
||||||
|
nextLoadAt = now + delay;
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized boolean attach(GifImage gif, DynamicTexture tex, ResourceLocation id) {
|
||||||
|
if (released) return false;
|
||||||
this.gif = gif;
|
this.gif = gif;
|
||||||
this.tex = tex;
|
this.tex = tex;
|
||||||
this.texture = id;
|
this.texture = id;
|
||||||
this.frame = -1;
|
this.frame = -1;
|
||||||
this.nextAt = 0L;
|
this.nextAt = 0L;
|
||||||
|
this.nextLoadAt = 0L;
|
||||||
|
this.failures = 0;
|
||||||
|
this.loading = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized boolean isReleased() {
|
||||||
|
return released;
|
||||||
}
|
}
|
||||||
|
|
||||||
void tick(long now, int speedPercent) {
|
void tick(long now, int speedPercent) {
|
||||||
if (gif == null || tex == null) return;
|
GifImage currentGif;
|
||||||
if (frame != -1 && now < nextAt) return;
|
DynamicTexture currentTex;
|
||||||
frame = (frame + 1) % gif.frameCount();
|
int currentFrame;
|
||||||
|
synchronized (this) {
|
||||||
int[] src = gif.framesAbgr[frame];
|
if (released || gif == null || tex == null) return;
|
||||||
NativeImage px = tex.getPixels();
|
if (frame != -1 && now < nextAt) return;
|
||||||
if (px != null && px.getWidth() == gif.width && px.getHeight() == gif.height) {
|
currentGif = gif;
|
||||||
int i = 0;
|
currentTex = tex;
|
||||||
for (int y = 0; y < gif.height; y++) {
|
frame = (frame + 1) % currentGif.frameCount();
|
||||||
for (int x = 0; x < gif.width; x++) {
|
currentFrame = frame;
|
||||||
px.setPixelRGBA(x, y, src[i++]);
|
int base = currentGif.delaysMs[currentFrame];
|
||||||
}
|
long duration = speedPercent > 0 ? (long) base * 100L / speedPercent : base;
|
||||||
}
|
nextAt = now + Math.max(20L, duration);
|
||||||
RenderSystem.recordRenderCall(tex::upload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int base = gif.delaysMs[frame];
|
try {
|
||||||
long dur = speedPercent > 0 ? (long) base * 100L / speedPercent : base;
|
int[] src = currentGif.framesAbgr[currentFrame];
|
||||||
nextAt = now + Math.max(20L, dur);
|
NativeImage pixels = currentTex.getPixels();
|
||||||
|
if (pixels == null || pixels.getWidth() != currentGif.width || pixels.getHeight() != currentGif.height) {
|
||||||
|
throw new IllegalStateException("Animated cape texture is no longer allocated");
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
for (int y = 0; y < currentGif.height; y++) {
|
||||||
|
for (int x = 0; x < currentGif.width; x++) {
|
||||||
|
pixels.setPixelRGBA(x, y, src[i++]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (RenderSystem.isOnRenderThread()) {
|
||||||
|
currentTex.upload();
|
||||||
|
} else {
|
||||||
|
RenderSystem.recordRenderCall(() -> upload(currentTex));
|
||||||
|
}
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
playbackFailed(currentTex, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upload(DynamicTexture expectedTexture) {
|
||||||
|
try {
|
||||||
|
expectedTexture.upload();
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
playbackFailed(expectedTexture, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playbackFailed(DynamicTexture expectedTexture, Throwable error) {
|
||||||
|
ResourceLocation staleId;
|
||||||
|
synchronized (this) {
|
||||||
|
if (released || tex != expectedTexture) return;
|
||||||
|
staleId = texture;
|
||||||
|
texture = null;
|
||||||
|
tex = null;
|
||||||
|
gif = null;
|
||||||
|
frame = -1;
|
||||||
|
loading = false;
|
||||||
|
failures++;
|
||||||
|
long delay = RetryBackoff.delayMillis(failures, RETRY_BASE_MS, RETRY_MAX_MS);
|
||||||
|
nextLoadAt = System.currentTimeMillis() + delay;
|
||||||
|
}
|
||||||
|
releaseTexture(staleId, expectedTexture);
|
||||||
|
LOGGER.warn("[NightSkins/Cape] Animated cape playback failed for uuid={}; texture will be rebuilt",
|
||||||
|
uuid, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
void release() {
|
void release() {
|
||||||
ResourceLocation id = texture;
|
ResourceLocation staleId;
|
||||||
if (id != null) {
|
DynamicTexture staleTexture;
|
||||||
RenderSystem.recordRenderCall(() -> {
|
synchronized (this) {
|
||||||
try {
|
if (released) return;
|
||||||
Minecraft.getInstance().getTextureManager().release(id);
|
released = true;
|
||||||
} catch (Throwable ignored) {
|
loading = false;
|
||||||
}
|
staleId = texture;
|
||||||
});
|
staleTexture = tex;
|
||||||
|
texture = null;
|
||||||
|
tex = null;
|
||||||
|
gif = null;
|
||||||
|
frame = -1;
|
||||||
}
|
}
|
||||||
texture = null;
|
releaseTexture(staleId, staleTexture);
|
||||||
tex = null;
|
|
||||||
gif = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package xyz.nightsync.skinsg.client;
|
||||||
|
|
||||||
|
final class RetryBackoff {
|
||||||
|
|
||||||
|
private RetryBackoff() {}
|
||||||
|
|
||||||
|
static long delayMillis(int failureCount, long baseDelay, long maxDelay) {
|
||||||
|
if (baseDelay <= 0 || maxDelay < baseDelay) {
|
||||||
|
throw new IllegalArgumentException("Invalid retry delay bounds");
|
||||||
|
}
|
||||||
|
int exponent = Math.max(0, Math.min(30, failureCount - 1));
|
||||||
|
if (baseDelay > (maxDelay >> exponent)) return maxDelay;
|
||||||
|
return Math.min(maxDelay, baseDelay << exponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,9 @@ public final class SkinsgCommands {
|
|||||||
return b.buildFuture();
|
return b.buildFuture();
|
||||||
};
|
};
|
||||||
|
|
||||||
return SkinsgCommands.<S>literal("skinsg")
|
return SkinsgCommands.<S>literal("nightskins")
|
||||||
|
.then(SkinsgCommands.<S>literal("reload")
|
||||||
|
.executes(c -> reload(fb, cfg, c)))
|
||||||
.then(SkinsgCommands.<S>literal("playgif")
|
.then(SkinsgCommands.<S>literal("playgif")
|
||||||
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); })
|
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); })
|
||||||
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
|
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
|
||||||
@@ -79,6 +81,12 @@ public final class SkinsgCommands {
|
|||||||
return ok(fb, c, "GIF capes: on for " + target);
|
return ok(fb, c, "GIF capes: on for " + target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static <S> int reload(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
|
||||||
|
if (!cfg.reload()) return err(fb, c, "could not reload config/skinsg.json");
|
||||||
|
CapeManager.releaseAll();
|
||||||
|
return ok(fb, c, "configuration and animated cape cache reloaded");
|
||||||
|
}
|
||||||
|
|
||||||
private static <S> int stopgif(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
|
private static <S> int stopgif(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
|
||||||
String target = StringArgumentType.getString(c, "target");
|
String target = StringArgumentType.getString(c, "target");
|
||||||
if (ALL.equals(target)) { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); }
|
if (ALL.equals(target)) { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); }
|
||||||
@@ -111,12 +119,12 @@ public final class SkinsgCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static <S> int ok(Feedback<S> fb, CommandContext<S> c, String text) {
|
private static <S> int ok(Feedback<S> fb, CommandContext<S> c, String text) {
|
||||||
fb.success(c.getSource(), "[skinsg] " + text);
|
fb.success(c.getSource(), "[NightSkins] " + text);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static <S> int err(Feedback<S> fb, CommandContext<S> c, String text) {
|
private static <S> int err(Feedback<S> fb, CommandContext<S> c, String text) {
|
||||||
fb.failure(c.getSource(), "[skinsg] " + text);
|
fb.failure(c.getSource(), "[NightSkins] " + text);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,10 @@ package xyz.nightsync.skinsg.mixin;
|
|||||||
|
|
||||||
import com.mojang.blaze3d.platform.NativeImage;
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
import net.minecraft.client.renderer.texture.HttpTexture;
|
import net.minecraft.client.renderer.texture.HttpTexture;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.resources.DefaultPlayerSkin;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
@@ -18,6 +22,9 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|||||||
*/
|
*/
|
||||||
@Mixin(HttpTexture.class)
|
@Mixin(HttpTexture.class)
|
||||||
public abstract class SkinMagicMixin {
|
public abstract class SkinMagicMixin {
|
||||||
|
private static final int VANILLA_SKIN_WIDTH = 64;
|
||||||
|
private static final int MAX_HD_SKIN_WIDTH = 2048;
|
||||||
|
private static final Logger SKINSG_LOGGER = LoggerFactory.getLogger("skinsg");
|
||||||
|
|
||||||
@Shadow
|
@Shadow
|
||||||
private static void setNoAlpha(NativeImage img, int x1, int y1, int x2, int y2) {}
|
private static void setNoAlpha(NativeImage img, int x1, int y1, int x2, int y2) {}
|
||||||
@@ -27,8 +34,17 @@ public abstract class SkinMagicMixin {
|
|||||||
|
|
||||||
@Inject(method = "processLegacySkin", at = @At("HEAD"), cancellable = true)
|
@Inject(method = "processLegacySkin", at = @At("HEAD"), cancellable = true)
|
||||||
private void skinsg$processLegacySkin(NativeImage image, CallbackInfoReturnable<@Nullable NativeImage> cir) {
|
private void skinsg$processLegacySkin(NativeImage image, CallbackInfoReturnable<@Nullable NativeImage> cir) {
|
||||||
final int h = image.getHeight();
|
try {
|
||||||
final int w = image.getWidth();
|
final int h = image.getHeight();
|
||||||
|
final int w = image.getWidth();
|
||||||
|
|
||||||
|
// Vanilla already owns the complete 64x32/64x64 lifecycle, including replacing and
|
||||||
|
// closing legacy images. Intercepting those images here needlessly duplicates that
|
||||||
|
// ownership path and can hand HttpTexture an already released image when another
|
||||||
|
// texture-loading mixin is present. Only HD skins belong to this mixin.
|
||||||
|
if (!skinsg$isSupportedHdSkin(w, h)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final int s = w / 8;
|
final int s = w / 8;
|
||||||
final int x64 = s * 8;
|
final int x64 = s * 8;
|
||||||
@@ -42,10 +58,6 @@ public abstract class SkinMagicMixin {
|
|||||||
final int x52 = x48 + x4;
|
final int x52 = x48 + x4;
|
||||||
final int x48m4 = x48 - x4;
|
final int x48m4 = x48 - x4;
|
||||||
|
|
||||||
if (w != x64 || (h != x32 && h != x64)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final boolean legacy = h == x32;
|
final boolean legacy = h == x32;
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
NativeImage newImg = new NativeImage(x64, x64, true);
|
NativeImage newImg = new NativeImage(x64, x64, true);
|
||||||
@@ -80,5 +92,36 @@ public abstract class SkinMagicMixin {
|
|||||||
|
|
||||||
cir.setReturnValue(image);
|
cir.setReturnValue(image);
|
||||||
cir.cancel();
|
cir.cancel();
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
SKINSG_LOGGER.error("[SkinsG] Failed to process skin image; falling back to the default Steve skin.", error);
|
||||||
|
try {
|
||||||
|
image.close();
|
||||||
|
} catch (RuntimeException closeError) {
|
||||||
|
SKINSG_LOGGER.debug("[SkinsG] Failed to close rejected skin image.", closeError);
|
||||||
|
}
|
||||||
|
cir.setReturnValue(skinsg$loadSteve());
|
||||||
|
cir.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean skinsg$isSupportedHdSkin(int width, int height) {
|
||||||
|
return width > VANILLA_SKIN_WIDTH
|
||||||
|
&& width <= MAX_HD_SKIN_WIDTH
|
||||||
|
&& width % VANILLA_SKIN_WIDTH == 0
|
||||||
|
&& (width & (width - 1)) == 0
|
||||||
|
&& (height == width / 2 || height == width);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeImage skinsg$loadSteve() {
|
||||||
|
try {
|
||||||
|
var resource = Minecraft.getInstance().getResourceManager()
|
||||||
|
.getResource(DefaultPlayerSkin.getDefaultSkin()).orElseThrow();
|
||||||
|
try (var stream = resource.open()) {
|
||||||
|
return NativeImage.read(stream);
|
||||||
|
}
|
||||||
|
} catch (Exception | LinkageError fallbackError) {
|
||||||
|
SKINSG_LOGGER.error("[SkinsG] Default Steve skin could not be loaded; vanilla will use its fallback.", fallbackError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"minVersion": "0.8",
|
"minVersion": "0.8",
|
||||||
"compatibilityLevel": "JAVA_17",
|
"compatibilityLevel": "JAVA_17",
|
||||||
"package": "xyz.nightsync.skinsg.mixin",
|
"package": "xyz.nightsync.skinsg.mixin",
|
||||||
"refmap": "skinsg.refmap.json",
|
"refmap": "skinsg-common-common-refmap.json",
|
||||||
"client": [
|
"client": [
|
||||||
"SkinMagicMixin",
|
"SkinMagicMixin",
|
||||||
"CapeMagicMixin"
|
"CapeMagicMixin"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package xyz.nightsync.skinsg;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class SkinsgConfigTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path configDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reloadNormalizesBlankBaseUrl() throws Exception {
|
||||||
|
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||||
|
Files.writeString(configDir.resolve("skinsg.json"), """
|
||||||
|
{
|
||||||
|
"baseUrl": "",
|
||||||
|
"mode": "ALL",
|
||||||
|
"globalSpeed": 100
|
||||||
|
}
|
||||||
|
""", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertTrue(config.reload());
|
||||||
|
assertEquals(SkinsgConfig.DEFAULT_BASE_URL, config.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedReloadKeepsLastKnownGoodConfiguration() throws Exception {
|
||||||
|
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||||
|
config.baseUrl = "https://example.invalid";
|
||||||
|
Files.writeString(configDir.resolve("skinsg.json"), "{broken", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertFalse(config.reload());
|
||||||
|
assertEquals("https://example.invalid", config.baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package xyz.nightsync.skinsg.client;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class RetryBackoffTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void growsExponentiallyAndCapsAtMaximum() {
|
||||||
|
assertEquals(5_000L, RetryBackoff.delayMillis(1, 5_000L, 300_000L));
|
||||||
|
assertEquals(10_000L, RetryBackoff.delayMillis(2, 5_000L, 300_000L));
|
||||||
|
assertEquals(160_000L, RetryBackoff.delayMillis(6, 5_000L, 300_000L));
|
||||||
|
assertEquals(300_000L, RetryBackoff.delayMillis(7, 5_000L, 300_000L));
|
||||||
|
assertEquals(300_000L, RetryBackoff.delayMillis(Integer.MAX_VALUE, 5_000L, 300_000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void treatsNonPositiveFailureCountAsFirstFailure() {
|
||||||
|
assertEquals(5_000L, RetryBackoff.delayMillis(0, 5_000L, 300_000L));
|
||||||
|
assertEquals(5_000L, RetryBackoff.delayMillis(-10, 5_000L, 300_000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidBounds() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> RetryBackoff.delayMillis(1, 0L, 300_000L));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> RetryBackoff.delayMillis(1, 5_000L, 4_999L));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package xyz.nightsync.skinsg.commands;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import xyz.nightsync.skinsg.SkinsgConfig;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
class SkinsgCommandsTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path configDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposesNightSkinsRootAndReloadCommand() {
|
||||||
|
SkinsgConfig config = SkinsgConfig.load(configDir);
|
||||||
|
SkinsgCommands.Feedback<Object> feedback = new SkinsgCommands.Feedback<>() {
|
||||||
|
@Override
|
||||||
|
public void success(Object source, String message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void failure(Object source, String message) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
LiteralCommandNode<Object> root = SkinsgCommands.build(config, feedback).build();
|
||||||
|
|
||||||
|
assertEquals("nightskins", root.getLiteral());
|
||||||
|
assertNotNull(root.getChild("reload"));
|
||||||
|
assertNotNull(root.getChild("playgif"));
|
||||||
|
assertNotNull(root.getChild("stopgif"));
|
||||||
|
assertNotNull(root.getChild("self"));
|
||||||
|
assertNotNull(root.getChild("speed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ loom {
|
|||||||
forge {
|
forge {
|
||||||
convertAccessWideners = true
|
convertAccessWideners = true
|
||||||
mixinConfig "skinsg.mixins.json"
|
mixinConfig "skinsg.mixins.json"
|
||||||
|
mixinConfig "skinsg.skinlayers3d.mixins.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ configurations {
|
|||||||
dependencies {
|
dependencies {
|
||||||
forge "net.minecraftforge:forge:${rootProject.forge_version}"
|
forge "net.minecraftforge:forge:${rootProject.forge_version}"
|
||||||
modApi "dev.architectury:architectury-forge:${rootProject.architectury_api_version}"
|
modApi "dev.architectury:architectury-forge:${rootProject.architectury_api_version}"
|
||||||
|
modCompileOnly "maven.modrinth:3dskinlayers:BBnR3V86"
|
||||||
|
modLocalRuntime "maven.modrinth:3dskinlayers:BBnR3V86"
|
||||||
|
|
||||||
common(project(path: ":common", configuration: "namedElements")) { transitive false }
|
common(project(path: ":common", configuration: "namedElements")) { transitive false }
|
||||||
shadowBundle project(path: ":common", configuration: "transformProductionForge")
|
shadowBundle project(path: ":common", configuration: "transformProductionForge")
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package xyz.nightsync.skinsg.forge.compat.skinlayers3d;
|
||||||
|
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
|
||||||
|
import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class SkinLayersCompatPlugin implements IMixinConfigPlugin {
|
||||||
|
@Override public void onLoad(String mixinPackage) {}
|
||||||
|
@Override public String getRefMapperConfig() { return null; }
|
||||||
|
@Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
|
||||||
|
return SkinLayersCompatPlugin.class.getClassLoader()
|
||||||
|
.getResource("dev/tr7zw/skinlayers/SkinUtil.class") != null;
|
||||||
|
}
|
||||||
|
@Override public void acceptTargets(Set<String> myTargets, Set<String> otherTargets) {}
|
||||||
|
@Override public List<String> getMixins() { return null; }
|
||||||
|
@Override public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {}
|
||||||
|
@Override public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package xyz.nightsync.skinsg.forge.compat.skinlayers3d.mixin;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
|
import com.mojang.authlib.GameProfile;
|
||||||
|
import com.mojang.authlib.minecraft.MinecraftProfileTexture;
|
||||||
|
import dev.tr7zw.skinlayers.SkinUtil;
|
||||||
|
import dev.tr7zw.skinlayers.accessor.PlayerSettings;
|
||||||
|
import dev.tr7zw.skinlayers.accessor.SkullSettings;
|
||||||
|
import dev.tr7zw.skinlayers.api.Mesh;
|
||||||
|
import dev.tr7zw.skinlayers.api.MeshHelper;
|
||||||
|
import dev.tr7zw.skinlayers.api.SkinLayersAPI;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Mixin(value = SkinUtil.class, remap = false)
|
||||||
|
public abstract class SkinUtilMixin {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger("skinsg");
|
||||||
|
private static final int MAX_HD_SIZE = 2048;
|
||||||
|
private static final long LOG_COOLDOWN_MS = 60_000L;
|
||||||
|
private static final ConcurrentHashMap<String, Long> LAST_LOG = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Inject(method = "setup3dLayers", at = @At("HEAD"), cancellable = true, require = 0)
|
||||||
|
private static void skinsg$setupPlayer(AbstractClientPlayer player, PlayerSettings settings, boolean thinArms,
|
||||||
|
CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
ResourceLocation location = player.getSkinTextureLocation();
|
||||||
|
NativeImage skin = SkinUtil.getTexture(location, null);
|
||||||
|
if (!isHdSkin(skin)) return;
|
||||||
|
|
||||||
|
if (location.equals(settings.getCurrentSkin()) && thinArms == settings.hasThinArms()) {
|
||||||
|
cir.setReturnValue(settings.getHeadMesh() != null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (NativeImage meshMap = downsampleForMesh(skin)) {
|
||||||
|
buildPlayerMeshes(meshMap, settings, thinArms);
|
||||||
|
settings.setCurrentSkin(location);
|
||||||
|
settings.setThinArms(thinArms);
|
||||||
|
cir.setReturnValue(true);
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
settings.clearMeshes();
|
||||||
|
settings.setCurrentSkin(location);
|
||||||
|
settings.setThinArms(thinArms);
|
||||||
|
logFailure("PLAYER_MESH", location, skin, error);
|
||||||
|
cir.setReturnValue(buildFallbackPlayer(settings, thinArms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "setup3dLayers", at = @At("HEAD"), cancellable = true, require = 0)
|
||||||
|
private static void skinsg$setupSkull(GameProfile profile, SkullSettings settings,
|
||||||
|
CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
if (profile == null) return;
|
||||||
|
Map<MinecraftProfileTexture.Type, MinecraftProfileTexture> textures = Minecraft.getInstance()
|
||||||
|
.getSkinManager().getInsecureSkinInformation(profile);
|
||||||
|
MinecraftProfileTexture texture = textures.get(MinecraftProfileTexture.Type.SKIN);
|
||||||
|
if (texture == null) return;
|
||||||
|
ResourceLocation location = Minecraft.getInstance().getSkinManager()
|
||||||
|
.registerTexture(texture, MinecraftProfileTexture.Type.SKIN);
|
||||||
|
setupSkull(location, settings, cir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "setup3dLayers", at = @At("HEAD"), cancellable = true, require = 0)
|
||||||
|
private static void skinsg$setupSkull(ResourceLocation location, SkullSettings settings,
|
||||||
|
CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
setupSkull(location, settings, cir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setupSkull(ResourceLocation location, SkullSettings settings,
|
||||||
|
CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
NativeImage skin = SkinUtil.getTexture(location, settings);
|
||||||
|
if (!isHdSkin(skin)) return;
|
||||||
|
try (NativeImage meshMap = downsampleForMesh(skin)) {
|
||||||
|
settings.setupHeadLayers(meshHelper().create3DMesh(meshMap, 8, 8, 8, 32, 0, false, 0.6f));
|
||||||
|
settings.setInitialized(true);
|
||||||
|
cir.setReturnValue(true);
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
settings.setupHeadLayers(null);
|
||||||
|
settings.setInitialized(true);
|
||||||
|
logFailure("SKULL_MESH", location, skin, error);
|
||||||
|
cir.setReturnValue(buildFallbackSkull(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHdSkin(NativeImage image) {
|
||||||
|
if (image == null) return false;
|
||||||
|
int width = image.getWidth();
|
||||||
|
return width > 64 && width <= MAX_HD_SIZE && image.getHeight() == width
|
||||||
|
&& width % 64 == 0 && (width & (width - 1)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeImage downsampleForMesh(NativeImage source) {
|
||||||
|
NativeImage result = new NativeImage(64, 64, true);
|
||||||
|
int scale = source.getWidth() / 64;
|
||||||
|
try {
|
||||||
|
for (int y = 0; y < 64; y++) {
|
||||||
|
for (int x = 0; x < 64; x++) {
|
||||||
|
int selected = 0;
|
||||||
|
int highestAlpha = -1;
|
||||||
|
for (int sy = 0; sy < scale; sy++) {
|
||||||
|
for (int sx = 0; sx < scale; sx++) {
|
||||||
|
int pixel = source.getPixelRGBA(x * scale + sx, y * scale + sy);
|
||||||
|
int alpha = pixel >>> 24;
|
||||||
|
if (alpha > highestAlpha) {
|
||||||
|
highestAlpha = alpha;
|
||||||
|
selected = pixel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.setPixelRGBA(x, y, selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
result.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void buildPlayerMeshes(NativeImage skin, PlayerSettings settings, boolean thinArms) {
|
||||||
|
MeshHelper helper = meshHelper();
|
||||||
|
settings.clearMeshes();
|
||||||
|
settings.setLeftLegMesh(helper.create3DMesh(skin, 4, 12, 4, 0, 48, true, 0f));
|
||||||
|
settings.setRightLegMesh(helper.create3DMesh(skin, 4, 12, 4, 0, 32, true, 0f));
|
||||||
|
int armWidth = thinArms ? 3 : 4;
|
||||||
|
settings.setLeftArmMesh(helper.create3DMesh(skin, armWidth, 12, 4, 48, 48, true, -2f));
|
||||||
|
settings.setRightArmMesh(helper.create3DMesh(skin, armWidth, 12, 4, 40, 32, true, -2f));
|
||||||
|
settings.setTorsoMesh(helper.create3DMesh(skin, 8, 12, 4, 16, 32, true, 0f));
|
||||||
|
settings.setHeadMesh(helper.create3DMesh(skin, 8, 8, 8, 32, 0, false, 0.6f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean buildFallbackPlayer(PlayerSettings settings, boolean thinArms) {
|
||||||
|
try (NativeImage fallback = opaqueMeshMap()) {
|
||||||
|
buildPlayerMeshes(fallback, settings, thinArms);
|
||||||
|
return settings.getHeadMesh() != null;
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
settings.clearMeshes();
|
||||||
|
logFailure("PLAYER_FALLBACK", settings.getCurrentSkin(), null, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean buildFallbackSkull(SkullSettings settings) {
|
||||||
|
try (NativeImage fallback = opaqueMeshMap()) {
|
||||||
|
Mesh mesh = meshHelper().create3DMesh(fallback, 8, 8, 8, 32, 0, false, 0.6f);
|
||||||
|
settings.setupHeadLayers(mesh);
|
||||||
|
return mesh != null;
|
||||||
|
} catch (Exception | LinkageError error) {
|
||||||
|
settings.setupHeadLayers(null);
|
||||||
|
logFailure("SKULL_FALLBACK", settings.getLastTexture(), null, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeImage opaqueMeshMap() {
|
||||||
|
NativeImage image = new NativeImage(64, 64, true);
|
||||||
|
image.fillRect(0, 0, 64, 64, 0xFFFFFFFF);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MeshHelper meshHelper() {
|
||||||
|
return SkinLayersAPI.getMeshHelper();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void logFailure(String stage, ResourceLocation resource, NativeImage image, Throwable error) {
|
||||||
|
String key = stage + '|' + resource + '|' + error.getClass().getName();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long previous = LAST_LOG.put(key, now);
|
||||||
|
if (previous == null || now - previous >= LOG_COOLDOWN_MS) {
|
||||||
|
String dimensions = image == null ? "unknown" : image.getWidth() + "x" + image.getHeight();
|
||||||
|
LOGGER.error("[SkinsG/3DSL Compat] HD skin processing failed; using safe fallback. "
|
||||||
|
+ "stage={}, resource={}, dimensions={}, thread={}",
|
||||||
|
stage, resource, dimensions, Thread.currentThread().getName(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,21 +11,21 @@ description = '''Client-side HD skins (up to 2048) and animated GIF cape support
|
|||||||
|
|
||||||
[[dependencies.skinsg]]
|
[[dependencies.skinsg]]
|
||||||
modId = "forge"
|
modId = "forge"
|
||||||
type = "required"
|
mandatory = true
|
||||||
versionRange = "[47,)"
|
versionRange = "[47,)"
|
||||||
ordering = "NONE"
|
ordering = "NONE"
|
||||||
side = "CLIENT"
|
side = "CLIENT"
|
||||||
|
|
||||||
[[dependencies.skinsg]]
|
[[dependencies.skinsg]]
|
||||||
modId = "minecraft"
|
modId = "minecraft"
|
||||||
type = "required"
|
mandatory = true
|
||||||
versionRange = "[1.20.1,1.21)"
|
versionRange = "[1.20.1,1.21)"
|
||||||
ordering = "NONE"
|
ordering = "NONE"
|
||||||
side = "CLIENT"
|
side = "CLIENT"
|
||||||
|
|
||||||
[[dependencies.skinsg]]
|
[[dependencies.skinsg]]
|
||||||
modId = "architectury"
|
modId = "architectury"
|
||||||
type = "required"
|
mandatory = false
|
||||||
versionRange = "[9.2.14,)"
|
versionRange = "[9.2.14,)"
|
||||||
ordering = "AFTER"
|
ordering = "AFTER"
|
||||||
side = "CLIENT"
|
side = "CLIENT"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"description": "HD Skin Support (Gravit)",
|
||||||
|
"pack_format": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"required": false,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"compatibilityLevel": "JAVA_17",
|
||||||
|
"package": "xyz.nightsync.skinsg.forge.compat.skinlayers3d.mixin",
|
||||||
|
"plugin": "xyz.nightsync.skinsg.forge.compat.skinlayers3d.SkinLayersCompatPlugin",
|
||||||
|
"client": ["SkinUtilMixin"],
|
||||||
|
"injectors": { "defaultRequire": 0 }
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ org.gradle.parallel=true
|
|||||||
# --- Mod ---
|
# --- Mod ---
|
||||||
mod_id=skinsg
|
mod_id=skinsg
|
||||||
mod_name=HD Skin Support (Gravit)
|
mod_name=HD Skin Support (Gravit)
|
||||||
mod_version=0.1.0
|
mod_version=0.1.4
|
||||||
mod_group_id=xyz.nightsync.skinsg
|
mod_group_id=xyz.nightsync.skinsg
|
||||||
mod_license=All Rights Reserved
|
mod_license=All Rights Reserved
|
||||||
mod_author=Nghtly
|
mod_author=Nghtly
|
||||||
|
|||||||
Reference in new issue
Block a user