Skip to content

save(packer="lz4"): chunk that doesn't compress (compressed size == uncompressed size) is kept as LZ4 instead of stored — breaks loading on strict runtimes #377

Description

@ForeverZack

Summary

When repacking a bundle with env.file.save(packer="lz4"), a data chunk whose LZ4-compressed size
is exactly equal to its uncompressed size is still flagged as LZ4-compressed instead of
being stored uncompressed. This produces a UnityFS data block where compressedSize == uncompressedSize but the block flag = LZ4 (2/3).

Standard Unity tolerates such a block, but stricter runtimes reject it — e.g. Unity WebGL on the
Tuanjie engine (团结引擎) fails to mount the bundle with:

The file 'archive:/CAB-xxx/CAB-xxx' is corrupted! Remove it and launch unity again! [Position out of
bounds!]
Could not allocate memory: System out of memory! Trying to allocate <huge>B

Root cause

UnityPy/helpers/CompressionHelper.pychunk_based_compress() (used by BundleFile.save):

if len(compressed_data) > chunk_size:        # <-- should be >=
  # store this chunk uncompressed, clear the compression flag
  compressed_file_data.extend(data[p : p + chunk_size])
  block_info.append((chunk_size, chunk_size, block_info_flag ^ switch))
else:
  # keep LZ4 data + LZ4 flag
  ...

The fallback-to-stored only triggers when the compressed output is strictly larger than the
input. When LZ4 lands exactly at break-even (len(compressed_data) == chunk_size), the >
check is false, so the chunk keeps the LZ4 flag even though there is no actual compression —
yielding compressedSize == uncompressedSize on an LZ4 block.

Unity's own packer (and the safe behavior) is to store the chunk raw whenever the compressed result
is not smaller, i.e. >=.

When it triggers

Rare but real: it needs a 128KB chunk that LZ4 compresses to exactly its original size. This
happens on high-entropy data — e.g. chunks dominated by already-compressed texture payloads
(crunched DXT/DXT5Crunched). The crunch payload is already maximally compressed, so the outer LZ4
can't shrink the chunk and occasionally lands exactly on break-even.

In our case (ASTC→DXT(Crunch) repack, ~10k bundles), 3 bundles hit this, and they crash on Tuanjie
WebGL while every other bundle loads fine.

Suggested fix

Change the comparison to >= so a chunk that doesn't actually shrink is stored uncompressed:

if len(compressed_data) >= chunk_size:
  compressed_file_data.extend(data[p : p + chunk_size])
  block_info.append((chunk_size, chunk_size, block_info_flag ^ switch))
else:
  ...

This is a harmless robustness improvement (a break-even chunk gains nothing from compression anyway)
and matches Unity's native packing behavior.

Environment

  • UnityPy version:
  • Bundle: UnityFS, LZ4 (packer="lz4"), Unity
  • Failing runtime: Unity WebGL / Tuanjie engine (团结引擎)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions