Author SHA1 Message Date
f1den 82da98cc35 Enable NightSync GIF capes by default 2026-07-11 22:06:36 +03:00
f1den c73bb5b19a Restore Forge resource pack metadata 2026-07-11 21:52:53 +03:00
f1den efac5d242b Add Forge 1.20.1 HD 3D Skin Layers compatibility 2026-07-11 19:36:48 +03:00
f1denandClaude Opus 4.8 2034c75f7d fix(forge): correct mods.toml dependency schema and mixin refmap name
Build / build (push) Successful in 19m56s
Two bugs made the built forge jar unusable in a Forge 1.20.1 production
client:

- mods.toml used the NeoForge dependency schema (type = "required")
  instead of Forge's mandatory = true. Forge threw
  InvalidModFileException during the mods-folder scan, which aborts
  discovery of the ENTIRE folder -> the whole modpack failed to load
  (only Minecraft+FML showed). architectury set mandatory = false since
  the code doesn't actually use it.

- skinsg.mixins.json referenced refmap "skinsg.refmap.json" but Loom
  bundles it as "skinsg-common-common-refmap.json". With no refmap
  loaded, CapeMagicMixin's @Inject on getCloakTextureLocation could not
  be remapped to its SRG name -> InvalidInjectionException -> hard crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 02:36:21 +03:00
f1den 8bc92b2341 Port multiloader build to Fabric and Forge 1.20.1
Build / build (push) Failing after 2h50m17s
2026-07-05 18:36:57 +03:00
f1den c11a278050 Multiloader scaffold: Architectury common+fabric+forge+neoforge (1.21.1)
Convert the single-loader NeoForge project into an Architectury multiloader:
- root/settings/subproject build.gradle, Gradle pinned to 8.10.2 (Loom needs 8.x)
- common: generic SkinsgCommands<S> + SkinsgClient bootstrap
- per-loader entrypoints (Fabric ClientModInitializer, Forge/NeoForge @Mod) + metadata
- migrate-to-multiloader.sh moves the loader/version-agnostic core into common/
- MULTILOADER.md: build/toolchain/proxy + 1.20.1 switch incl. rewritten cape mixin

main (working NeoForge 1.21.1) untouched.
2026-07-05 18:10:53 +03:00
Nghtly 6e3de2716c CI: bypass proxy for the internal git host
Build / build (push) Failing after 3h11m7s
2026-07-04 18:27:36 +03:00
Nghtly 2d1c9963e9 CI: route downloads through internal proxy
Build / build (push) Failing after 4m39s
2026-07-04 18:06:37 +03:00
Nghtly 6258df003f Add license and README, document GIF cape + backend integration, cache CI
Build / build (push) Has been cancelled
2026-07-04 18:01:30 +03:00
40 changed files with 1038 additions and 405 deletions
+15 -2
View File
@@ -2,9 +2,21 @@ name: Build
on:
push:
branches: [main]
branches: [main, multiloader]
pull_request:
env:
http_proxy: http://172.21.5.18:1080
https_proxy: http://172.21.5.18:1080
HTTP_PROXY: http://172.21.5.18:1080
HTTPS_PROXY: http://172.21.5.18:1080
no_proxy: 192.168.88.60,git.mcbcorp.ru,localhost,127.0.0.1
NO_PROXY: 192.168.88.60,git.mcbcorp.ru,localhost,127.0.0.1
GRADLE_OPTS: >-
-Dhttp.proxyHost=172.21.5.18 -Dhttp.proxyPort=1080
-Dhttps.proxyHost=172.21.5.18 -Dhttps.proxyPort=1080
-Dhttp.nonProxyHosts=192.168.88.60|git.mcbcorp.ru|localhost|127.0.0.1
jobs:
build:
runs-on: ubuntu-latest
@@ -13,7 +25,8 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
java-version: '17'
cache: gradle
- run: chmod +x ./gradlew
- run: ./gradlew build --no-daemon
- uses: actions/upload-artifact@v3
+14 -1
View File
@@ -4,6 +4,18 @@ on:
push:
tags: ['v*']
env:
http_proxy: http://172.21.5.18:1080
https_proxy: http://172.21.5.18:1080
HTTP_PROXY: http://172.21.5.18:1080
HTTPS_PROXY: http://172.21.5.18:1080
no_proxy: 192.168.88.60,git.mcbcorp.ru,localhost,127.0.0.1
NO_PROXY: 192.168.88.60,git.mcbcorp.ru,localhost,127.0.0.1
GRADLE_OPTS: >-
-Dhttp.proxyHost=172.21.5.18 -Dhttp.proxyPort=1080
-Dhttps.proxyHost=172.21.5.18 -Dhttps.proxyPort=1080
-Dhttp.nonProxyHosts=192.168.88.60|git.mcbcorp.ru|localhost|127.0.0.1
jobs:
release:
runs-on: ubuntu-latest
@@ -12,7 +24,8 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
java-version: '17'
cache: gradle
- run: chmod +x ./gradlew
- run: ./gradlew build --no-daemon
- uses: akkuman/gitea-release-action@v1
+21
View File
@@ -0,0 +1,21 @@
HDSkinSupportGravit License
Copyright (c) 2026 Nghtly
Permission is hereby granted, free of charge, to any person to use, copy,
modify, merge, publish, and distribute this software and its source code, in
whole or in part, for any purpose.
Condition for commercial use:
Any commercial use, distribution, or product (paid, monetized, or as part of a
paid service) that includes this software or a work derived from it must give
visible credit to the original author, "Nghtly" — for example in the product
description, credits screen, or accompanying documentation.
Non-commercial use carries no attribution requirement.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR ITS USE.
+60
View File
@@ -0,0 +1,60 @@
# skinsg multiloader notes
Branch `multiloader` builds the mod for **Minecraft 1.20.1** on **Fabric** and **Forge** from one
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.
## Layout
- `common/` - shared config, GIF decoder, cape manager, mixins, and generic Brigadier command tree.
- `fabric/` - Fabric client entrypoint and `fabric.mod.json`.
- `forge/` - Forge client entrypoint and `mods.toml`.
- `neoforge/` - retained scaffold, but not included in `enabled_platforms` for the 1.20.1 build.
The old single-loader `src/` tree has been migrated into `common/`; do not run the migration helper
again on this branch.
## Versions
Configured in `gradle.properties`:
- Minecraft `1.20.1`
- Architectury API `9.2.14`
- Fabric Loader `0.16.9`
- Fabric API `0.92.5+1.20.1`
- Forge `1.20.1-47.4.10`
- Parchment `2023.09.03`
- Java `17`
## Build
```bash
./gradlew build
```
Runtime jars are collected into:
- `build/libs/skinsg-fabric-0.1.0.jar`
- `build/libs/skinsg-forge-0.1.0.jar`
The per-loader `build/libs` folders still contain intermediate/source/dev-shadow jars.
## 1.20.1 port details
- `CapeMagicMixin` targets `AbstractClientPlayer#getCloakTextureLocation`, because 1.20.1 does not
expose the 1.21 `PlayerSkin` return object.
- `CapeManager` uses the 1.20.1 `new ResourceLocation(namespace, path)` constructor.
- `skinsg.mixins.json` uses `JAVA_17`.
- `settings.gradle` includes only platforms listed in `enabled_platforms`.
## Manual runtime checks
Compile/build is verified, but actual rendering still needs an in-game client check:
- Fabric 1.20.1 with Fabric API and Architectury API.
- Forge 1.20.1-47.4.10 with Architectury API.
- A configured `config/skinsg.json` `baseUrl` serving `GET /api/minecraft/cape/<uuid>?gif`.
+45 -16
View File
@@ -1,27 +1,51 @@
# SkinsG
# HDSkinSupportGravit
Client-side Minecraft mod that adds **HD skins** (up to 2048×2048) and **animated GIF capes**
(with HD & transparency), driven by a launcher/site asset API keyed on player UUID.
Client-side Minecraft mod that adds **HD skins** (up to 2048×2048) and, importantly,
**animated GIF cape support** — including HD and transparent GIFs — on top of a UUID-keyed
asset API. Built for GravitLauncher setups but backend-agnostic.
Currently targets **NeoForge 1.21.1**. Fabric/Forge and 1.20.1 / 1.19.2 are planned — the core
(GIF decoder, cape state, mixins) is written to be loader/version agnostic.
This branch targets **Fabric and Forge 1.20.1** through an Architectury multiloader build. The core
(GIF decoder, cape state, mixins) is shared between loaders.
## Features
- HD skins: the legacy-skin conversion is rescaled so 128/256/…/2048-wide skins render correctly.
- GIF capes: decoded once, then played back by copying a pre-composited frame per tick — no
per-frame decoding, no AWT, so it doesn't cost FPS. Playback speed comes from the GIF itself.
- **HD skins**: the legacy-skin conversion is rescaled so 128/256/…/2048-wide skins render correctly.
- **GIF capes**: animated capes decoded from GIF (HD, transparency, per-frame timing). Decoded once,
then played back by copying a pre-composited frame per tick — no per-frame decoding, no AWT, so it
doesn't cost FPS. Playback speed comes from the GIF itself (overridable per player).
- HD PNG capes fall through to the vanilla renderer untouched.
- Per-player control and persistence (see below).
- 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)
The mod talks to your existing site/launcher backend over plain HTTP — no server-side plugin needed.
Set one value (`baseUrl`) in the config and the mod requests:
```
GET <baseUrl>/api/minecraft/cape/<uuid>?gif
```
If the response is a GIF it is animated; anything else (PNG / 404) keeps the vanilla cape. This maps
cleanly onto most launcher-site engines (DLE-based sites, custom backends, etc.) — you just expose
that one endpoint returning the player's GIF cape by UUID. HD skins use the normal skin URL your
launcher already serves, so they need no extra endpoint.
## Install
Drop `skinsg-x.y.z.jar` into your `mods/` folder on a NeoForge 1.21.1 client. Client-side only.
Drop the matching `skinsg-fabric-x.y.z.jar` or `skinsg-forge-x.y.z.jar` into your Minecraft 1.20.1
client's `mods/` folder. Client-side only.
## Configuration
On first launch the mod writes `config/skinsg.json` with an **empty** `baseUrl`. Nothing is
fetched until you set it.
On first launch the NightSync build writes `config/skinsg.json` with
`https://nightsync.xyz` as `baseUrl`. Existing empty configs are migrated automatically.
```json
{
@@ -34,8 +58,7 @@ fetched until you set it.
}
```
- `baseUrl` — root of your asset API. The mod requests `"<baseUrl>/api/minecraft/cape/<uuid>?gif"`;
if the response is a GIF it is animated, otherwise the vanilla cape is kept.
- `baseUrl` — root of your asset API (no hardcoded host in the mod).
- `globalSpeed` — playback speed in percent (`100` = the GIF's own timing).
- The rest is managed by the in-game commands and shouldn't be edited by hand.
@@ -65,8 +88,14 @@ All settings persist in `config/skinsg.json`.
./gradlew build
```
Output: `build/libs/skinsg-<version>.jar`. Requires JDK 21.
Runtime jars:
- `build/libs/skinsg-fabric-<version>.jar`
- `build/libs/skinsg-forge-<version>.jar`
Requires JDK 17.
## License
All rights reserved.
Free to use and modify for any purpose. Commercial use must credit the author (Nghtly).
See [LICENSE](LICENSE).
+60 -150
View File
@@ -1,168 +1,78 @@
// Root build for the Architectury multiloader (Fabric / Forge).
// Target MC version is driven by gradle.properties (minecraft_version + the
// *_version props). This branch targets Minecraft 1.20.1 / Java 17.
plugins {
id 'java-library'
id 'maven-publish'
id 'net.neoforged.gradle.userdev' version '7.1.20'
id "base"
id "architectury-plugin" version "3.4.164"
id "dev.architectury.loom" version "1.7.435" apply false
id "com.gradleup.shadow" version "8.3.5" apply false
}
tasks.named('wrapper', Wrapper).configure {
// Define wrapper values here so as to not have to always do so when updating gradlew.properties.
// Switching this to Wrapper.DistributionType.ALL will download the full gradle sources that comes with
// documentation attached on cursor hover of gradle classes and methods. However, this comes with increased
// file size for Gradle. If you do switch this to ALL, run the Gradle wrapper task twice afterwards.
// (Verify by checking gradle/wrapper/gradle-wrapper.properties to see if distributionUrl now points to `-all`)
distributionType = Wrapper.DistributionType.BIN
architectury {
minecraft = rootProject.minecraft_version
}
version = mod_version
group = mod_group_id
repositories {
// Add here additional repositories if required by some of the dependencies below.
allprojects {
group = rootProject.mod_group_id
version = rootProject.mod_version
}
base {
archivesName = mod_id
}
subprojects {
apply plugin: "dev.architectury.loom"
apply plugin: "architectury-plugin"
apply plugin: "maven-publish"
// Mojang ships Java 21 to end users in 1.21.1, so mods should target Java 21.
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
def isLegacy = rootProject.minecraft_version == "1.20.1"
def javaVer = isLegacy ? 17 : 21
//minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg')
//minecraft.accessTransformers.entry public net.minecraft.client.Minecraft textureManager # textureManager
// Default run configurations.
// These can be tweaked, removed, or duplicated as needed.
runs {
// applies to all the run configs below
configureEach {
// Recommended logging data for a userdev environment
// The markers can be added/remove as needed separated by commas.
// "SCAN": For mods scan.
// "REGISTRIES": For firing of registry events.
// "REGISTRYDUMP": For getting the contents of all registries.
systemProperty 'forge.logging.markers', 'REGISTRIES'
// Recommended logging level for the console
// You can set various levels here.
// Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels
systemProperty 'forge.logging.console.level', 'debug'
modSource project.sourceSets.main
base {
// built jars: skinsg-fabric-<ver>.jar, skinsg-forge-<ver>.jar, ...
archivesName = "${rootProject.mod_id}-${project.name}"
}
client {
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
server {
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
argument '--nogui'
}
// This run config launches GameTestServer and runs all registered gametests, then exits.
// By default, the server will crash when no gametests are provided.
// The gametest system is also enabled by default for other run configs under the /test command.
gameTestServer {
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
data {
// example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it
// workingDirectory project.file('run-data')
// Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources.
arguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath()
}
}
// Include resources generated by data generators.
sourceSets.main.resources { srcDir 'src/generated/resources' }
// Sets up a dependency configuration called 'localRuntime'.
// This configuration should be used instead of 'runtimeOnly' to declare
// a dependency that will be present for runtime testing but that is
// "optional", meaning it will not be pulled by dependents of this mod.
configurations {
runtimeClasspath.extendsFrom localRuntime
}
dependencies {
// Specify the version of Minecraft to use.
// Depending on the plugin applied there are several options. We will assume you applied the userdev plugin as shown above.
// The group for userdev is net.neoforged, the module name is neoforge, and the version is the same as the neoforge version.
// You can however also use the vanilla plugin (net.neoforged.gradle.vanilla) to use a version of Minecraft without the neoforge loader.
// And its provides the option to then use net.minecraft as the group, and one of; client, server or joined as the module name, plus the game version as version.
// For all intends and purposes: You can treat this dependency as if it is a normal library you would use.
implementation "net.neoforged:neoforge:${neo_version}"
// Example optional mod dependency with JEI
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
// Example mod dependency using a mod jar from ./libs with a flat dir repository
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
// The group id is ignored when searching -- in this case, it is "blank"
// implementation "blank:coolmod-${mc_version}:${coolmod_version}"
// Example mod dependency using a file as dependency
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
// 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.
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments.
// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html
tasks.withType(ProcessResources).configureEach {
var replaceProperties = [
minecraft_version : minecraft_version,
minecraft_version_range: minecraft_version_range,
neo_version : neo_version,
loader_version_range : loader_version_range,
mod_id : mod_id,
mod_name : mod_name,
mod_license : mod_license,
mod_version : mod_version,
]
inputs.properties replaceProperties
filesMatching(['META-INF/neoforge.mods.toml']) {
expand replaceProperties
}
}
// Example configuration to allow publishing using the maven-publish plugin
publishing {
publications {
register('mavenJava', MavenPublication) {
from components.java
}
}
repositories {
maven {
url "file://${project.projectDir}/repo"
maven { url "https://maven.architectury.dev/" }
maven { url "https://api.modrinth.com/maven" }
maven { url "https://maven.neoforged.net/releases/" }
maven { url "https://maven.minecraftforge.net/" }
maven { url "https://maven.parchmentmc.org" }
}
dependencies {
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
mappings loom.layered {
officialMojangMappings()
parchment("org.parchmentmc.data:parchment-${rootProject.parchment_minecraft}:${rootProject.parchment_version}@zip")
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.toVersion(javaVer)
targetCompatibility = JavaVersion.toVersion(javaVer)
toolchain.languageVersion = JavaLanguageVersion.of(javaVer)
}
// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior.
idea {
module {
downloadSources = true
downloadJavadoc = true
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
options.release = javaVer
}
}
def runtimePlatforms = rootProject.enabled_platforms.split(",").collect { it.trim() }.findAll { !it.isEmpty() }
tasks.register("collectRuntimeJars", Copy) {
into layout.buildDirectory.dir("libs")
doFirst {
delete(layout.buildDirectory.dir("libs"))
}
runtimePlatforms.each { platform ->
def remap = project(":${platform}").tasks.named("remapJar")
dependsOn remap
from(remap.flatMap { it.archiveFile })
}
}
tasks.named("build") {
dependsOn tasks.named("collectRuntimeJars")
}
+10
View File
@@ -0,0 +1,10 @@
architectury {
common(rootProject.enabled_platforms.split(","))
}
dependencies {
// Fabric Loader is used at compile time only for the @Environment annotations,
// which Architectury remaps to the right annotation on each platform.
modImplementation "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}"
modApi "dev.architectury:architectury:${rootProject.architectury_api_version}"
}
@@ -0,0 +1,35 @@
package xyz.nightsync.skinsg;
import net.minecraft.client.Minecraft;
import xyz.nightsync.skinsg.client.CapeManager;
import java.nio.file.Path;
/**
* Loader-agnostic client bootstrap. Each loader entrypoint calls {@link #init(Path)}
* with its own config directory, then wires the client command + client tick using
* {@link xyz.nightsync.skinsg.commands.SkinsgCommands} and {@link CapeManager}.
*/
public final class SkinsgClient {
private static SkinsgConfig config;
private SkinsgClient() {}
public static void init(Path configDir) {
config = SkinsgConfig.load(configDir);
CapeManager.init(config, () -> {
var p = Minecraft.getInstance().player;
return p != null ? p.getUUID() : null;
});
}
public static SkinsgConfig config() {
return config;
}
/** Call every client tick from the loader event. */
public static void tick() {
CapeManager.clientTick();
}
}
@@ -18,8 +18,9 @@ public final class SkinsgConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
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 Set<String> enabled = new LinkedHashSet<>();
public Set<String> blocked = new LinkedHashSet<>();
@@ -41,12 +42,12 @@ public final class SkinsgConfig {
}
cfg.normalize();
cfg.file = f;
if (fresh) cfg.save();
cfg.save();
return cfg;
}
private void normalize() {
if (baseUrl == null) baseUrl = "";
if (baseUrl == null || baseUrl.isBlank()) baseUrl = DEFAULT_BASE_URL;
baseUrl = baseUrl.trim();
while (baseUrl.endsWith("/")) baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
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.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xyz.nightsync.skinsg.SkinsgConfig;
import xyz.nightsync.skinsg.gif.GifDecoder;
import xyz.nightsync.skinsg.gif.GifImage;
@@ -35,6 +37,7 @@ public final class CapeManager {
private static final String MODID = "skinsg";
private static final int MAX_CACHE = 96;
private static final int MAX_BYTES = 12 * 1024 * 1024;
private static final Logger LOGGER = LoggerFactory.getLogger("skinsg");
private static SkinsgConfig config;
private static Supplier<UUID> selfProvider = () -> null;
@@ -110,8 +113,9 @@ public final class CapeManager {
return;
}
buildTexture(entry, GifDecoder.decode(bytes, LIMITS));
} catch (Throwable t) {
} catch (Exception | LinkageError t) {
entry.noAnimation = true;
LOGGER.error("[SkinsG/Cape] Failed to load animated cape for uuid={}", uuid, t);
}
});
return entry;
@@ -122,11 +126,12 @@ public final class CapeManager {
try {
DynamicTexture tex = new DynamicTexture(gif.width, gif.height, true);
((AbstractTexture) tex).setFilter(false, false);
ResourceLocation id = ResourceLocation.fromNamespaceAndPath(MODID, "cape/" + entry.uuid + "/anim");
ResourceLocation id = new ResourceLocation(MODID, "cape/" + entry.uuid + "/anim");
Minecraft.getInstance().getTextureManager().register(id, tex);
entry.attach(gif, tex, id);
} catch (Throwable t) {
} catch (Exception | LinkageError t) {
entry.noAnimation = true;
LOGGER.error("[SkinsG/Cape] Failed to create animated cape texture for uuid={}", entry.uuid, t);
}
});
}
@@ -140,7 +145,8 @@ public final class CapeManager {
c.setConnectTimeout(7000);
c.setReadTimeout(10000);
c.setRequestProperty("User-Agent", MODID + "/1.0");
if (c.getResponseCode() != 200) return null;
int status = c.getResponseCode();
if (status != 200) throw new IOException("Cape endpoint returned HTTP " + status);
int len = c.getContentLength();
if (len > MAX_BYTES) return null;
try (InputStream in = c.getInputStream()) {
@@ -0,0 +1,122 @@
package xyz.nightsync.skinsg.commands;
import com.mojang.brigadier.arguments.ArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.SuggestionProvider;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import xyz.nightsync.skinsg.SkinsgConfig;
import xyz.nightsync.skinsg.client.CapeManager;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* Loader-agnostic client command tree, built with RAW Brigadier builders so it is
* generic over the command source type {@code S}. Fabric client commands use
* {@code FabricClientCommandSource}; Forge/NeoForge use {@code CommandSourceStack}.
* Each loader supplies a {@link Feedback} that speaks to its own source, then
* registers {@code build(cfg, feedback)} into its client dispatcher.
*/
public final class SkinsgCommands {
/** Per-loader chat feedback so common code doesn't touch a loader source type. */
public interface Feedback<S> {
void success(S source, String message);
void failure(S source, String message);
}
public static final String ALL = "@a";
private SkinsgCommands() {}
public static <S> LiteralArgumentBuilder<S> build(SkinsgConfig cfg, Feedback<S> fb) {
SuggestionProvider<S> targets = (ctx, b) -> {
b.suggest(ALL);
var conn = Minecraft.getInstance().getConnection();
if (conn != null) {
for (PlayerInfo pi : conn.getOnlinePlayers()) b.suggest(pi.getProfile().getName());
}
return b.buildFuture();
};
return SkinsgCommands.<S>literal("skinsg")
.then(SkinsgCommands.<S>literal("playgif")
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); })
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
.executes(c -> playgif(fb, cfg, c))))
.then(SkinsgCommands.<S>literal("stopgif")
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); })
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
.executes(c -> stopgif(fb, cfg, c))))
.then(SkinsgCommands.<S>literal("self")
.executes(c -> { cfg.setSelf(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: self only"); }))
.then(SkinsgCommands.<S>literal("speed")
.then(SkinsgCommands.<S, String>argument("target", StringArgumentType.word()).suggests(targets)
.then(SkinsgCommands.<S, Integer>argument("percent", IntegerArgumentType.integer(1, 2000))
.executes(c -> speed(fb, cfg, c)))));
}
private static <S> LiteralArgumentBuilder<S> literal(String name) {
return LiteralArgumentBuilder.literal(name);
}
private static <S, T> RequiredArgumentBuilder<S, T> argument(String name, ArgumentType<T> type) {
return RequiredArgumentBuilder.argument(name, type);
}
private static <S> int playgif(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
String target = StringArgumentType.getString(c, "target");
if (ALL.equals(target)) { cfg.setAll(); return ok(fb, c, "GIF capes: everyone"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(fb, c, "player not found: " + target);
cfg.enableFor(ids);
return ok(fb, c, "GIF capes: on for " + target);
}
private static <S> int stopgif(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
String target = StringArgumentType.getString(c, "target");
if (ALL.equals(target)) { cfg.setNone(); CapeManager.releaseAll(); return ok(fb, c, "GIF capes: off"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(fb, c, "player not found: " + target);
cfg.disableFor(ids);
for (UUID id : ids) CapeManager.releaseIfPresent(id);
return ok(fb, c, "GIF capes: stopped for " + target);
}
private static <S> int speed(Feedback<S> fb, SkinsgConfig cfg, CommandContext<S> c) {
String target = StringArgumentType.getString(c, "target");
int percent = IntegerArgumentType.getInteger(c, "percent");
if (ALL.equals(target)) { cfg.setGlobalSpeed(percent); return ok(fb, c, "GIF speed: " + percent + "% (all)"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(fb, c, "player not found: " + target);
for (UUID id : ids) cfg.setSpeed(id, percent);
return ok(fb, c, "GIF speed: " + percent + "% for " + target);
}
private static Set<UUID> resolve(String name) {
Set<UUID> out = new HashSet<>();
var conn = Minecraft.getInstance().getConnection();
if (conn != null) {
for (PlayerInfo pi : conn.getOnlinePlayers()) {
if (pi.getProfile().getName().equalsIgnoreCase(name)) out.add(pi.getProfile().getId());
}
}
return out;
}
private static <S> int ok(Feedback<S> fb, CommandContext<S> c, String text) {
fb.success(c.getSource(), "[skinsg] " + text);
return 1;
}
private static <S> int err(Feedback<S> fb, CommandContext<S> c, String text) {
fb.failure(c.getSource(), "[skinsg] " + text);
return 0;
}
}
@@ -2,7 +2,6 @@ package xyz.nightsync.skinsg.mixin;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.resources.PlayerSkin;
import net.minecraft.resources.ResourceLocation;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@@ -13,14 +12,10 @@ import xyz.nightsync.skinsg.client.CapeManager;
@Mixin(AbstractClientPlayer.class)
public abstract class CapeMagicMixin {
@Inject(method = "getSkin", at = @At("RETURN"), cancellable = true)
private void skinsg$overrideCape(CallbackInfoReturnable<PlayerSkin> cir) {
@Inject(method = "getCloakTextureLocation", at = @At("RETURN"), cancellable = true)
private void skinsg$overrideCape(CallbackInfoReturnable<ResourceLocation> cir) {
GameProfile profile = ((AbstractClientPlayer) (Object) this).getGameProfile();
ResourceLocation animated = CapeManager.getCape(profile.getId());
if (animated == null) return;
PlayerSkin s = cir.getReturnValue();
if (s == null) return;
cir.setReturnValue(new PlayerSkin(
s.texture(), s.textureUrl(), animated, s.elytraTexture(), s.model(), s.secure()));
if (animated != null) cir.setReturnValue(animated);
}
}
@@ -2,6 +2,10 @@ package xyz.nightsync.skinsg.mixin;
import com.mojang.blaze3d.platform.NativeImage;
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.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
@@ -18,6 +22,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
*/
@Mixin(HttpTexture.class)
public abstract class SkinMagicMixin {
private static final Logger SKINSG_LOGGER = LoggerFactory.getLogger("skinsg");
@Shadow
private static void setNoAlpha(NativeImage img, int x1, int y1, int x2, int y2) {}
@@ -27,8 +32,9 @@ public abstract class SkinMagicMixin {
@Inject(method = "processLegacySkin", at = @At("HEAD"), cancellable = true)
private void skinsg$processLegacySkin(NativeImage image, CallbackInfoReturnable<@Nullable NativeImage> cir) {
final int h = image.getHeight();
final int w = image.getWidth();
try {
final int h = image.getHeight();
final int w = image.getWidth();
final int s = w / 8;
final int x64 = s * 8;
@@ -80,5 +86,28 @@ public abstract class SkinMagicMixin {
cir.setReturnValue(image);
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 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;
}
}
}
@@ -1,9 +1,9 @@
{
"required": true,
"minVersion": "0.8",
"compatibilityLevel": "JAVA_21",
"compatibilityLevel": "JAVA_17",
"package": "xyz.nightsync.skinsg.mixin",
"refmap": "skinsg.refmap.json",
"refmap": "skinsg-common-common-refmap.json",
"client": [
"SkinMagicMixin",
"CapeMagicMixin"
+42
View File
@@ -0,0 +1,42 @@
plugins {
id "com.gradleup.shadow"
}
architectury {
platformSetupLoomIde()
fabric()
}
configurations {
common
shadowBundle
compileClasspath.extendsFrom common
runtimeClasspath.extendsFrom common
developmentFabric.extendsFrom common
}
dependencies {
modImplementation "net.fabricmc:fabric-loader:${rootProject.fabric_loader_version}"
modApi "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
modApi "dev.architectury:architectury-fabric:${rootProject.architectury_api_version}"
common(project(path: ":common", configuration: "namedElements")) { transitive false }
shadowBundle project(path: ":common", configuration: "transformProductionFabric")
}
processResources {
inputs.property "version", project.version
filesMatching("fabric.mod.json") {
expand version: project.version
}
}
shadowJar {
configurations = [project.configurations.shadowBundle]
archiveClassifier = "dev-shadow"
}
remapJar {
input.set shadowJar.archiveFile
archiveClassifier = null
}
+1
View File
@@ -0,0 +1 @@
loom.platform=fabric
@@ -0,0 +1,26 @@
package xyz.nightsync.skinsg.fabric;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.network.chat.Component;
import xyz.nightsync.skinsg.SkinsgClient;
import xyz.nightsync.skinsg.commands.SkinsgCommands;
public final class SkinsgFabric implements ClientModInitializer {
private static final SkinsgCommands.Feedback<FabricClientCommandSource> FEEDBACK = new SkinsgCommands.Feedback<>() {
@Override public void success(FabricClientCommandSource s, String m) { s.sendFeedback(Component.literal(m)); }
@Override public void failure(FabricClientCommandSource s, String m) { s.sendError(Component.literal(m)); }
};
@Override
public void onInitializeClient() {
SkinsgClient.init(FabricLoader.getInstance().getConfigDir());
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registry) ->
dispatcher.register(SkinsgCommands.build(SkinsgClient.config(), FEEDBACK)));
ClientTickEvents.END_CLIENT_TICK.register(mc -> SkinsgClient.tick());
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "skinsg",
"version": "${version}",
"name": "HD Skin Support (Gravit)",
"description": "Client-side HD skins (up to 2048) and animated GIF cape support.",
"authors": ["Nghtly"],
"license": "All Rights Reserved",
"environment": "client",
"entrypoints": {
"client": ["xyz.nightsync.skinsg.fabric.SkinsgFabric"]
},
"mixins": ["skinsg.mixins.json"],
"depends": {
"fabricloader": ">=0.16",
"minecraft": "~1.20.1",
"java": ">=17",
"fabric-api": "*",
"architectury": ">=9.2.14"
}
}
+51
View File
@@ -0,0 +1,51 @@
plugins {
id "com.gradleup.shadow"
}
architectury {
platformSetupLoomIde()
forge()
}
loom {
forge {
convertAccessWideners = true
mixinConfig "skinsg.mixins.json"
mixinConfig "skinsg.skinlayers3d.mixins.json"
}
}
configurations {
common
shadowBundle
compileClasspath.extendsFrom common
runtimeClasspath.extendsFrom common
developmentForge.extendsFrom common
}
dependencies {
forge "net.minecraftforge:forge:${rootProject.forge_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 }
shadowBundle project(path: ":common", configuration: "transformProductionForge")
}
processResources {
inputs.property "version", project.version
filesMatching("META-INF/mods.toml") {
expand version: project.version
}
}
shadowJar {
configurations = [project.configurations.shadowBundle]
archiveClassifier = "dev-shadow"
}
remapJar {
input.set shadowJar.archiveFile
archiveClassifier = null
}
+1
View File
@@ -0,0 +1 @@
loom.platform=forge
@@ -0,0 +1,32 @@
package xyz.nightsync.skinsg.forge;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RegisterClientCommandsEvent;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.loading.FMLPaths;
import xyz.nightsync.skinsg.SkinsgClient;
import xyz.nightsync.skinsg.commands.SkinsgCommands;
@Mod("skinsg")
public final class SkinsgForge {
private static final SkinsgCommands.Feedback<CommandSourceStack> FEEDBACK = new SkinsgCommands.Feedback<>() {
@Override public void success(CommandSourceStack s, String m) { s.sendSuccess(() -> Component.literal(m), false); }
@Override public void failure(CommandSourceStack s, String m) { s.sendFailure(Component.literal(m)); }
};
public SkinsgForge() {
// Client-side only; guard so a server never touches client classes.
if (net.minecraftforge.fml.loading.FMLEnvironment.dist != Dist.CLIENT) return;
SkinsgClient.init(FMLPaths.CONFIGDIR.get());
MinecraftForge.EVENT_BUS.addListener((RegisterClientCommandsEvent e) ->
e.getDispatcher().register(SkinsgCommands.build(SkinsgClient.config(), FEEDBACK)));
MinecraftForge.EVENT_BUS.addListener((TickEvent.ClientTickEvent e) -> {
if (e.phase == TickEvent.Phase.END) SkinsgClient.tick();
});
}
}
@@ -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) {}
}
@@ -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);
}
}
}
@@ -0,0 +1,31 @@
modLoader = "javafml"
loaderVersion = "[47,)"
license = "All Rights Reserved"
[[mods]]
modId = "skinsg"
version = "${version}"
displayName = "HD Skin Support (Gravit)"
authors = "Nghtly"
description = '''Client-side HD skins (up to 2048) and animated GIF cape support.'''
[[dependencies.skinsg]]
modId = "forge"
mandatory = true
versionRange = "[47,)"
ordering = "NONE"
side = "CLIENT"
[[dependencies.skinsg]]
modId = "minecraft"
mandatory = true
versionRange = "[1.20.1,1.21)"
ordering = "NONE"
side = "CLIENT"
[[dependencies.skinsg]]
modId = "architectury"
mandatory = false
versionRange = "[9.2.14,)"
ordering = "AFTER"
side = "CLIENT"
+6
View File
@@ -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 }
}
+27 -34
View File
@@ -1,39 +1,32 @@
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
org.gradle.jvmargs=-Xmx1G
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx3G
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
#read more on this at https://github.com/neoforged/NeoGradle/blob/NG_7.0/README.md#apply-parchment-mappings
# you can also find the latest versions at: https://parchmentmc.org/docs/getting-started
neogradle.subsystems.parchment.minecraftVersion=1.21.1
neogradle.subsystems.parchment.mappingsVersion=2024.11.17
# Environment Properties
# You can find the latest versions here: https://projects.neoforged.net/neoforged/neoforge
# The Minecraft version must agree with the Neo version to get a valid artifact
minecraft_version=1.21.1
# The Minecraft version range can use any release version of Minecraft as bounds.
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
# as they do not follow standard versioning conventions.
minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.219
# The loader version range can only use the major version of FML as bounds
loader_version_range=[1,)
## Mod Properties
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
# Must match the String constant located in the main mod class annotated with @Mod.
# --- Mod ---
mod_id=skinsg
# The human-readable display name for the mod.
mod_name=NightSync Skins (HD + animated capes)
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=All Rights Reserved
# The mod version. See https://semver.org/
mod_version=0.1.0
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
mod_name=HD Skin Support (Gravit)
mod_version=0.1.2
mod_group_id=xyz.nightsync.skinsg
mod_license=All Rights Reserved
mod_author=Nghtly
# --- Target: Minecraft 1.20.1 (Architectury Fabric / Forge) ---
minecraft_version=1.20.1
minecraft_version_range=[1.20.1]
parchment_minecraft=1.20.1
parchment_version=2023.09.03
# Loaders / APIs for 1.20.1
architectury_api_version=9.2.14
fabric_loader_version=0.16.9
fabric_api_version=0.92.5+1.20.1
forge_version=1.20.1-47.4.10
neoforge_version=47.1.106
enabled_platforms=fabric,forge
# NeoGradle parchment (kept for reference; loom uses parchment_* above)
neogradle.subsystems.parchment.minecraftVersion=1.20.1
neogradle.subsystems.parchment.mappingsVersion=2023.09.03
loader_version_range=[1,)
+2 -1
View File
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
# Architectury Loom needs Gradle 8.x (not 9.x). 8.10.2 is known-good with Loom 1.7.
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# One-time restructure of the single-loader NeoForge tree into the Architectury
# multiloader layout. Run once from the repo root on the `multiloader` branch:
# bash migrate-to-multiloader.sh
# It only MOVES the loader/version-agnostic code (config, gif, client, mixins)
# into common/, and the NeoForge metadata into neoforge/. The changed files
# (generic commands, per-loader entrypoints, mod metadata) are added separately.
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -e src/main/java/xyz/nightsync/skinsg/SkinsgConfig.java ]]; then
echo "Migration already applied; src/ is not present."
exit 0
fi
CJ=common/src/main/java/xyz/nightsync/skinsg
CR=common/src/main/resources
mkdir -p "$CJ" "$CR" neoforge/src/main/resources/META-INF
# --- loader/version-agnostic core -> common ---
git mv src/main/java/xyz/nightsync/skinsg/SkinsgConfig.java "$CJ"/
git mv src/main/java/xyz/nightsync/skinsg/gif "$CJ"/
git mv src/main/java/xyz/nightsync/skinsg/client "$CJ"/
git mv src/main/java/xyz/nightsync/skinsg/mixin "$CJ"/
git mv src/main/resources/skinsg.mixins.json "$CR"/
# --- superseded / rewritten: generic commands, per-loader entrypoints + fresh metadata ---
git rm src/main/java/xyz/nightsync/skinsg/commands/SkinsgCommands.java
git rm src/main/java/xyz/nightsync/skinsg/SkinsgMod.java
git rm src/main/resources/META-INF/neoforge.mods.toml
# clean up now-empty old source roots
rm -rf src
echo "Migration done. Now add the new files (SkinsgCommands generic, SkinsgClient,"
echo "per-loader entrypoints + metadata) — see MULTILOADER.md — then ./gradlew build."
+41
View File
@@ -0,0 +1,41 @@
plugins {
id "com.gradleup.shadow"
}
architectury {
platformSetupLoomIde()
neoForge()
}
configurations {
common
shadowBundle
compileClasspath.extendsFrom common
runtimeClasspath.extendsFrom common
developmentNeoForge.extendsFrom common
}
dependencies {
neoForge "net.neoforged:neoforge:${rootProject.neoforge_version}"
modApi "dev.architectury:architectury-neoforge:${rootProject.architectury_api_version}"
common(project(path: ":common", configuration: "namedElements")) { transitive false }
shadowBundle project(path: ":common", configuration: "transformProductionNeoForge")
}
processResources {
inputs.property "version", project.version
filesMatching("META-INF/neoforge.mods.toml") {
expand version: project.version
}
}
shadowJar {
configurations = [project.configurations.shadowBundle]
archiveClassifier = "dev-shadow"
}
remapJar {
input.set shadowJar.archiveFile
archiveClassifier = null
}
+1
View File
@@ -0,0 +1 @@
loom.platform=neoforge
@@ -0,0 +1,29 @@
package xyz.nightsync.skinsg.neoforge;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
import net.neoforged.neoforge.common.NeoForge;
import xyz.nightsync.skinsg.SkinsgClient;
import xyz.nightsync.skinsg.commands.SkinsgCommands;
@Mod(value = "skinsg", dist = Dist.CLIENT)
public final class SkinsgNeoForge {
private static final SkinsgCommands.Feedback<CommandSourceStack> FEEDBACK = new SkinsgCommands.Feedback<>() {
@Override public void success(CommandSourceStack s, String m) { s.sendSuccess(() -> Component.literal(m), false); }
@Override public void failure(CommandSourceStack s, String m) { s.sendFailure(Component.literal(m)); }
};
public SkinsgNeoForge(IEventBus modBus) {
SkinsgClient.init(FMLPaths.CONFIGDIR.get());
NeoForge.EVENT_BUS.addListener((RegisterClientCommandsEvent e) ->
e.getDispatcher().register(SkinsgCommands.build(SkinsgClient.config(), FEEDBACK)));
NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post e) -> SkinsgClient.tick());
}
}
@@ -0,0 +1,34 @@
modLoader = "javafml"
loaderVersion = "[1,)"
license = "All Rights Reserved"
[[mods]]
modId = "skinsg"
version = "${version}"
displayName = "HD Skin Support (Gravit)"
authors = "Nghtly"
description = '''Client-side HD skins (up to 2048) and animated GIF cape support.'''
[[mixins]]
config = "skinsg.mixins.json"
[[dependencies.skinsg]]
modId = "neoforge"
type = "required"
versionRange = "[21.1,)"
ordering = "NONE"
side = "CLIENT"
[[dependencies.skinsg]]
modId = "minecraft"
type = "required"
versionRange = "[1.21,1.22)"
ordering = "NONE"
side = "CLIENT"
[[dependencies.skinsg]]
modId = "architectury"
type = "required"
versionRange = "[13.0.8,)"
ordering = "AFTER"
side = "CLIENT"
+12 -3
View File
@@ -1,10 +1,19 @@
pluginManagement {
repositories {
maven { url "https://maven.fabricmc.net/" }
maven { url "https://maven.architectury.dev/" }
maven { url "https://maven.minecraftforge.net/" }
maven { url "https://maven.neoforged.net/releases/" }
gradlePluginPortal()
maven { url = 'https://maven.neoforged.net/releases' }
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
rootProject.name = "skinsg"
include("common")
def props = new Properties()
file("gradle.properties").withInputStream { props.load(it) }
props.getProperty("enabled_platforms", "fabric,forge").split(",").collect { it.trim() }.findAll { !it.isEmpty() }.each { platform ->
include(platform)
}
@@ -1,41 +0,0 @@
package xyz.nightsync.skinsg;
import net.minecraft.client.Minecraft;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
import net.neoforged.neoforge.common.NeoForge;
import xyz.nightsync.skinsg.client.CapeManager;
import xyz.nightsync.skinsg.commands.SkinsgCommands;
@Mod(value = SkinsgMod.MODID, dist = Dist.CLIENT)
public final class SkinsgMod {
public static final String MODID = "skinsg";
private static SkinsgConfig config;
public SkinsgMod(IEventBus modBus) {
config = SkinsgConfig.load(FMLPaths.CONFIGDIR.get());
CapeManager.init(config, () -> {
var p = Minecraft.getInstance().player;
return p != null ? p.getUUID() : null;
});
NeoForge.EVENT_BUS.addListener(this::onRegisterCommands);
NeoForge.EVENT_BUS.addListener(this::onClientTick);
}
public static SkinsgConfig config() {
return config;
}
private void onRegisterCommands(RegisterClientCommandsEvent e) {
SkinsgCommands.register(e.getDispatcher(), config);
}
private void onClientTick(ClientTickEvent.Post e) {
CapeManager.clientTick();
}
}
@@ -1,108 +0,0 @@
package xyz.nightsync.skinsg.commands;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.SuggestionProvider;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import xyz.nightsync.skinsg.SkinsgConfig;
import xyz.nightsync.skinsg.client.CapeManager;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static net.minecraft.commands.Commands.argument;
import static net.minecraft.commands.Commands.literal;
public final class SkinsgCommands {
private static SkinsgConfig cfg;
public static final String ALL = "@a";
private SkinsgCommands() {}
private static final SuggestionProvider<CommandSourceStack> TARGETS = (ctx, b) -> {
b.suggest(ALL);
var conn = Minecraft.getInstance().getConnection();
if (conn != null) {
for (PlayerInfo pi : conn.getOnlinePlayers()) {
b.suggest(pi.getProfile().getName());
}
}
return b.buildFuture();
};
public static void register(CommandDispatcher<CommandSourceStack> d, SkinsgConfig config) {
cfg = config;
d.register(literal("skinsg")
.then(literal("playgif")
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return msg(c, "GIF capes: off"); })
.then(argument("target", StringArgumentType.word()).suggests(TARGETS)
.executes(SkinsgCommands::playgif)))
.then(literal("stopgif")
.executes(c -> { cfg.setNone(); CapeManager.releaseAll(); return msg(c, "GIF capes: off"); })
.then(argument("target", StringArgumentType.word()).suggests(TARGETS)
.executes(SkinsgCommands::stopgif)))
.then(literal("self")
.executes(c -> { cfg.setSelf(); CapeManager.releaseAll(); return msg(c, "GIF capes: self only"); }))
.then(literal("speed")
.then(argument("target", StringArgumentType.word()).suggests(TARGETS)
.then(argument("percent", IntegerArgumentType.integer(1, 2000))
.executes(SkinsgCommands::speed)))));
}
private static int playgif(CommandContext<CommandSourceStack> c) {
String target = StringArgumentType.getString(c, "target");
if (ALL.equals(target)) { cfg.setAll(); return msg(c, "GIF capes: everyone"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(c, "player not found: " + target);
cfg.enableFor(ids);
return msg(c, "GIF capes: on for " + target);
}
private static int stopgif(CommandContext<CommandSourceStack> c) {
String target = StringArgumentType.getString(c, "target");
if (ALL.equals(target)) { cfg.setNone(); CapeManager.releaseAll(); return msg(c, "GIF capes: off"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(c, "player not found: " + target);
cfg.disableFor(ids);
for (UUID id : ids) CapeManager.releaseIfPresent(id);
return msg(c, "GIF capes: stopped for " + target);
}
private static int speed(CommandContext<CommandSourceStack> c) {
String target = StringArgumentType.getString(c, "target");
int percent = IntegerArgumentType.getInteger(c, "percent");
if (ALL.equals(target)) { cfg.setGlobalSpeed(percent); return msg(c, "GIF speed: " + percent + "% (all)"); }
Set<UUID> ids = resolve(target);
if (ids.isEmpty()) return err(c, "player not found: " + target);
for (UUID id : ids) cfg.setSpeed(id, percent);
return msg(c, "GIF speed: " + percent + "% for " + target);
}
private static Set<UUID> resolve(String name) {
Set<UUID> out = new HashSet<>();
var conn = Minecraft.getInstance().getConnection();
if (conn != null) {
for (PlayerInfo pi : conn.getOnlinePlayers()) {
if (pi.getProfile().getName().equalsIgnoreCase(name)) out.add(pi.getProfile().getId());
}
}
return out;
}
private static int msg(CommandContext<CommandSourceStack> c, String text) {
c.getSource().sendSuccess(() -> Component.literal("[skinsg] " + text), false);
return 1;
}
private static int err(CommandContext<CommandSourceStack> c, String text) {
c.getSource().sendFailure(Component.literal("[skinsg] " + text));
return 0;
}
}
@@ -1,30 +0,0 @@
modLoader="javafml"
loaderVersion="${loader_version_range}"
license="${mod_license}"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name}"
displayURL="https://git.mcbcorp.ru/Fiden/SkinsG"
authors="Nghtly"
description='''
HD skins (up to 2048x2048) and animated GIF capes, driven by a UUID-keyed asset API.
'''
[[mixins]]
config="${mod_id}.mixins.json"
[[dependencies.${mod_id}]]
modId="neoforge"
type="required"
versionRange="[21.1,)"
ordering="NONE"
side="CLIENT"
[[dependencies.${mod_id}]]
modId="minecraft"
type="required"
versionRange="${minecraft_version_range}"
ordering="NONE"
side="CLIENT"