From 2630cc325172e6364db1daa41c166e14e85106fb Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Mon, 25 May 2026 10:46:38 -0400 Subject: [PATCH 01/11] init: add claude docs and flake.nix --- CLAUDE.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.lock | 27 ++++++++++++++++ flake.nix | 80 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 CLAUDE.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3cd6dc9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +OpenYSM is an open-source replacement for the "Yes Steve Model" Minecraft mod (`2.6.5 Forge` baseline, currently `mod_version = 2.6.6`). It targets Minecraft 1.20.1 on both Fabric and Forge from a single codebase, using Architectury as the multi-loader abstraction. The mod replaces the vanilla player model with Bedrock-style models/animations via a vendored GeckoLib, plus an optional C++ native library (`ysm-core`) for fast rendering. + +Native source lives in a separate repo: [OpenYSMDev/openysm.cpp](https://github.com/OpenYSMDev/openysm.cpp). + +## Build commands + +```bash +./gradlew build # builds both platforms +./gradlew :fabric:build # Fabric only +./gradlew :forge:build # Forge only +./gradlew :forge:runClient # standard Loom dev client (Forge) +./gradlew :fabric:runClient # standard Loom dev client (Fabric) +``` + +Artifacts land in `/build/libs/openysm--.jar`. The archive base name is `openysm`, but `rootProject.name = 'yes_steve_model'` — directory/Gradle name and artifact name intentionally differ. + +`mod_version` is the single source of truth in root `gradle.properties`; `processResources` expands `${version}` into `forge/.../mods.toml` and `fabric/.../fabric.mod.json`. The GitHub Actions workflow (`.github/workflows/build-release.yml`) uses JDK 21 to invoke Gradle but targets Java 17 bytecode; on push to `main` it auto-cuts a `v` tag and uploads both jars if that tag doesn't already exist. + +## Module layout (Architectury) + +- **`common/`** — platform-agnostic code, the bulk of the mod. Pulls in `fabric-loader` only for the `@Environment` annotation; do NOT use any other Fabric class here (it gets remapped on Forge). +- **`forge/`** — Forge entry (`@Mod` class `YesSteveModelForge`), Forge capability registration, most mod-compat impls. +- **`fabric/`** — Fabric entry (`YesSteveModelFabric` / `YesSteveModelFabricClient`), Cardinal Components wiring (`YsmComponents`), Fabric compat impls. +- **`libs/`** — flat-dir jars for the long list of mod-compat dependencies (Curios, Create, Iron's Spellbooks, etc.); they are NOT fetched from Maven. The `flatDir { dirs rootProject.file('libs') }` repository in root `build.gradle` resolves entries like `:elytraslot-forge:6.4.4+1.20.1`. + +## Platform abstraction pattern + +The codebase uses Architectury's `@ExpectPlatform`: declare a `static` method in `common/` annotated `@ExpectPlatform` that `throw new AssertionError()`. Provide an implementation class with the same fully-qualified path, plus the platform name as the package suffix, named `Impl`: + +``` +common: rip.ysm.api.PlatformAPI.isServer() +forge: rip.ysm.api.forge.PlatformAPIImpl.isServer() +fabric: rip.ysm.api.fabric.PlatformAPIImpl.isServer() +``` + +The plugin rewrites bytecode at build time to redirect the common call site to the active platform's impl. Every mod-compat module under `rip.ysm.compat.*` follows this exact pattern (`CuriosCompat` → `curios/forge/CuriosCompatImpl` + `curios/fabric/CuriosCompatImpl`). + +## Entry flow + +1. Forge: `YesSteveModelForge` constructor (`@Mod`) → `EventBuses.registerModEventBus` → `YesSteveModel.init()`. Capabilities register in `onRegisterCapabilities` listener on the mod event bus. +2. Fabric: `YesSteveModelFabric#onInitialize` → `YesSteveModel.init()`. Client setup runs from `YesSteveModelFabricClient#onInitializeClient`. Cardinal Components register via the `cardinal-components-entity` entrypoint in `fabric.mod.json` (→ `YsmComponents`). +3. Shared: `YesSteveModel.init()` loads the native lib via `NativeLibLoader`, registers configs (only if native loaded), then `YsmEventBootstrap.register()` wires all common + (client-only) client events. + +## Native library (`ysm-core`) + +`com.elfmcys.yesstevemodel.NativeLibLoader` extracts the platform-specific binary from `/natives//` in classpath resources to a per-OS storage dir and loads it via JNA: + +- Windows: `%TEMP%/ysm/ysm-core.dll` +- Linux: `~/.ysm/libysm-core.so` +- macOS: `~/.ysm/libysm-core.{dylib}` +- Android: `$MOD_ANDROID_RUNTIME/libysm-core.so` (env var required) + +Override the entire path with `YSM_CORE_LIB`. Prebuilt binaries are committed under `common/src/main/resources/natives//`. The `compileNative` Gradle task that would build them via CMake is currently gated off (`if (true) return false` at the top). + +Most of the mod no-ops when the native lib didn't load: `YesSteveModel.isAvailable()` gates config registration, capability registration on Forge, and the `LifecycleEvent.SETUP` handler in `CommonEvent`. When touching code that depends on native calls, mirror this gating. + +## Mixins + +Three mixin config files, each with its own root in resources: + +- `common/src/main/resources/yes_steve_model.mixins.json` — common mixins; uses `MixinTweaker` as `IMixinConfigPlugin` (currently a no-op stub that just returns defaults) +- `forge/src/main/resources/yes_steve_model_forge.mixins.json` +- `fabric/src/main/resources/yes_steve_model_fabric.mixins.json` + +Common mixins are split: client-only mixins live in `mixin/client/` and go in the `client:` array of the JSON; server/common mixins live at `mixin/` root and go in the `mixins:` array. Forge loads both files via `loom.forge.mixinConfig` declarations in `forge/build.gradle`; Fabric loads them through the `mixins:` array in `fabric.mod.json`. + +## Networking + +Custom channel: `yes_steve_model:2_6_0` (the protocol version `NetworkHandler.VERSION` is encoded into the channel resource path with dots replaced by underscores — bump both together). The client handshake state is tracked on the Netty `Channel` via an `AttributeKey`, accessed through mixin accessors (`ConnectionAccessor#ysm$getChannel`, `ServerCommonPacketListenerImplAccessor#ysm$getConnection`). Packet classes live under `com.elfmcys.yesstevemodel.network.message`. + +## Capabilities / Components + +Same conceptual data on both loaders, different mechanisms: + +- **Forge:** capability classes under `com.elfmcys.yesstevemodel.capability` registered in `YesSteveModelForge.onRegisterCapabilities` (subscribes to `RegisterCapabilitiesEvent` on the mod bus). Server-only ones are always registered; client-only ones (`PlayerCapability`, `ProjectileCapability`, `VehicleCapability`) skipped on dedicated server. +- **Fabric:** Cardinal Components. The component IDs (e.g. `yes_steve_model:star_models`) are declared in `fabric.mod.json` under `custom.cardinal-components`, and `YsmComponents` is the entrypoint that registers them with the entity component factory. + +When adding a new capability/component, update **both** sides plus the Fabric `fabric.mod.json` ID list. + +## Vendored GeckoLib + +Code under `com.elfmcys.yesstevemodel.geckolib3` is a heavily-modified vendored copy of GeckoLib, NOT a dependency. The published GeckoLib jar is listed only as `modCompileOnly` for Forge (for reading signatures of types the runtime never sees). Treat this package as first-party code. + +## Misc gotchas + +- `@Keep` annotation in `com.elfmcys.yesstevemodel.util.obfuscate` marks methods/fields that must survive obfuscation (Mixin plugin entry points, Mod-required methods). Add it when introducing reflective entry points. +- Forge bundles ImageStream via `forgeRuntimeLibrary` + `include` (JIJ); Fabric uses `implementation` + `include`. Both `common` and `forge` declare ImageStream as `compileOnly`/`forgeRuntimeLibrary` — the actual classes only exist at runtime through the platform jars. +- `processResources` in both platform modules pulls common resources via `from(project(':common').sourceSets.main.resources)` with `DuplicatesStrategy.INCLUDE` — platform-specific overrides should live in the platform's own resources tree, not in `common`. +- Old config migration: `YesSteveModel.initConfig` renames the legacy `yes_steve_model-common.toml` to `yes_steve_model-client.toml` on first run. Don't break this path. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1c785f9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779593580, + "narHash": "sha256-le3WvQyzAQjBZnb7q2c8C5Fk2c9LgN/Oq+b0KiD4fM4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d849bb215dcdf71bce3e686839ccdb4219e84b2f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d690059 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "OpenYSM — Minecraft 1.20.1 mod (Fabric + Forge via Architectury)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem ( + pkgs: + let + inherit (pkgs) lib stdenv; + + # CI builds with Temurin 21 (see .github/workflows/build-release.yml). + # The mod targets Java 17 bytecode via Gradle's source/targetCompatibility — + # JDK 21 is the invoker, not the target. + jdk = pkgs.temurin-bin-21; + + # Native libraries dlopen'd at runtime by LWJGL (Minecraft client) and + # the bundled libysm-core.so. Linux only; on Darwin the equivalents come + # from the system SDK and are not resolved via LD_LIBRARY_PATH. + linuxRuntimeLibs = with pkgs; [ + stdenv.cc.cc.lib # libstdc++, libgcc_s — needed by libysm-core.so + zlib + libGL + glfw + openal + libpulseaudio + alsa-lib + flite # Minecraft narrator (text-to-speech) + libxkbcommon + wayland + libx11 + libxext + libxcursor + libxi + libxrandr + libxxf86vm + ]; + in + { + default = pkgs.mkShell { + packages = [ + jdk + pkgs.gradle # convenience; ./gradlew is the canonical entry point + pkgs.git + pkgs.cmake # for the (currently gated-off) :common:compileNative task + pkgs.gnumake + pkgs.gcc + ]; + + shellHook = + '' + export JAVA_HOME=${jdk} + export GRADLE_USER_HOME="''${GRADLE_USER_HOME:-$HOME/.gradle}" + '' + + lib.optionalString stdenv.isLinux '' + export LD_LIBRARY_PATH="${lib.makeLibraryPath linuxRuntimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + '' + + '' + echo "OpenYSM dev shell ready — $(${jdk}/bin/java -version 2>&1 | head -n1)" + ''; + }; + } + ); + + formatter = forEachSystem (pkgs: pkgs.nixfmt); + }; +} From 6ddac35d6f4a98b38d16aa30678961e6e5536f65 Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 00:27:07 -0400 Subject: [PATCH 02/11] perf: O(n) bone parent lookup in YSMClientMapper.buildMesh --- .../yesstevemodel/resource/YSMClientMapper.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java index 993cb72..8a3c22b 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java @@ -323,6 +323,7 @@ private static GeoModel buildMesh(RawYsmModel.RawGeometry rawGeo, GeometryDescri List geoBones = new ArrayList<>(); List bakedBones = new ArrayList<>(); Map parentMap = new HashMap<>(); + Map boneIndexByName = new HashMap<>(); for (RawYsmModel.RawBone rb : rawGeo.bones) { parentMap.put(rb.name, rb.parentName); @@ -419,19 +420,16 @@ private static GeoModel buildMesh(RawYsmModel.RawGeometry rawGeo, GeometryDescri bb.cubes.add(bc); } } + boneIndexByName.put(bb.name, bakedBones.size()); bakedBones.add(bb); } - // 回填父级索引 + // 回填父级索引:用 map 取代 O(n) 线扫,整体从 O(n^2) 降到 O(n) for (GeoModel.BakedBone b : bakedBones) { String parentName = parentMap.get(b.name); if (parentName != null && !parentName.isEmpty()) { - for (int i = 0; i < bakedBones.size(); i++) { - if (bakedBones.get(i).name.equals(parentName)) { - b.parentIdx = i; - break; - } - } + Integer idx = boneIndexByName.get(parentName); + if (idx != null) b.parentIdx = idx; } if (b.name.equals("LeftArm")) b.partMask = 1; else if (b.name.equals("RightArm")) b.partMask = 2; From 61688674daf9799c856b04dd7179f518bb0d05a7 Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 00:27:48 -0400 Subject: [PATCH 03/11] refactor: route parser hot-path stdout to LOGGER.debug --- .../yesstevemodel/resource/YSMBinaryDeserializer.java | 11 ++++++----- .../yesstevemodel/resource/YSMClientMapper.java | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMBinaryDeserializer.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMBinaryDeserializer.java index db8ce7f..39d3486 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMBinaryDeserializer.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMBinaryDeserializer.java @@ -1,5 +1,6 @@ package com.elfmcys.yesstevemodel.resource; +import com.elfmcys.yesstevemodel.YesSteveModel; import com.elfmcys.yesstevemodel.resource.pojo.RawYsmModel; import io.netty.buffer.Unpooled; import rip.ysm.security.YSMByteBuf; @@ -28,7 +29,7 @@ public YSMBinaryDeserializer(byte[] decompressedData, int format) { private RawYsmModel deserializeInternal(boolean closeOnExit) { - System.out.println("deserializing format " + format + " file..."); + YesSteveModel.LOGGER.debug("deserializing format {} file...", format); if (format < 4) { deserializeLegacyV1(); } else if (format <= 15) { @@ -46,7 +47,7 @@ private RawYsmModel deserializeInternal(boolean closeOnExit) { if (closeOnExit) { this.reader.close(); } - System.out.println("end offset: 0x" + Integer.toHexString(offset)); + YesSteveModel.LOGGER.debug("end offset: 0x{}", Integer.toHexString(offset)); return model; } @@ -82,8 +83,8 @@ public void parseYSMFooter(RawYsmModel footer) { } } catch (Throwable t) { - System.out.println("ERROR"); - t.printStackTrace(System.out); + YesSteveModel.LOGGER.debug("Failed to parse YSM footer (format={}, offset=0x{})", + format, Integer.toHexString(reader.getOffset()), t); } } @@ -400,7 +401,7 @@ private void deserializeModern() { geoRef.sha256 = hash; geoRef.modelType = modelType; tempMainModels.add(geoRef); - System.out.println("Model Table Entry: ID=" + modelType + ", Hash=" + hash); + YesSteveModel.LOGGER.debug("Model Table Entry: ID={}, Hash={}", modelType, hash); } assignMainModels(tempMainModels); diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java index 8a3c22b..d8e59ef 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java @@ -1,6 +1,7 @@ package com.elfmcys.yesstevemodel.resource; import com.elfmcys.yesstevemodel.NativeLibLoader; +import com.elfmcys.yesstevemodel.YesSteveModel; import com.elfmcys.yesstevemodel.audio.AudioCodec; import com.elfmcys.yesstevemodel.audio.AudioTrackData; import com.elfmcys.yesstevemodel.client.ClientModelInfo; @@ -212,7 +213,7 @@ private static BufferedImage decodeToImage(byte[] data, int imageFormat, int wid } } } catch (Exception e) { - e.printStackTrace(); + YesSteveModel.LOGGER.debug("decodeToImage failed (format={}, {}x{})", imageFormat, width, height, e); } return null; } @@ -224,7 +225,7 @@ private static byte[] encodeToPng(BufferedImage img, byte[] fallbackData) { ImageIO.write(img, "png", baos); return baos.toByteArray(); } catch (Exception e) { - e.printStackTrace(); + YesSteveModel.LOGGER.debug("encodeToPng failed", e); } } return fallbackData; From 83083431c7340ce315ba40c679ef176e8d996a3d Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 00:28:15 -0400 Subject: [PATCH 04/11] perf: zero-copy raster access in TranslucencyScanner --- .../resource/YSMClientMapper.java | 113 ++++++++++++------ 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java index d8e59ef..f7e322f 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java @@ -63,29 +63,46 @@ public class YSMClientMapper { public static class TranslucencyScanner { private final BufferedImage[] images; + private final int[] widths; + private final int[] heights; + // 直接持有 BufferedImage 底层 DataBuffer 的 int[]/byte[](零拷贝), + // 仅当图像是已知排布(TYPE_INT_ARGB[_PRE] 或 TYPE_4BYTE_ABGR[_PRE], + // 且 DataBuffer offset 为 0)时设置。其他类型保持 null,scan() 走原 getRGB 回落路径。 + private final int[][] intPixels; + private final byte[][] bytePixels; private final boolean[] results; -// private int remaining; public static final int STATE_INVISIBLE = 0; public static final int STATE_OPAQUE = 1; public static final int STATE_TRANSLUCENT = 2; public TranslucencyScanner(BufferedImage[] images, int expectedCount) { + int n = images.length; this.images = images; - this.results = new boolean[Math.max(expectedCount, images.length)]; -// this.remaining = images.length; -// -// for (BufferedImage image : images) { -// if (image == null) { -// remaining--; -// } -// } + this.widths = new int[n]; + this.heights = new int[n]; + this.intPixels = new int[n][]; + this.bytePixels = new byte[n][]; + for (int i = 0; i < n; i++) { + BufferedImage img = images[i]; + if (img == null) continue; + widths[i] = img.getWidth(); + heights[i] = img.getHeight(); + int t = img.getType(); + java.awt.image.DataBuffer buf = img.getRaster().getDataBuffer(); + if ((t == BufferedImage.TYPE_INT_ARGB || t == BufferedImage.TYPE_INT_ARGB_PRE) + && buf instanceof java.awt.image.DataBufferInt + && buf.getOffset() == 0) { + intPixels[i] = ((java.awt.image.DataBufferInt) buf).getData(); + } else if ((t == BufferedImage.TYPE_4BYTE_ABGR || t == BufferedImage.TYPE_4BYTE_ABGR_PRE) + && buf instanceof java.awt.image.DataBufferByte + && buf.getOffset() == 0) { + bytePixels[i] = ((java.awt.image.DataBufferByte) buf).getData(); + } + } + this.results = new boolean[Math.max(expectedCount, n)]; } -// public boolean isFinished() { -// return remaining <= 0; -// } - public boolean[] getResults() { return results; } @@ -105,12 +122,12 @@ public int scan(RawYsmModel.RawFace face) { boolean faceHasTransparentPixel = false; for (int i = 0; i < images.length; i++) { - if (images[i] == null) continue; + BufferedImage img = images[i]; + if (img == null) continue; hasValidImage = true; - BufferedImage img = images[i]; - int imgW = img.getWidth(); - int imgH = img.getHeight(); + int imgW = widths[i]; + int imgH = heights[i]; int startX = (int) Math.floor(minU * imgW + 0.01f); int endX = (int) Math.floor(maxU * imgW - 0.01f); @@ -129,39 +146,59 @@ public int scan(RawYsmModel.RawFace face) { boolean imageHasTransparentPixel = false; boolean imageHasColoredTranslucentPixel = false; - for (int x = startX; x <= endX; x++) { - for (int y = startY; y <= endY; y++) { - int alpha = (img.getRGB(x, y) >>> 24) & 0xFF; - - if (alpha > 0) { - imageHasVisiblePixel = true; - - if (alpha < 255) { - imageHasColoredTranslucentPixel = true; + int[] intData = intPixels[i]; + byte[] byteData = bytePixels[i]; + + scan: { + if (intData != null) { + // TYPE_INT_ARGB: alpha = (pixel >>> 24) & 0xFF + for (int y = startY; y <= endY; y++) { + int row = y * imgW; + for (int x = startX; x <= endX; x++) { + int alpha = (intData[row + x] >>> 24) & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; } } - - if (alpha < 255) { - imageHasTransparentPixel = true; + } else if (byteData != null) { + // TYPE_4BYTE_ABGR: 字节序 A,B,G,R 交错;alpha 在每像素首字节 + for (int y = startY; y <= endY; y++) { + int row = y * imgW * 4; + for (int x = startX; x <= endX; x++) { + int alpha = byteData[row + x * 4] & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; + } } - - if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) { - break; + } else { + // Fallback:未知 BufferedImage 类型,沿用原 getRGB(x,y) 逐像素 + for (int x = startX; x <= endX; x++) { + for (int y = startY; y <= endY; y++) { + int alpha = (img.getRGB(x, y) >>> 24) & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; + } } } - - if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) { - break; - } } if (imageHasVisiblePixel) { faceHasVisiblePixel = true; - if (imageHasTransparentPixel) { faceHasTransparentPixel = true; } - if (imageHasColoredTranslucentPixel) { results[i] = true; } From 661a8591a032d5cb5a3fdfef1410eb12a9a590f1 Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 14:58:11 +0800 Subject: [PATCH 05/11] refactor: enhance OuterFileTexture with raw RGBA support and optimize YSMClientMapper --- .../client/texture/OuterFileTexture.java | 39 +- .../resource/YSMClientMapper.java | 411 +++++++++++++----- 2 files changed, 338 insertions(+), 112 deletions(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/client/texture/OuterFileTexture.java b/common/src/main/java/com/elfmcys/yesstevemodel/client/texture/OuterFileTexture.java index b9b4284..1662c1b 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/client/texture/OuterFileTexture.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/client/texture/OuterFileTexture.java @@ -16,11 +16,21 @@ public class OuterFileTexture extends AbstractTexture implements ITextureMap { private final byte[] data; + private final int rawRgbaWidth; + private final int rawRgbaHeight; private Map suffixTextures = Reference2ReferenceMaps.emptyMap(); public OuterFileTexture(byte[] data) { this.data = data; + this.rawRgbaWidth = 0; + this.rawRgbaHeight = 0; + } + + public OuterFileTexture(byte[] rawRgbaData, int width, int height) { + this.data = rawRgbaData; + this.rawRgbaWidth = width; + this.rawRgbaHeight = height; } @Override @@ -34,7 +44,7 @@ public void load(@NotNull ResourceManager resourceManager) { public void doLoad() { try { - NativeImage imageIn = NativeImage.read(new ByteArrayInputStream(data)); + NativeImage imageIn = isRawRgba() ? readRawRgba() : NativeImage.read(new ByteArrayInputStream(data)); int width = imageIn.getWidth(); int height = imageIn.getHeight(); TextureUtil.prepareImage(this.getId(), 0, width, height); @@ -44,6 +54,31 @@ public void doLoad() { } } + private boolean isRawRgba() { + return rawRgbaWidth > 0 && rawRgbaHeight > 0; + } + + private NativeImage readRawRgba() throws IOException { + long expectedLength = (long) rawRgbaWidth * (long) rawRgbaHeight * 4L; + if (data == null || data.length < expectedLength) { + throw new IOException("Invalid raw RGBA texture data"); + } + + NativeImage image = new NativeImage(rawRgbaWidth, rawRgbaHeight, false); + for (int y = 0; y < rawRgbaHeight; y++) { + int row = y * rawRgbaWidth * 4; + for (int x = 0; x < rawRgbaWidth; x++) { + int offset = row + x * 4; + int r = data[offset] & 0xFF; + int g = data[offset + 1] & 0xFF; + int b = data[offset + 2] & 0xFF; + int a = data[offset + 3] & 0xFF; + image.setPixelRGBA(x, y, (a << 24) | (b << 16) | (g << 8) | r); + } + } + return image; + } + public void setSuffixTextures(Map map) { this.suffixTextures = Reference2ReferenceMaps.unmodifiable(new Reference2ReferenceOpenHashMap<>(map)); } @@ -51,4 +86,4 @@ public void setSuffixTextures(Map map) { public Map getSuffixTextures() { return this.suffixTextures; } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java index f7e322f..0d67b9e 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java @@ -62,14 +62,7 @@ public class YSMClientMapper { public static class TranslucencyScanner { - private final BufferedImage[] images; - private final int[] widths; - private final int[] heights; - // 直接持有 BufferedImage 底层 DataBuffer 的 int[]/byte[](零拷贝), - // 仅当图像是已知排布(TYPE_INT_ARGB[_PRE] 或 TYPE_4BYTE_ABGR[_PRE], - // 且 DataBuffer offset 为 0)时设置。其他类型保持 null,scan() 走原 getRGB 回落路径。 - private final int[][] intPixels; - private final byte[][] bytePixels; + private final TranslucencyAtlas atlas; private final boolean[] results; public static final int STATE_INVISIBLE = 0; @@ -77,30 +70,12 @@ public static class TranslucencyScanner { public static final int STATE_TRANSLUCENT = 2; public TranslucencyScanner(BufferedImage[] images, int expectedCount) { - int n = images.length; - this.images = images; - this.widths = new int[n]; - this.heights = new int[n]; - this.intPixels = new int[n][]; - this.bytePixels = new byte[n][]; - for (int i = 0; i < n; i++) { - BufferedImage img = images[i]; - if (img == null) continue; - widths[i] = img.getWidth(); - heights[i] = img.getHeight(); - int t = img.getType(); - java.awt.image.DataBuffer buf = img.getRaster().getDataBuffer(); - if ((t == BufferedImage.TYPE_INT_ARGB || t == BufferedImage.TYPE_INT_ARGB_PRE) - && buf instanceof java.awt.image.DataBufferInt - && buf.getOffset() == 0) { - intPixels[i] = ((java.awt.image.DataBufferInt) buf).getData(); - } else if ((t == BufferedImage.TYPE_4BYTE_ABGR || t == BufferedImage.TYPE_4BYTE_ABGR_PRE) - && buf instanceof java.awt.image.DataBufferByte - && buf.getOffset() == 0) { - bytePixels[i] = ((java.awt.image.DataBufferByte) buf).getData(); - } - } - this.results = new boolean[Math.max(expectedCount, n)]; + this(new TranslucencyAtlas(images), expectedCount); + } + + private TranslucencyScanner(TranslucencyAtlas atlas, int expectedCount) { + this.atlas = atlas; + this.results = new boolean[Math.max(expectedCount, atlas.size())]; } public boolean[] getResults() { @@ -121,13 +96,13 @@ public int scan(RawYsmModel.RawFace face) { boolean faceHasVisiblePixel = false; boolean faceHasTransparentPixel = false; - for (int i = 0; i < images.length; i++) { - BufferedImage img = images[i]; - if (img == null) continue; + for (int i = 0; i < atlas.size(); i++) { + AlphaIndex index = atlas.get(i); + if (index == null) continue; hasValidImage = true; - int imgW = widths[i]; - int imgH = heights[i]; + int imgW = index.width; + int imgH = index.height; int startX = (int) Math.floor(minU * imgW + 0.01f); int endX = (int) Math.floor(maxU * imgW - 0.01f); @@ -142,64 +117,14 @@ public int scan(RawYsmModel.RawFace face) { startY = Math.max(0, Math.min(startY, imgH - 1)); endY = Math.max(0, Math.min(endY, imgH - 1)); - boolean imageHasVisiblePixel = false; - boolean imageHasTransparentPixel = false; - boolean imageHasColoredTranslucentPixel = false; - - int[] intData = intPixels[i]; - byte[] byteData = bytePixels[i]; - - scan: { - if (intData != null) { - // TYPE_INT_ARGB: alpha = (pixel >>> 24) & 0xFF - for (int y = startY; y <= endY; y++) { - int row = y * imgW; - for (int x = startX; x <= endX; x++) { - int alpha = (intData[row + x] >>> 24) & 0xFF; - if (alpha > 0) { - imageHasVisiblePixel = true; - if (alpha < 255) imageHasColoredTranslucentPixel = true; - } - if (alpha < 255) imageHasTransparentPixel = true; - if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; - } - } - } else if (byteData != null) { - // TYPE_4BYTE_ABGR: 字节序 A,B,G,R 交错;alpha 在每像素首字节 - for (int y = startY; y <= endY; y++) { - int row = y * imgW * 4; - for (int x = startX; x <= endX; x++) { - int alpha = byteData[row + x * 4] & 0xFF; - if (alpha > 0) { - imageHasVisiblePixel = true; - if (alpha < 255) imageHasColoredTranslucentPixel = true; - } - if (alpha < 255) imageHasTransparentPixel = true; - if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; - } - } - } else { - // Fallback:未知 BufferedImage 类型,沿用原 getRGB(x,y) 逐像素 - for (int x = startX; x <= endX; x++) { - for (int y = startY; y <= endY; y++) { - int alpha = (img.getRGB(x, y) >>> 24) & 0xFF; - if (alpha > 0) { - imageHasVisiblePixel = true; - if (alpha < 255) imageHasColoredTranslucentPixel = true; - } - if (alpha < 255) imageHasTransparentPixel = true; - if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; - } - } - } - } + AlphaScanResult scanResult = index.scan(startX, endX, startY, endY); - if (imageHasVisiblePixel) { + if (scanResult.hasVisiblePixel) { faceHasVisiblePixel = true; - if (imageHasTransparentPixel) { + if (scanResult.hasTransparentPixel) { faceHasTransparentPixel = true; } - if (imageHasColoredTranslucentPixel) { + if (scanResult.hasColoredTranslucentPixel) { results[i] = true; } } @@ -212,6 +137,254 @@ public int scan(RawYsmModel.RawFace face) { } } + private static class TranslucencyAtlas { + private final AlphaIndex[] indexes; + + TranslucencyAtlas(BufferedImage[] images) { + this.indexes = new AlphaIndex[images.length]; + for (int i = 0; i < images.length; i++) { + BufferedImage image = images[i]; + if (image != null) { + indexes[i] = new AlphaIndex(image); + } + } + } + + int size() { + return indexes.length; + } + + AlphaIndex get(int index) { + return indexes[index]; + } + } + + private static class AlphaIndex { + // Prefix sums are only worth their full-image build cost after repeated + // scans. The current model corpus regresses when this threshold is lower. + private static final long PREFIX_BUILD_AREA_MULTIPLIER = 8L; + + private final BufferedImage image; + final int width; + final int height; + private final int stride; + private final int[] intPixels; + private final int intBaseOffset; + private final int intScanlineStride; + private final byte[] bytePixels; + private final int byteAlphaOffset; + private final int bytePixelStride; + private final int byteScanlineStride; + private long directScanArea; + private int[] visible; + private int[] transparent; + private int[] coloredTranslucent; + + AlphaIndex(BufferedImage image) { + this.image = image; + this.width = image.getWidth(); + this.height = image.getHeight(); + this.stride = width + 1; + + java.awt.image.Raster raster = image.getRaster(); + java.awt.image.SampleModel sampleModel = raster.getSampleModel(); + java.awt.image.DataBuffer buffer = raster.getDataBuffer(); + + int[] resolvedIntPixels = null; + int resolvedIntBaseOffset = 0; + int resolvedIntScanlineStride = 0; + byte[] resolvedBytePixels = null; + int resolvedByteAlphaOffset = 0; + int resolvedBytePixelStride = 0; + int resolvedByteScanlineStride = 0; + + if ((image.getType() == BufferedImage.TYPE_INT_ARGB || image.getType() == BufferedImage.TYPE_INT_ARGB_PRE) + && sampleModel instanceof java.awt.image.SinglePixelPackedSampleModel + && buffer instanceof java.awt.image.DataBufferInt + && buffer.getNumBanks() == 1) { + java.awt.image.SinglePixelPackedSampleModel sm = (java.awt.image.SinglePixelPackedSampleModel) sampleModel; + java.awt.image.DataBufferInt db = (java.awt.image.DataBufferInt) buffer; + int baseOffset = db.getOffset() + sm.getOffset(-raster.getSampleModelTranslateX(), -raster.getSampleModelTranslateY()); + int scanlineStride = sm.getScanlineStride(); + int lastOffset = baseOffset + (height - 1) * scanlineStride + (width - 1); + int[] data = db.getData(); + if (baseOffset >= 0 && scanlineStride > 0 && lastOffset < data.length) { + resolvedIntPixels = data; + resolvedIntBaseOffset = baseOffset; + resolvedIntScanlineStride = scanlineStride; + } + } else if ((image.getType() == BufferedImage.TYPE_4BYTE_ABGR || image.getType() == BufferedImage.TYPE_4BYTE_ABGR_PRE) + && sampleModel instanceof java.awt.image.ComponentSampleModel + && buffer instanceof java.awt.image.DataBufferByte + && buffer.getNumBanks() == 1) { + java.awt.image.ComponentSampleModel sm = (java.awt.image.ComponentSampleModel) sampleModel; + java.awt.image.DataBufferByte db = (java.awt.image.DataBufferByte) buffer; + int[] bandOffsets = sm.getBandOffsets(); + if (bandOffsets.length >= 4) { + int pixelBaseOffset = db.getOffset() + + sm.getOffset(-raster.getSampleModelTranslateX(), -raster.getSampleModelTranslateY()) + - bandOffsets[0]; + int alphaOffset = pixelBaseOffset + bandOffsets[3]; + int pixelStride = sm.getPixelStride(); + int scanlineStride = sm.getScanlineStride(); + int lastOffset = alphaOffset + (height - 1) * scanlineStride + (width - 1) * pixelStride; + byte[] data = db.getData(); + if (alphaOffset >= 0 && lastOffset < data.length && pixelStride > 0 && scanlineStride > 0) { + resolvedBytePixels = data; + resolvedByteAlphaOffset = alphaOffset; + resolvedBytePixelStride = pixelStride; + resolvedByteScanlineStride = scanlineStride; + } + } + } + + this.intPixels = resolvedIntPixels; + this.intBaseOffset = resolvedIntBaseOffset; + this.intScanlineStride = resolvedIntScanlineStride; + this.bytePixels = resolvedBytePixels; + this.byteAlphaOffset = resolvedByteAlphaOffset; + this.bytePixelStride = resolvedBytePixelStride; + this.byteScanlineStride = resolvedByteScanlineStride; + } + + AlphaScanResult scan(int startX, int endX, int startY, int endY) { + if (visible != null) { + return scanPrefix(startX, endX, startY, endY); + } + + long area = (long) (endX - startX + 1) * (long) (endY - startY + 1); + directScanArea += area; + if (directScanArea >= (long) width * (long) height * PREFIX_BUILD_AREA_MULTIPLIER) { + buildPrefix(); + return scanPrefix(startX, endX, startY, endY); + } + + return scanDirect(startX, endX, startY, endY); + } + + private AlphaScanResult scanPrefix(int startX, int endX, int startY, int endY) { + return new AlphaScanResult( + sum(visible, startX, endX, startY, endY) > 0, + sum(transparent, startX, endX, startY, endY) > 0, + sum(coloredTranslucent, startX, endX, startY, endY) > 0 + ); + } + + private AlphaScanResult scanDirect(int startX, int endX, int startY, int endY) { + boolean imageHasVisiblePixel = false; + boolean imageHasTransparentPixel = false; + boolean imageHasColoredTranslucentPixel = false; + + scan: + if (intPixels != null) { + for (int y = startY; y <= endY; y++) { + int row = intBaseOffset + y * intScanlineStride; + for (int x = startX; x <= endX; x++) { + int alpha = (intPixels[row + x] >>> 24) & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; + } + } + } else if (bytePixels != null) { + for (int y = startY; y <= endY; y++) { + int row = byteAlphaOffset + y * byteScanlineStride; + for (int x = startX; x <= endX; x++) { + int alpha = bytePixels[row + x * bytePixelStride] & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; + } + } + } else { + for (int x = startX; x <= endX; x++) { + for (int y = startY; y <= endY; y++) { + int alpha = (image.getRGB(x, y) >>> 24) & 0xFF; + if (alpha > 0) { + imageHasVisiblePixel = true; + if (alpha < 255) imageHasColoredTranslucentPixel = true; + } + if (alpha < 255) imageHasTransparentPixel = true; + if (imageHasVisiblePixel && imageHasTransparentPixel && imageHasColoredTranslucentPixel) break scan; + } + } + } + + return new AlphaScanResult(imageHasVisiblePixel, imageHasTransparentPixel, imageHasColoredTranslucentPixel); + } + + private void buildPrefix() { + int size = stride * (height + 1); + visible = new int[size]; + transparent = new int[size]; + coloredTranslucent = new int[size]; + int[] rowPixels = intPixels == null && bytePixels == null ? new int[width] : null; + + for (int y = 0; y < height; y++) { + if (rowPixels != null) { + image.getRGB(0, y, width, 1, rowPixels, 0, width); + } + int row = (y + 1) * stride; + int previousRow = y * stride; + int visibleRunning = 0; + int transparentRunning = 0; + int coloredTranslucentRunning = 0; + int intRow = intBaseOffset + y * intScanlineStride; + int byteRow = byteAlphaOffset + y * byteScanlineStride; + + for (int x = 0; x < width; x++) { + int alpha; + if (intPixels != null) { + alpha = (intPixels[intRow + x] >>> 24) & 0xFF; + } else if (bytePixels != null) { + alpha = bytePixels[byteRow + x * bytePixelStride] & 0xFF; + } else { + alpha = (rowPixels[x] >>> 24) & 0xFF; + } + + if (alpha > 0) visibleRunning++; + if (alpha < 255) transparentRunning++; + if (alpha > 0 && alpha < 255) coloredTranslucentRunning++; + + int idx = row + x + 1; + int above = previousRow + x + 1; + visible[idx] = visible[above] + visibleRunning; + transparent[idx] = transparent[above] + transparentRunning; + coloredTranslucent[idx] = coloredTranslucent[above] + coloredTranslucentRunning; + } + } + } + + private int sum(int[] prefix, int startX, int endX, int startY, int endY) { + int x1 = startX; + int y1 = startY; + int x2 = endX + 1; + int y2 = endY + 1; + return prefix[y2 * stride + x2] + - prefix[y1 * stride + x2] + - prefix[y2 * stride + x1] + + prefix[y1 * stride + x1]; + } + } + + private static class AlphaScanResult { + final boolean hasVisiblePixel; + final boolean hasTransparentPixel; + final boolean hasColoredTranslucentPixel; + + AlphaScanResult(boolean hasVisiblePixel, boolean hasTransparentPixel, boolean hasColoredTranslucentPixel) { + this.hasVisiblePixel = hasVisiblePixel; + this.hasTransparentPixel = hasTransparentPixel; + this.hasColoredTranslucentPixel = hasColoredTranslucentPixel; + } + } + private static BufferedImage decodeToImage(byte[] data, int imageFormat, int width, int height) { if (data == null || data.length == 0) { return null; @@ -226,9 +399,9 @@ private static BufferedImage decodeToImage(byte[] data, int imageFormat, int wid try { if (imageFormat == -1) { - if (width > 0 && height > 0 && data.length >= width * height * 4) { + if (width > 0 && height > 0 && data.length >= (long) width * height * 4L) { BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - int[] pixels = new int[width * height]; + int[] pixels = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData(); for (int i = 0; i < pixels.length; i++) { int r = data[i * 4] & 0xFF; int g = data[i * 4 + 1] & 0xFF; @@ -236,7 +409,6 @@ private static BufferedImage decodeToImage(byte[] data, int imageFormat, int wid int a = data[i * 4 + 3] & 0xFF; pixels[i] = (a << 24) | (r << 16) | (g << 8) | b; } - img.setRGB(0, 0, width, height, pixels, 0, width); return img; } else throw new RuntimeException("Invalid RGBA texture"); } else { @@ -276,6 +448,30 @@ public static byte[] toPng(byte[] data, int imageFormat, int width, int height) return encodeToPng(img, data); } + private static OuterFileTexture buildTexture(byte[] data, int imageFormat, int width, int height) { + return buildTexture(data, imageFormat, width, height, null); + } + + private static OuterFileTexture buildTexture(byte[] data, int imageFormat, int width, int height, BufferedImage decodedImage) { + if (data == null || data.length == 0) { + return new OuterFileTexture(data); + } + if (imageFormat == -1 && width > 0 && height > 0 && data.length >= (long) width * height * 4L) { + return new OuterFileTexture(data, width, height); + } + + int resolvedFormat = imageFormat; + if (resolvedFormat == 0) { + resolvedFormat = YSMFolderDeserializer.detectFormat(data); + } + if (resolvedFormat == 1 || resolvedFormat == 2 || resolvedFormat == 3) { + return new OuterFileTexture(data); + } + + BufferedImage img = decodedImage != null ? decodedImage : decodeToImage(data, imageFormat, width, height); + return new OuterFileTexture(encodeToPng(img, data)); + } + public static ClientModelInfo buildParsedBundle(RawYsmModel raw, String modelId) { Map mainTextures = new LinkedHashMap<>(); int textureCount = Math.max(1, raw.mainEntity.textures.size()); @@ -286,17 +482,15 @@ public static ClientModelInfo buildParsedBundle(RawYsmModel raw, String modelId) BufferedImage img = decodeToImage(rt.data, rt.imageFormat, rt.width, rt.height); imagesList.add(img); - byte[] processedData = (rt.imageFormat == 2) ? rt.data : encodeToPng(img, rt.data); - OuterFileTexture tex = new OuterFileTexture(processedData); + OuterFileTexture tex = buildTexture(rt.data, rt.imageFormat, rt.width, rt.height, img); Map suffixTextures = new LinkedHashMap<>(); for (RawYsmModel.RawTexture.SubTexture sub : rt.subTextures) { if (sub.data == null) continue; - byte[] processedSubData = toPng(sub.data, sub.imageFormat, sub.width, sub.height); if (sub.specularType == 1) { - suffixTextures.put(ShadersTextureType.NORMAL, new OuterFileTexture(processedSubData)); + suffixTextures.put(ShadersTextureType.NORMAL, buildTexture(sub.data, sub.imageFormat, sub.width, sub.height)); } else if (sub.specularType == 2) { - suffixTextures.put(ShadersTextureType.SPECULAR, new OuterFileTexture(processedSubData)); + suffixTextures.put(ShadersTextureType.SPECULAR, buildTexture(sub.data, sub.imageFormat, sub.width, sub.height)); } } tex.setSuffixTextures(suffixTextures); @@ -305,8 +499,7 @@ public static ClientModelInfo buildParsedBundle(RawYsmModel raw, String modelId) Map avatarTextures = new LinkedHashMap<>(); for (RawYsmModel.RawMetadata.Author author : raw.metadata.authors) { if (author.avatarImage == null) continue; - byte[] processedAvatarData = toPng(author.avatarImage.data, author.avatarImage.format, author.avatarImage.width, author.avatarImage.height); - OuterFileTexture tex = new OuterFileTexture(processedAvatarData); + OuterFileTexture tex = buildTexture(author.avatarImage.data, author.avatarImage.format, author.avatarImage.width, author.avatarImage.height); avatarTextures.put(author.avatarImage.name, tex); } OrderedStringMap textureMap = buildTextureMap(mainTextures); @@ -314,10 +507,11 @@ public static ClientModelInfo buildParsedBundle(RawYsmModel raw, String modelId) GeometryDescription context = buildContext(raw.mainEntity.mainModel); BufferedImage[] imagesArray = imagesList.toArray(new BufferedImage[0]); + TranslucencyAtlas mainAtlas = new TranslucencyAtlas(imagesArray); TranslucencyScanner mainScanner = raw.mainEntity.mainModel != null ? - new TranslucencyScanner(imagesArray, textureCount) : null; + new TranslucencyScanner(mainAtlas, textureCount) : null; TranslucencyScanner armScanner = raw.mainEntity.armModel != null ? - new TranslucencyScanner(imagesArray, textureCount) : null; + new TranslucencyScanner(mainAtlas, textureCount) : null; GeoModel mainMesh = buildMesh(raw.mainEntity.mainModel, context, textureCount, mainScanner, raw.properties.allCutout); GeoModel armMesh = raw.mainEntity.armModel != null ? buildMesh(raw.mainEntity.armModel, context, textureCount, armScanner, raw.properties.allCutout) : mainMesh; @@ -789,9 +983,8 @@ private static ProjectileModelFiles buildSubEntityHolder(RawYsmModel.RawSubEntit for(RawYsmModel.RawTexture rt : sub.textures.values()) { BufferedImage img = decodeToImage(rt.data, rt.imageFormat, rt.width, rt.height); imgList.add(img); - byte[] processedData = (rt.imageFormat == 2) ? rt.data : encodeToPng(img, rt.data); if (texture == null) { - texture = new OuterFileTexture(processedData); + texture = buildTexture(rt.data, rt.imageFormat, rt.width, rt.height, img); } } if (sub.model != null) { @@ -831,9 +1024,8 @@ private static VehicleModelFiles buildSubEntityWrapper(RawYsmModel.RawSubEntity for(RawYsmModel.RawTexture rt : sub.textures.values()) { BufferedImage img = decodeToImage(rt.data, rt.imageFormat, rt.width, rt.height); imgList.add(img); - byte[] processedData = (rt.imageFormat == 2) ? rt.data : encodeToPng(img, rt.data); if (texture == null) { - texture = new OuterFileTexture(processedData); + texture = buildTexture(rt.data, rt.imageFormat, rt.width, rt.height, img); } } if (sub.model != null) { @@ -868,8 +1060,7 @@ private static Map buildExtraTextures(RawYsmModel raw) Map result = new LinkedHashMap<>(); for (RawYsmModel.RawImage img : raw.properties.backgroundImages) { if (img.name != null && !img.name.isEmpty()) { - byte[] processedData = toPng(img.data, img.format, img.width, img.height); - result.put(img.name, new OuterFileTexture(processedData)); + result.put(img.name, buildTexture(img.data, img.format, img.width, img.height)); } } return result; @@ -1041,4 +1232,4 @@ private static boolean isNegativeSizedFace(RawYsmModel.RawFace f) { float dot = nx * f.normal[0] + ny * f.normal[1] + nz * f.normal[2]; return dot < -1e-5f; } -} \ No newline at end of file +} From 70ab6f771422a7332a49903d9ba6cd1dfe2ad9eb Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 04:50:07 -0400 Subject: [PATCH 06/11] fix: add validation for decrypted packet structure in handlePacket01 and enhance type check in handlePacket03 --- .../client/ClientModelManager.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/client/ClientModelManager.java b/common/src/main/java/com/elfmcys/yesstevemodel/client/ClientModelManager.java index b0f0099..c93309f 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/client/ClientModelManager.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/client/ClientModelManager.java @@ -193,6 +193,22 @@ private static void processServerData(ByteBuffer data) { } private static void handlePacket01(byte[] decryptedBuffer) throws Exception { + // Plaintext shape (server nativeSyncModels + encrypt appendNextKey): + // [garbageLen|0x80] [0x00] [garbage] [0x01] [56-byte nextKey] + // garbageLen fits in 7 bits, so total = 2 + garbageLen + 1 + 56 ∈ [59, 186]. + // A late packet 05 chunk decrypted with the wrong key shows up here as + // kilobytes of structurally-invalid bytes — drop it instead of accepting + // it as a key exchange, which would corrupt lastKey and cascade into a + // handlePacket03 crash on the next inbound chunk. + if (decryptedBuffer == null || decryptedBuffer.length < 59 || decryptedBuffer.length > 186) { + return; + } + int rxGarbageLen = decryptedBuffer[0] & 0x7F; + if (decryptedBuffer.length != 2 + rxGarbageLen + 1 + 56 + || decryptedBuffer[2 + rxGarbageLen] != 0x01) { + return; + } + key1 = new byte[56]; System.arraycopy(decryptedBuffer, decryptedBuffer.length - 56, key1, 0, 56); syncStep = 2; @@ -223,7 +239,11 @@ private record ModelHash(long hash1, long hash2) { private static void handlePacket03(YSMByteBuf buf) throws Exception { buf.skipGarbageHeader(); - int type = buf.readVarInt(); // expect 3 + int type = buf.readVarInt(); + // Defense in depth: a wrong-key decryption here would have lastKey pointing + // at junk and `type` reading as anything. handlePacket05 already does the + // same guard with `if (type != 5) return`. + if (type != 3) return; long folderHash = buf.readVarLong(); currentCacheFolderName = Long.toHexString(folderHash); From 8707b4c0c0aafd1de81fa61868322050c08b964f Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 19:59:17 +0800 Subject: [PATCH 07/11] Revert "perf: O(n) bone parent lookup in YSMClientMapper.buildMesh" This reverts commit 6ddac35d6f4a98b38d16aa30678961e6e5536f65. --- .../yesstevemodel/resource/YSMClientMapper.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java index 0d67b9e..d90cd26 100644 --- a/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java +++ b/common/src/main/java/com/elfmcys/yesstevemodel/resource/YSMClientMapper.java @@ -555,7 +555,6 @@ private static GeoModel buildMesh(RawYsmModel.RawGeometry rawGeo, GeometryDescri List geoBones = new ArrayList<>(); List bakedBones = new ArrayList<>(); Map parentMap = new HashMap<>(); - Map boneIndexByName = new HashMap<>(); for (RawYsmModel.RawBone rb : rawGeo.bones) { parentMap.put(rb.name, rb.parentName); @@ -652,16 +651,19 @@ private static GeoModel buildMesh(RawYsmModel.RawGeometry rawGeo, GeometryDescri bb.cubes.add(bc); } } - boneIndexByName.put(bb.name, bakedBones.size()); bakedBones.add(bb); } - // 回填父级索引:用 map 取代 O(n) 线扫,整体从 O(n^2) 降到 O(n) + // 回填父级索引 for (GeoModel.BakedBone b : bakedBones) { String parentName = parentMap.get(b.name); if (parentName != null && !parentName.isEmpty()) { - Integer idx = boneIndexByName.get(parentName); - if (idx != null) b.parentIdx = idx; + for (int i = 0; i < bakedBones.size(); i++) { + if (bakedBones.get(i).name.equals(parentName)) { + b.parentIdx = i; + break; + } + } } if (b.name.equals("LeftArm")) b.partMask = 1; else if (b.name.equals("RightArm")) b.partMask = 2; From 3d6f5e2e8785ab5555280851b888389f2aa3f2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B1=B3=E5=87=9BMiRin?= <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 22:00:59 +0800 Subject: [PATCH 08/11] Delete CLAUDE.md --- CLAUDE.md | 95 ------------------------------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3cd6dc9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,95 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project - -OpenYSM is an open-source replacement for the "Yes Steve Model" Minecraft mod (`2.6.5 Forge` baseline, currently `mod_version = 2.6.6`). It targets Minecraft 1.20.1 on both Fabric and Forge from a single codebase, using Architectury as the multi-loader abstraction. The mod replaces the vanilla player model with Bedrock-style models/animations via a vendored GeckoLib, plus an optional C++ native library (`ysm-core`) for fast rendering. - -Native source lives in a separate repo: [OpenYSMDev/openysm.cpp](https://github.com/OpenYSMDev/openysm.cpp). - -## Build commands - -```bash -./gradlew build # builds both platforms -./gradlew :fabric:build # Fabric only -./gradlew :forge:build # Forge only -./gradlew :forge:runClient # standard Loom dev client (Forge) -./gradlew :fabric:runClient # standard Loom dev client (Fabric) -``` - -Artifacts land in `/build/libs/openysm--.jar`. The archive base name is `openysm`, but `rootProject.name = 'yes_steve_model'` — directory/Gradle name and artifact name intentionally differ. - -`mod_version` is the single source of truth in root `gradle.properties`; `processResources` expands `${version}` into `forge/.../mods.toml` and `fabric/.../fabric.mod.json`. The GitHub Actions workflow (`.github/workflows/build-release.yml`) uses JDK 21 to invoke Gradle but targets Java 17 bytecode; on push to `main` it auto-cuts a `v` tag and uploads both jars if that tag doesn't already exist. - -## Module layout (Architectury) - -- **`common/`** — platform-agnostic code, the bulk of the mod. Pulls in `fabric-loader` only for the `@Environment` annotation; do NOT use any other Fabric class here (it gets remapped on Forge). -- **`forge/`** — Forge entry (`@Mod` class `YesSteveModelForge`), Forge capability registration, most mod-compat impls. -- **`fabric/`** — Fabric entry (`YesSteveModelFabric` / `YesSteveModelFabricClient`), Cardinal Components wiring (`YsmComponents`), Fabric compat impls. -- **`libs/`** — flat-dir jars for the long list of mod-compat dependencies (Curios, Create, Iron's Spellbooks, etc.); they are NOT fetched from Maven. The `flatDir { dirs rootProject.file('libs') }` repository in root `build.gradle` resolves entries like `:elytraslot-forge:6.4.4+1.20.1`. - -## Platform abstraction pattern - -The codebase uses Architectury's `@ExpectPlatform`: declare a `static` method in `common/` annotated `@ExpectPlatform` that `throw new AssertionError()`. Provide an implementation class with the same fully-qualified path, plus the platform name as the package suffix, named `Impl`: - -``` -common: rip.ysm.api.PlatformAPI.isServer() -forge: rip.ysm.api.forge.PlatformAPIImpl.isServer() -fabric: rip.ysm.api.fabric.PlatformAPIImpl.isServer() -``` - -The plugin rewrites bytecode at build time to redirect the common call site to the active platform's impl. Every mod-compat module under `rip.ysm.compat.*` follows this exact pattern (`CuriosCompat` → `curios/forge/CuriosCompatImpl` + `curios/fabric/CuriosCompatImpl`). - -## Entry flow - -1. Forge: `YesSteveModelForge` constructor (`@Mod`) → `EventBuses.registerModEventBus` → `YesSteveModel.init()`. Capabilities register in `onRegisterCapabilities` listener on the mod event bus. -2. Fabric: `YesSteveModelFabric#onInitialize` → `YesSteveModel.init()`. Client setup runs from `YesSteveModelFabricClient#onInitializeClient`. Cardinal Components register via the `cardinal-components-entity` entrypoint in `fabric.mod.json` (→ `YsmComponents`). -3. Shared: `YesSteveModel.init()` loads the native lib via `NativeLibLoader`, registers configs (only if native loaded), then `YsmEventBootstrap.register()` wires all common + (client-only) client events. - -## Native library (`ysm-core`) - -`com.elfmcys.yesstevemodel.NativeLibLoader` extracts the platform-specific binary from `/natives//` in classpath resources to a per-OS storage dir and loads it via JNA: - -- Windows: `%TEMP%/ysm/ysm-core.dll` -- Linux: `~/.ysm/libysm-core.so` -- macOS: `~/.ysm/libysm-core.{dylib}` -- Android: `$MOD_ANDROID_RUNTIME/libysm-core.so` (env var required) - -Override the entire path with `YSM_CORE_LIB`. Prebuilt binaries are committed under `common/src/main/resources/natives//`. The `compileNative` Gradle task that would build them via CMake is currently gated off (`if (true) return false` at the top). - -Most of the mod no-ops when the native lib didn't load: `YesSteveModel.isAvailable()` gates config registration, capability registration on Forge, and the `LifecycleEvent.SETUP` handler in `CommonEvent`. When touching code that depends on native calls, mirror this gating. - -## Mixins - -Three mixin config files, each with its own root in resources: - -- `common/src/main/resources/yes_steve_model.mixins.json` — common mixins; uses `MixinTweaker` as `IMixinConfigPlugin` (currently a no-op stub that just returns defaults) -- `forge/src/main/resources/yes_steve_model_forge.mixins.json` -- `fabric/src/main/resources/yes_steve_model_fabric.mixins.json` - -Common mixins are split: client-only mixins live in `mixin/client/` and go in the `client:` array of the JSON; server/common mixins live at `mixin/` root and go in the `mixins:` array. Forge loads both files via `loom.forge.mixinConfig` declarations in `forge/build.gradle`; Fabric loads them through the `mixins:` array in `fabric.mod.json`. - -## Networking - -Custom channel: `yes_steve_model:2_6_0` (the protocol version `NetworkHandler.VERSION` is encoded into the channel resource path with dots replaced by underscores — bump both together). The client handshake state is tracked on the Netty `Channel` via an `AttributeKey`, accessed through mixin accessors (`ConnectionAccessor#ysm$getChannel`, `ServerCommonPacketListenerImplAccessor#ysm$getConnection`). Packet classes live under `com.elfmcys.yesstevemodel.network.message`. - -## Capabilities / Components - -Same conceptual data on both loaders, different mechanisms: - -- **Forge:** capability classes under `com.elfmcys.yesstevemodel.capability` registered in `YesSteveModelForge.onRegisterCapabilities` (subscribes to `RegisterCapabilitiesEvent` on the mod bus). Server-only ones are always registered; client-only ones (`PlayerCapability`, `ProjectileCapability`, `VehicleCapability`) skipped on dedicated server. -- **Fabric:** Cardinal Components. The component IDs (e.g. `yes_steve_model:star_models`) are declared in `fabric.mod.json` under `custom.cardinal-components`, and `YsmComponents` is the entrypoint that registers them with the entity component factory. - -When adding a new capability/component, update **both** sides plus the Fabric `fabric.mod.json` ID list. - -## Vendored GeckoLib - -Code under `com.elfmcys.yesstevemodel.geckolib3` is a heavily-modified vendored copy of GeckoLib, NOT a dependency. The published GeckoLib jar is listed only as `modCompileOnly` for Forge (for reading signatures of types the runtime never sees). Treat this package as first-party code. - -## Misc gotchas - -- `@Keep` annotation in `com.elfmcys.yesstevemodel.util.obfuscate` marks methods/fields that must survive obfuscation (Mixin plugin entry points, Mod-required methods). Add it when introducing reflective entry points. -- Forge bundles ImageStream via `forgeRuntimeLibrary` + `include` (JIJ); Fabric uses `implementation` + `include`. Both `common` and `forge` declare ImageStream as `compileOnly`/`forgeRuntimeLibrary` — the actual classes only exist at runtime through the platform jars. -- `processResources` in both platform modules pulls common resources via `from(project(':common').sourceSets.main.resources)` with `DuplicatesStrategy.INCLUDE` — platform-specific overrides should live in the platform's own resources tree, not in `common`. -- Old config migration: `YesSteveModel.initConfig` renames the legacy `yes_steve_model-common.toml` to `yes_steve_model-client.toml` on first run. Don't break this path. From 498bd3e156af19197cfdee3a72b74f99f8e86907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B1=B3=E5=87=9BMiRin?= <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 22:01:17 +0800 Subject: [PATCH 09/11] Delete flake.nix --- flake.nix | 80 ------------------------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 flake.nix diff --git a/flake.nix b/flake.nix deleted file mode 100644 index d690059..0000000 --- a/flake.nix +++ /dev/null @@ -1,80 +0,0 @@ -{ - description = "OpenYSM — Minecraft 1.20.1 mod (Fabric + Forge via Architectury)"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - }; - - outputs = - { self, nixpkgs }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); - in - { - devShells = forEachSystem ( - pkgs: - let - inherit (pkgs) lib stdenv; - - # CI builds with Temurin 21 (see .github/workflows/build-release.yml). - # The mod targets Java 17 bytecode via Gradle's source/targetCompatibility — - # JDK 21 is the invoker, not the target. - jdk = pkgs.temurin-bin-21; - - # Native libraries dlopen'd at runtime by LWJGL (Minecraft client) and - # the bundled libysm-core.so. Linux only; on Darwin the equivalents come - # from the system SDK and are not resolved via LD_LIBRARY_PATH. - linuxRuntimeLibs = with pkgs; [ - stdenv.cc.cc.lib # libstdc++, libgcc_s — needed by libysm-core.so - zlib - libGL - glfw - openal - libpulseaudio - alsa-lib - flite # Minecraft narrator (text-to-speech) - libxkbcommon - wayland - libx11 - libxext - libxcursor - libxi - libxrandr - libxxf86vm - ]; - in - { - default = pkgs.mkShell { - packages = [ - jdk - pkgs.gradle # convenience; ./gradlew is the canonical entry point - pkgs.git - pkgs.cmake # for the (currently gated-off) :common:compileNative task - pkgs.gnumake - pkgs.gcc - ]; - - shellHook = - '' - export JAVA_HOME=${jdk} - export GRADLE_USER_HOME="''${GRADLE_USER_HOME:-$HOME/.gradle}" - '' - + lib.optionalString stdenv.isLinux '' - export LD_LIBRARY_PATH="${lib.makeLibraryPath linuxRuntimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - '' - + '' - echo "OpenYSM dev shell ready — $(${jdk}/bin/java -version 2>&1 | head -n1)" - ''; - }; - } - ); - - formatter = forEachSystem (pkgs: pkgs.nixfmt); - }; -} From a9fc4b8a8c491aebbfe20cbbd0a98cbb2e708718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B1=B3=E5=87=9BMiRin?= <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 22:01:27 +0800 Subject: [PATCH 10/11] Delete flake.lock --- flake.lock | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 flake.lock diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 1c785f9..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1779593580, - "narHash": "sha256-le3WvQyzAQjBZnb7q2c8C5Fk2c9LgN/Oq+b0KiD4fM4=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "d849bb215dcdf71bce3e686839ccdb4219e84b2f", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} From c375433d6985583a1341be81aee4f4e6203290dc Mon Sep 17 00:00:00 2001 From: MiRinChan <148533509+MiRinChan@users.noreply.github.com> Date: Tue, 26 May 2026 22:06:54 +0800 Subject: [PATCH 11/11] gitignore: flake.* --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fe2c8c6..ac00352 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ gradle-app.setting # JDT-specific (Eclipse Java Development Tools) .classpath .gitsync.json + +# NixOS flake.nix +flake.*