JuicyPixels-blp
BLP format decoder/encoder over JuicyPixels library
https://github.com/NCrashed/JuicyPixels-blp#readme
| LTS Haskell 12.26: | 0.1.1.0 |
| Stackage Nightly 2026-10-21: | 1.0.0.0 |
| Latest on Hackage: | 1.0.0.0 |
JuicyPixels-blp-1.0.0.0@sha256:0d2ca19eb8de9f9d229df7825e561fe901a7c6ee8b4ff1a4dba3a2b4394eff52,4062Module documentation for 1.0.0.0
JuicyPixels-blp
Read and write BLP1, the texture format of Warcraft III, and BLP2, its
World of Warcraft variant, with images as
JuicyPixels types. The
package also ships blp2any, a command-line converter between BLP and
common image formats.
For the layout of a BLP file and how the library handles each part, see docs/format.md.
Library
import Codec.Picture
import Codec.Picture.Blp
main :: IO ()
main = do
-- Decode: the result is an Image PixelRGBA8, whatever the content.
Right img <- readBlp "war3mapMap.blp"
writePng "war3mapMap.png" img
-- Encode: JPEG at quality 90 with every mip level down to 1x1.
Right png <- readImage "icon.png"
result <- writeBlpWith "icon.blp" defaultBlpEncodeOptions png
either print return result
-
readBlp/decodeBlpreturn the full size image of a BLP1 or BLP2 file asImage PixelRGBA8. -
readBlpMipmaps/decodeBlpMipmapsreturn the image followed by its mipmaps. -
Files they cannot read give a
BlpDecodeError, which says why: not a BLP file, a header cut short, unsupported content, an invalid size, a size above the limit, no image data, or a damaged full size image.displayBlpDecodeErrorturns it into a message. -
encodeBlpWith/writeBlpWithencode withBlpEncodeOptions:blpVersion:Blp1(Warcraft III, the default) orBlp2(World of Warcraft).blpFormat: in BLP1,BlpFormatJpeg quality(1 to 100; the texture gets alpha when the image has it); in BLP2,BlpFormatDxt1(1-bit alpha when some alpha is below 128),BlpFormatDxt3,BlpFormatDxt5orBlpFormatBgra(lossless); in both,BlpFormatPaletted withAlpha(256 colour palette, with 8-bit alpha or none).blpMipLevels: how many mip levels to write, clamped to 1 and the full chain.mipMapsUpTocomputes it from a smallest side length.blpDither: whether paletted textures are dithered (on by default; turn it off for icons and pixel art).blpMaxSize: halve the image until its larger side is at most this. Warcraft III shows mip levels up to 512 pixels only, so larger ones only make the file bigger.
Start from
defaultBlpEncodeOptionsand change the fields you need.They return
Leftfor images they cannot encode (BlpEncodeError): an empty image, a side above 65535 (afterblpMaxSize), an invalid JPEG quality or size limit, a format the version does not have (such as JPEG in BLP2), or a file over 4 GiB.
The Codec.Picture.Blp.Internal.* modules expose the parser, decoder,
encoder and file structure. They are useful for tools, but their API may
change between minor versions.
What works
-
Decoding all BLP1 variants: JPEG, and paletted with 0, 1, 4 or 8-bit alpha, including their mipmaps.
-
Decoding BLP2 (World of Warcraft): DXT1, DXT3, DXT5, BGRA and paletted content, including mipmaps.
blpInfotells the versions and content types apart. -
Damaged and non-standard files that are common in community maps and ports of World of Warcraft assets:
- odd header values;
- short palettes;
- garbage in unused mipmap slots;
- truncated or corrupt mipmaps. These cost only the levels from the damaged one onwards; a damaged full size image still fails the file.
The decoder reports malformed input as a
BlpDecodeError, including errors raised inside JuicyPixels’ JPEG decoder. Memory use follows the size in the file header, so decoding refuses images above 8192×8192 by default; check untrusted files withblpInfoand set your own limit withdecodeBlpWith (BlpDecodeOptions n). One known exception: some corrupt JPEG streams make JuicyPixels loop forever (Juicy.Pixels#230), so decoding untrusted files needs a timeout. -
Encoding BLP1 to JPEG or a 256 colour palette, with or without alpha. The JPEG header is shared between mipmaps, as in Blizzard’s own files.
-
Encoding BLP2 to DXT1, DXT3, DXT5, BGRA or a palette. The DXT encoder fits each block along its principal axis and refines it by least squares; on test images it is 0.2–0.6 dB below libsquish’s cluster fit (1.8 dB on a small icon) and 0.7–2.5 dB above a plain range fit.
Limitations
- Lossy palette. Uncompressed encoding quantises to 256 colours, so it is lossy for images with more colours. Mipmaps after the first are mapped to the first level’s palette.
- Fixed header values. The encoder writes
extraas 3 with alpha and 5 without, andhasMipmapsas 5, or 0 for a single level.
Out of scope
The library reads and writes every BLP version in use, BLP1 and BLP2. These are left out on purpose, not for lack of time:
- BLP0, which keeps mipmaps in separate
.b00–.b15files and appeared only in the Reign of Chaos beta. - BLP2 with JPEG content (
type0): no known file uses it, so it is rejected rather than guessed at. - Writing 1-bit or 4-bit alpha, or chosen
extraandhasMipmapsvalues, through the public encoder.Codec.Picture.Blp.Internal.Encodercan write anyBlpStruct. - Archives and other texture formats, such as MPQ, CASC or the DDS textures of Warcraft III: Reforged.
blp2any
Ready-made binaries for Windows, macOS (Apple Silicon) and Linux are
attached to each GitHub release
from 0.5.0.0 on. The Linux binary needs glibc 2.35 or newer (Ubuntu 22.04,
Debian 12 and later), plus zlib and GMP, which almost every distribution
has. To build it yourself instead, use cabal install JuicyPixels-blp
(from Hackage) or nix run github:NCrashed/JuicyPixels-blp.
blp2any convert INPUT_PATH OUTPUT_PATH [-i FORMAT] [-f FORMAT] [-q QUALITY]
[-b jpg|uncompressedWithAlpha|uncompressedWithoutAlpha|dxt1|dxt3|dxt5|bgra]
[--blp2]
[--min-mipmap-size N] [--max-size PIXELS] [--no-dither]
[-s|--shallow] [-p|--flatten] [--timeout SECONDS]
blp2any stats DIRECTORY OUTPUT_PATH
convertturns a file or a whole directory from one format into another. Supported formats areblp,png,jpg,tiff(tif),gifandbmp.- Formats are inferred from the file names, or given with
-iand-f. - With a directory it recurses unless
-sis given, and it recreates the subdirectories unless-p(--flatten) is given. A file that fails is reported on stderr and skipped; at the end it prints how many files were converted and failed, and exits with status 1 if any failed. --timeout SECONDSgives up on a file that takes longer; use it on untrusted input, where some corrupt JPEG data makes decoding hang. The output never overwrites its input.- When writing BLP:
-bpicks the internal format, JPEG by default.dxt1,dxt3,dxt5andbgrawrite BLP2; the others write BLP1 unless--blp2is given, which writes a paletted BLP2.--max-size PIXELShalves the image until its larger side fits; Warcraft III shows mip levels up to 512 pixels only.--no-ditherturns off dithering for paletted output, which suits icons and pixel art.--min-mipmap-sizeis the smallest mip level to keep. The default is 1, which writes every level down to 1×1. A larger value leaves textures without their smallest levels; Warcraft III draws missing levels as transparent black, so this is only safe for interface images.
- Formats are inferred from the file names, or given with
statsgroups the BLP files in a directory by their header values and copies one example of each group toOUTPUT_PATH, named after the group: content type,alphaBits,extraandhasMipmapsfor BLP1, andalphaDepth,alphaTypeandhasMipsfor BLP2. Files it cannot read are reported and skipped. It helps when hunting for unusual files.
blp2any convert war3/ textures-png/ -f png
blp2any convert icons/ icons-blp/ -i png -f blp -b jpg -q 85
Building and testing
See CONTRIBUTING.md for what a pull request needs.
With Nix (flakes enabled), each supported GHC has its own development shell:
nix develop .#ghc912 # or ghc94, ghc96, ghc98, ghc910, ghc914
cabal build all
cabal test
nix develop without a name gives a shell with haskell-language-server.
nix build builds the package, and nix run . -- --help runs blp2any.
Plain cabal works as well. stack.yaml is kept for Stack users, but CI
does not test it.
- Test suite. It builds its BLP fixtures in code (
test/Fixture.hs): paletted files byte by byte, and JPEG files from the encoder, patched where needed. A test that pins down a fix names its PR or issue. - CI. It builds and tests GHC 9.4 to 9.14 with
-Werror. The benchmarks are built in CI but not run.
Benchmarks
bench/Main.hs (tasty-bench) times decoding and encoding at several sizes
for each content type, plus a few internals. The inputs are generated in
code. To compare a change against the current state:
cabal bench --ghc-options=-fproc-alignment=64 --benchmark-options '--csv before.csv'
# ... change the code ...
cabal bench --ghc-options=-fproc-alignment=64 --benchmark-options '--baseline before.csv'
Use -p to run a subset, for example -p decode or -p '/jpeg alpha/'.
Two things make the numbers noisy:
- Code alignment. Without
-fproc-alignment=64, a change in one module can move the tight loops of another and shift its timings by 20–25%. This happened to the paletted decoder while the JPEG code was being changed. - Machine load. Compare runs made under the same conditions, ideally by alternating the two builds.
Changes
1.0.0.0 (2026-10-17)
The library now reads and writes both BLP versions in use, and its API is meant to stay. Decoding reports why it fails and returns the image type it always produced, BLP2 can be written, and the deprecated encoders are gone. Every decoding function and the encoding options change; see below.
Writing BLP2
- Breaking:
BlpEncodeOptionshas a new field,blpVersion(Blp1by default), andBlpFormatnew constructorsBlpFormatDxt1,BlpFormatDxt3,BlpFormatDxt5andBlpFormatBgra. These exist only in BLP2; JPEG exists only in BLP1, and a palette in both. Other combinations give the newBlpUnsupportedFormat. - The DXT encoder fits each block’s colours along their principal axis, refines the endpoints by least squares, and tries a single-colour fit from exact tables. Against ImageMagick’s DDS encoders on seven test images it is 0.7–2.5 dB above range fit and 0.2–0.6 dB below cluster fit (1.8 dB on a 64×64 icon). It encodes a 512×512 texture in about 80 ms (DXT1) or 100 ms (DXT5).
- Files written this way decode with an independent decoder (Pinta365/blp, with the DXT5 alpha fix reported in Pinta365/blp#1): palette and BGRA exactly, DXT within the two decoders’ rounding differences.
blp2any convertwrites BLP2 with-b dxt1,dxt3,dxt5orbgra, and paletted BLP2 with--blp2.
Decoding
- Breaking:
decodeBlp,decodeBlpWith,readBlpand theMipmapsvariants returnImage PixelRGBA8instead ofDynamicImage; the image was alwaysImageRGBA8. Drop theconvertRGBA8call, or wrap the result inImageRGBA8where aDynamicImageis needed. - Breaking: they and
blpInforeport errors asBlpDecodeErrorinstead ofString:BlpNotBlp,BlpTruncatedHeader,BlpUnsupported,BlpInvalidSize,BlpTooLarge,BlpNoImageDataandBlpCorruptImage.displayBlpDecodeErrorgives a readable message. Callers can tell a broken file from one that is too large or of an unsupported kind without parsing text. - A paletted file without any usable mip level is now
BlpNoImageDatafordecodeBlpMipmapstoo; it used to give an empty list. JPEG files already failed this way. Apart from that, decoding is unchanged: an equivalence run against 0.6.0.0 over 4735 cases gives the same pixels, levels,blpInforesults and failures. - JPEG errors from JuicyPixels name the level, for example
JPEG mipmap 0: not enough bytes.
Also
- Breaking: the deprecated
encodeBlpJpeg,encodeBlpUncompressedWithAlpha,encodeBlpUncompressedWithoutAlphaand theirwriteBlp*variants are removed. UseencodeBlpWithorwriteBlpWith; unlike them, these returnLeftinstead of throwing. BlpDecodeErrorandBlpEncodeErrorareExceptions, and the public result and option types haveNFDatainstances.blp2any statsgroups BLP2 files too, by content type,alphaDepth,alphaTypeandhasMips. Group names of BLP1 files are unchanged.- The README lists what is out of scope on purpose: BLP0, BLP2 with JPEG content, chosen header values through the public encoder, and archives or other texture formats.
- Internal modules:
Internal.DxthasencodeDxt.Internal.EncoderhastoBlp2Struct,encodeBlp2andblp2Encoder, andtoBlpStructreturnsEither BlpEncodeError.- The parser and decoder return
BlpDecodeError, and the decoderImage PixelRGBA8. - The BLP1 magic parser
blpVersionis renamedblp1Tag, as the name now belongs to the option.
0.6.0.0 (2026-10-16)
BLP2, the World of Warcraft variant, can be read. BlpInfo and
BlpCompression change for it; see the breaking change below.
decodeBlp,decodeBlpMipmaps, theirWithandreadvariants andblpInforead BLP2 files: DXT1, DXT3, DXT5, BGRA and paletted content, with mipmaps (#3). Damaged levels are handled as in BLP1, and the size limit applies. An alpha depth of 0 makes the image opaque. BLP2 files with JPEG content (type0) are rejected; no known file uses them.blp2any convertreads BLP2 too.- Decoding was checked pixel by pixel against the BLP2 sample files of
Pillow and of the Pinta365/blp library. BGRA and paletted content match
exactly. DXT colours differ by at most 2 per channel from Pinta365/blp,
from rounding, and by up to 6 from Pillow, whose BLP plugin skips the
bit replication of RGB565 endpoints. See
docs/format.md. - Breaking:
BlpInfohas a new first field,blpInfoVersion :: BlpVersion(Blp1orBlp2), andBlpCompressionnew constructorsBlpCompressionDxt1,BlpCompressionDxt3,BlpCompressionDxt5andBlpCompressionBgra. Code that buildsBlpInfopositionally or matchesBlpCompressionexhaustively needs updating. - BLP1 decoding is unchanged, and files whose magic is neither
BLP1norBLP2are still rejected with the BLP1 parser’s error. - The option, result and error types of
Codec.Picture.Blpare now defined outside the internal modules, so changes to the internal file structure cannot change them.Codec.Picture.Blp.Internal.Datastill re-exports the ones it exported before. - The benchmarks accept
tasty-bench0.5. 0.5.0.0 has the same bound in a Hackage revision. - Internal modules:
- New
Codec.Picture.Blp.Internal.Dxt, a DXT1, DXT3 and DXT5 block decoder (decodeDxt,dxtLevelBytes). - New
Codec.Picture.Blp.Internal.Decoder, which turns a parsed file into images:decodeBlpFile,decodeBlpStruct,decodeBlp2Structand the matching...Infofunctions. Internal.Data:Blp2Struct,Blp2Content,BlpFile, andmipChainLength(moved fromInternal.Parser, which still re-exports it) next to the newmipLevelsDownTo.Internal.Parser:parseBlp2,parseBlpFileandreadPalette.
- New
0.5.0.0 (2026-10-16)
Paletted textures look better, and encoding takes new options. The
encoder’s output changes, and BlpEncodeOptions has new fields.
- Paletted mipmaps after the first are averaged and mapped to the nearest palette colour, instead of taking one pixel of the dithered image per block. Measured against exact downscaling, levels 1 to 5 gain 8–17 dB on a smooth gradient, about 11 dB on a noisy texture (15–16 dB to 26–27 dB), and up to 25 dB on the small levels of a soft-edged sprite. Mip 0 is unchanged. Paletted encoding is 20–30% slower for it.
- Fully transparent pixels count as black when the palette is built, so colours hidden under them no longer take palette entries.
BlpEncodeOptionsgainsblpDither(on by default) andblpMaxSize, which halves the image until its larger side fits; Warcraft III shows mip levels up to 512 pixels only.blp2anyhas--no-ditherand--max-size PIXELS. Build options fromdefaultBlpEncodeOptions; code that applied theBlpEncodeOptionsconstructor to two arguments needs updating.BlpInvalidMaxSizeis a newBlpEncodeError.- JPEG textures made from RGBA images whose pixels are all opaque are written without alpha.
- Ready-made
blp2anybinaries for Windows, macOS (Apple Silicon) and Linux are attached to the GitHub release (#45). Codec.Picture.Blp.Internal.Encoder:toBlpStructtakesBlpEncodeOptions;shrinkTois new.
0.4.1.0 (2026-10-15)
- Decoding refuses images above
blpMaxPixelspixels, 8192x8192 by default, before allocating anything.decodeBlpWithanddecodeBlpMipmapsWithtakeBlpDecodeOptionsto set the limit. The 0.4.0.0 notes claimed that small files could no longer demand gigabytes; that was wrong. A JPEG frame smaller than its level is padded to the level size, and the level size comes from the header, so a 493-byte file claiming 65535x65535 pixels still made the decoder allocate about 17 GB. blpInforeads the header (content type, size, alpha bits, usable mip levels) without decoding any pixels, so callers can reject textures before decoding them.BlpCompressionis exported fromCodec.Picture.Blp.- blp2any:
--timeout SECONDSgives up on a file that takes too long, so a corrupt JPEG that makes decoding hang no longer stalls a batch.- An output name with an unknown extension is an error; it used to be
written as PNG regardless, and
.tifoutput really was PNG. - It refuses to write over its input, rejects
-qoutside 1..100, prints encoding errors in words, andstatsfinds*.BLPin upper case.
- Lower bounds now match the oldest versions CI tests: JuicyPixels 3.3.6, vector 0.12.3, attoparsec 0.14.4, binary 0.8.9, bytestring 0.11.5 and deepseq 1.4.8 (and for blp2any directory 1.3.7, filepath 1.4.2, optparse-simple 0.1, transformers 0.5.6), and a CI job builds and tests with exactly those. The old bounds allowed versions that were never tested.
- Fixed the documentation of
decodeBlpMipmaps, which rendered as “Element @ is always mip level @”.
0.4.0.0 (2026-10-15)
Breaking changes:
- New encoding API:
encodeBlpWithandwriteBlpWithtakeBlpEncodeOptions(aBlpFormatand a mip level count) and returnEither BlpEncodeError. Empty images, sides above 65535, JPEG quality outside 1..100 and files over 4 GiB are errors instead of crashes or silent wrap-around (#31).encodeBlpJpeg,encodeBlpUncompressedWithAlpha,encodeBlpUncompressedWithoutAlphaand theirwriteBlp*variants still work as deprecated wrappers; they clamp the JPEG quality, which used to wrap modulo 256. - The decoder takes alpha from the
alphaBitsheader field, as the format specifies, instead of from theextrafield. Paletted files with 1-bit and 4-bit alpha now decode, files whoseextrafield says otherwise keep their alpha, and unknownextravalues are no longer rejected (#25). - Non-square textures get the full mip chain down to 1x1 instead of
stopping when the shorter side reaches one pixel, and
mipMapsUpTocounts levels by the larger side, capped at 16 (#26, #31). - Mipmap colour is averaged with alpha as the weight, so transparent pixels no longer darken the edges of sprites, and the alpha of paletted mipmaps is averaged instead of sampled. Opaque textures encode exactly as before (#32).
Codec.Picture.Blp.Internal.*:BlpStructkeeps the raw header values inblpAlphaBits,blpExtraandblpHasMipmaps;blpCompressionis a function ofblpExt.BlpFlagandBlpPictureTypeare gone.- Paletted content is one
BlpPalettedconstructor holding each level’s raw data. - The parser is
parseBlpplusmipLevelsandjpegFrameSize; the attoparsec walk over the content (blpJpegParser,skipToOffset, …) is gone. encodeBlpandblpEncodertake just theBlpStruct;toBlpStructtakes aBlpFormat.
Decoding untrusted files (#30):
- Errors that JuicyPixels’ JPEG decoder raises from pure code are returned
as
Left; in a tail mipmap they end the list. - Width and height must be between 1 and 65535. Larger values used to overflow the size checks and leave an exception in the decoded image.
- Each JPEG level’s frame size is read before decoding. A frame larger than the level is rejected; a smaller one is padded with transparent black. (This was described as stopping small files from demanding gigabytes; it did not, see 0.4.1.0.)
- Mipmap slots are read by index. A missing or garbage slot ends the chain instead of shifting the later levels, and a garbage slot that points into the palette no longer breaks the file.
- Some corrupt JPEG streams still make JuicyPixels loop forever (haskell-github-trust/Juicy.Pixels#230), so decoding untrusted files needs a timeout.
Encoder fixes (#31):
- Unused mipmap slots are zero, as the format requires, and
hasMipmapsis 0 when only one level is written. - Only the levels that are written are encoded; asking for one level of a 2048x2048 JPEG texture used to encode all of them.
Faster (#34): JPEG decoding is 2.1–2.7x and JPEG encoding 2.2–2.8x as fast,
measured with the new benchmark suite (bench/Main.hs). Output is
byte-identical.
blp2any (#33):
-s/--shallowand-p(now also--flatten) were accepted but did nothing; they now keep conversion to the top folder and write all output into one folder.- A file that fails no longer aborts a batch conversion or
stats: it is reported on stderr and skipped, andconvertprints a summary and exits with status 1 if anything failed. - An input folder given with a trailing slash no longer sends output to the root of the file system.
--versionreports the package version.
Documentation: docs/format.md describes the BLP1 format and what the
library does with each part; the README covers usage, blp2any,
limitations and benchmarking; every module has haddock (#27).
0.3.0.0 (2026-10-15)
Breaking changes:
encodeBlpUncompressedWithAlphaalways writes an alpha plane andencodeBlpUncompressedWithoutAlphanever does. Both used to pick by the input image, so the two were identical andblp2any -bhad no effect.decodeBlpanddecodeBlpMipmapsalways returnImageRGBA8. JPEG BLPs whose stream is not four-component (greyscale, YCbCr) used to come back asImageY8orImageRGB8.- Removed unused functions from
Codec.Picture.Blp.Internal.Convert:toPngRepresentableandtoBlpUncompressable. mipMapsUpTocounts every level down to the minimum side; it was one short and returned 0 or less for tiny images.- Removed the
TextShowandHashableinstances fromCodec.Picture.Blp.Internal.Data, and with them thetext-showandhashabledependencies.blp2anyno longer depends onunordered-containers.
The encoder always writes mip 0; a mipmap count of zero or less used to
produce an unreadable file, which blp2any did for 1x1 images.
The uncompressed encoder no longer hands out ByteStrings over image memory it
does not keep alive, and no longer relies on an orphan, endianness-dependent
Storable PixelRGBA8 instance.
Decoder robustness for BLP1 files found in the wild:
- Accept picture type 6 (WoW JPEG variant) and picture type 0 (#5, #10).
- Tolerate palettes shorter than 256 entries (#6).
- Decode files labelled with-alpha whose mipmap 0 has no alpha plane as without-alpha instead of crashing (#8).
- A corrupt JPEG mipmap no longer fails the whole file; the mipmap list is cut at the first broken level (#9).
decodeBlpMipmapsrenders uncompressed mipmaps at their own sizes instead of crashing; a mip 0 too short for the image is aLeft(#13).- Without-alpha textures decode opaque regardless of the palette’s fourth byte, so the encoder’s own output no longer comes back transparent (#14).
- Decoding no longer dumps the parsed structure to stderr (#15).
- The JPEG encoder writes plain BGRA planes instead of dividing colour by alpha, and a uniformly semi-transparent alpha plane is kept; only all-0x00 or all-0xFF planes count as unpopulated (#17).
- The JPEG encoder shares one header between mipmaps; 1x1 textures no longer encode to a file without image data (#18).
- Skip mipmap slots that point past the end of the file or overlap an earlier one (#7).
- Decode JPEG planes as BGRA instead of CMYK (#11).
Support GHC 9.4 – 9.14: relax upper bounds for attoparsec, bytestring,
hashable, text-show, vector and filepath. Minimum supported GHC is now 9.4.
0.2.1.0
Relax upper boundary for text-show-3.9.
0.2.0.0
Implement #1. Tool now preserves subfolder structure.
0.1.3.0
Fix #2 index error.
0.1.2.0
Relax upper boundary for hashable and text-show.
0.1.1.0
Relax upper boundary for JuicyPixels-3.3.
0.1.0.1
Relax upper boundary for binary-0.9.0.0.
0.1.0.0
Initial release.