From c8441aba9d834f34d055bebcbcc4c792a5fce68e Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 14 Oct 2026 22:58:30 +0800 Subject: [PATCH 1/2] fix(MeshHelper): implied fourth bone weight of compressed meshes Compressed meshes store up to three 5-bit weights per vertex and imply the fourth one when they do not add up to 31. The running sum counts 5-bit units since #338, but the implied weight was still computed as 1 - sum, which gives a negative weight (-24 for weights 10, 10, 5). It is now (31 - sum) / 31, so the four weights of a vertex add up to 1. tests/test_compressed_mesh.py decodes a synthetic CompressedMesh with one vertex that needs the implied weight and one with a single full weight. --- UnityPy/helpers/MeshHelper.py | 2 +- tests/test_compressed_mesh.py | 87 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/test_compressed_mesh.py diff --git a/UnityPy/helpers/MeshHelper.py b/UnityPy/helpers/MeshHelper.py index edbd6bee..c51312dc 100644 --- a/UnityPy/helpers/MeshHelper.py +++ b/UnityPy/helpers/MeshHelper.py @@ -608,7 +608,7 @@ def decompress_compressed_mesh(self): # we read three weights, but they don't add up to one. calculate the fourth one, and read # missing bone index. continue with next vertex. elif j == 3: # - boneWeights[vertexIndex][j] = 1 - sum + boneWeights[vertexIndex][j] = (31 - sum) / 31 boneIndices[vertexIndex][j] = next(boneIndicesIterator) vertexIndex += 1 diff --git a/tests/test_compressed_mesh.py b/tests/test_compressed_mesh.py new file mode 100644 index 00000000..e6df053c --- /dev/null +++ b/tests/test_compressed_mesh.py @@ -0,0 +1,87 @@ +from types import SimpleNamespace + +import pytest + +from UnityPy.classes.generated import ( + AABB, + ChannelInfo, + CompressedMesh, + Mesh, + PackedBitVector, + Vector3f, + VertexData, +) +from UnityPy.helpers.MeshHelper import MeshHandler + +VERSION = (2021, 3, 0, 0) + + +def pack_ints(values, bit_size): + data = bytearray((len(values) * bit_size + 7) // 8) + for i, value in enumerate(values): + for bit in range(bit_size): + if value >> bit & 1: + pos = i * bit_size + bit + data[pos // 8] |= 1 << (pos % 8) + return PackedBitVector(m_Data=list(data), m_NumItems=len(values), m_BitSize=bit_size) + + +def pack_floats(values, bit_size, start=0.0, range_=1.0): + top = (1 << bit_size) - 1 + packed = pack_ints([round((value - start) / range_ * top) for value in values], bit_size) + packed.m_Start = start + packed.m_Range = range_ + return packed + + +def compressed_mesh(vertex_count, m_UVInfo=0, **vectors): + names = ( + "m_BoneIndices", + "m_NormalSigns", + "m_Normals", + "m_TangentSigns", + "m_Tangents", + "m_Triangles", + "m_UV", + "m_Weights", + ) + compressed = CompressedMesh( + m_Vertices=pack_floats([0.0] * vertex_count * 3, 8), + m_UVInfo=m_UVInfo, + **{name: vectors.get(name, PackedBitVector(m_Data=[], m_NumItems=0)) for name in names}, + ) + mesh = Mesh( + m_BindPose=[], + m_CompressedMesh=compressed, + m_IndexBuffer=[], + m_IndexFormat=0, + m_LocalAABB=AABB(m_Center=Vector3f(0, 0, 0), m_Extent=Vector3f(1, 1, 1)), + m_MeshCompression=1, + m_Name="mesh", + m_SubMeshes=[], + m_VertexData=VertexData( + m_DataSize=b"", + m_VertexCount=0, + m_Channels=[ChannelInfo(dimension=0, format=0, offset=0, stream=0)], + ), + ) + mesh.object_reader = SimpleNamespace(version=VERSION) + handler = MeshHandler(mesh) + handler.process() + return handler + + +def test_compressed_bone_weights(): + # vertex 0: three weights that do not reach 31/31, so the fourth weight is implied + # vertex 1: one weight of 31/31, the remaining influences are empty + handler = compressed_mesh( + 2, + m_Weights=pack_ints([10, 10, 5, 31], 5), + m_BoneIndices=pack_ints([0, 1, 2, 3, 7], 4), + ) + expected = [(10 / 31, 10 / 31, 5 / 31, 6 / 31), (1.0, 0, 0, 0)] + assert len(handler.m_BoneWeights) == len(expected) + for weights, expected_weights in zip(handler.m_BoneWeights, expected): + assert weights == pytest.approx(expected_weights) + assert sum(weights) == pytest.approx(1.0) + assert handler.m_BoneIndices == [(0, 1, 2, 3), (7, 0, 0, 0)] From 156eecc66e8bd05df5356aa9dedcecbf049db96a Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 14 Oct 2026 23:00:29 +0800 Subject: [PATCH 2/2] fix(MeshHelper): offset of the third and later compressed UV sets With m_UVInfo set, a compressed mesh stores its texture coordinate sets back to back in m_UV. The read offset was assigned the size of the current set instead of advancing by it, so every set after the second one was read from the position of the second set and came back as a copy of it. The offset now accumulates, as the sets are stored. tests/test_compressed_mesh.py decodes three sets with 2, 3 and 2 components (sets 0, 1 and 3) from a synthetic CompressedMesh. --- UnityPy/helpers/MeshHelper.py | 2 +- tests/test_compressed_mesh.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/UnityPy/helpers/MeshHelper.py b/UnityPy/helpers/MeshHelper.py index c51312dc..9a4556e6 100644 --- a/UnityPy/helpers/MeshHelper.py +++ b/UnityPy/helpers/MeshHelper.py @@ -508,7 +508,7 @@ def decompress_compressed_mesh(self): shape=(uvDim,), ) setattr(self, f"m_UV{uv_channel}", m_UV) - uvSrcOffset = uvDim * m_VertexCount + uvSrcOffset += uvDim * m_VertexCount else: self.m_UV0 = unpack_floats(m_CompressedMesh.m_UV, 0, m_VertexCount * 2, shape=(2,)) if m_CompressedMesh.m_UV.m_NumItems >= m_VertexCount * 4: diff --git a/tests/test_compressed_mesh.py b/tests/test_compressed_mesh.py index e6df053c..252df7bf 100644 --- a/tests/test_compressed_mesh.py +++ b/tests/test_compressed_mesh.py @@ -85,3 +85,26 @@ def test_compressed_bone_weights(): assert weights == pytest.approx(expected_weights) assert sum(weights) == pytest.approx(1.0) assert handler.m_BoneIndices == [(0, 1, 2, 3), (7, 0, 0, 0)] + + +def test_compressed_uv_sets(): + # sets 0, 1 and 3 with 2, 3 and 2 components, stored back to back in m_UV + uv_sets = { + 0: [(0.125, 0.25), (0.375, 0.5)], + 1: [(0.5, 0.625, 0.75), (0.875, 1.0, 0.0)], + 3: [(0.0625, 0.1875), (0.3125, 0.4375)], + } + uv_info = 0 + for channel, values in uv_sets.items(): + uv_info |= (4 | (len(values[0]) - 1)) << (channel * 4) + handler = compressed_mesh( + 2, + m_UVInfo=uv_info, + m_UV=pack_floats([v for values in uv_sets.values() for uv in values for v in uv], 16), + ) + for channel, values in uv_sets.items(): + decoded = getattr(handler, f"m_UV{channel}") + assert len(decoded) == len(values) + for uv, expected in zip(decoded, values): + assert uv == pytest.approx(expected, abs=1e-4) + assert handler.m_UV2 is None