Skip to content
hearhellacoptersPublic

About

A standalone, portable C++ library (with a flat C API and WebAssembly build) for decoding and encoding GPU compressed texture formats, plus platform unswizzling and reswizzling for console texture layouts.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

Repository files navigation

tex_codec

A standalone, portable C++ library (with a flat C API and WebAssembly build) for decoding and encoding GPU compressed texture formats, plus platform unswizzling and reswizzling for console texture layouts.

No external dependencies. One header, one static/shared library.

Includes Web Assembly for .wasm, .js and .ts output.

Supported formats

Family Formats Decode Encode
BC / DXT BC1 (DXT1), BC2 (DXT3), BC3 (DXT5), BC4 ± SNORM (ATI1/RGTC1), BC5 ± SNORM (ATI2/3Dc), BC6H UF16/SF16, BC7 ✅ all modes ✅ (BC6H: mode 11, BC7: mode 6)
ETC / EAC ETC1 (with alpha†), ETC2 RGB / RGBA1 (punchthrough) / RGBA8, EAC R11 / RG11 ± signed ✅ ✅
PVRTC PVRTC1 (with alpha†) 2bpp & 4bpp RGB/RGBA, PVRTC2 2bpp & 4bpp ✅ ✅ (PVRTC2: hard-mode)
ASTC LDR + HDR blocks, 2D block sizes 4x4 ... 12x12 ✅ ✅ (valid-bitstream baseline)
ATC ATC RGB, RGBA explicit, RGBA interpolated ✅ ✅
PICA200 (3DS) PICA_ETC1_RGB8 (GPU_ETC1), PICA_ETC1_RGB8A4 (GPU_ETC1A4) ✅ ✅
GameCube / Wii (GX, TPL) WII_I4, WII_I8, WII_IA4, WII_IA8, WII_RGB565, WII_RGB5A3, WII_RGBA8, WII_CMPR; paletted WII_C4, WII_C8, WII_C14X2 ✅ (paletted via texc_decode_paletted) ✅ (not paletted)
Raw RGBA8 passthrough ✅ ✅
Raw elements RAW8, RAW16, RAW32, RAW64, RAW128 (1-16 bytes a texel, swizzling only) - -

Decode target is 8-bit RGBA (texc_decode) or float RGBA (texc_decode_f32, full range for BC6H / ASTC HDR). Encode source is 8-bit RGBA (texc_encode) or float RGBA (texc_encode_f32). Non-block-aligned dimensions are handled (decoders clip, encoders edge-replicate). Encoders favour portability and determinism over ultimate quality; they produce valid bitstreams everywhere.

† See Alpha-atlas formats below.

Alpha-atlas formats

Some alpha-less codecs can hold an alpha by storing the image twice in one double-height texture: RGB on top, the alpha channel as a grayscale map below. The *_A_ATLAS formats reproduce that end to end - you always pass the final (half-height) dimensions:

  • texc_decode decodes the double-height base image and folds the atlas back exactly like the engine (A = R of the bottom-half texel).
  • texc_encode splits your RGBA into the two planes and encodes them as one base-codec image.
  • texc_swizzle / texc_unswizzle / texc_swizzled_size operate on the double-height layout automatically (doubles HEIGHT before untiling).

PICA200 (Nintendo 3DS) ETC1

PICA_ETC1_RGB8 and PICA_ETC1_RGB8A4 are ordinary ETC1 colour data in the 3DS's own container, handled transparently:

  • the image is stored as 8x8 pixel tiles in row-major order, each tile holding four 4x4 ETC1 blocks in the order (0,0), (4,0), (0,4), (4,4);
  • each 64-bit ETC1 block is byte-reversed relative to the standard big-endian layout;
  • RGB8A4 additionally prefixes every colour block with 8 bytes of 4-bit alpha, nibble index x*4 + y (ETC1's own pixel order), expanded as (a << 4) | a.

Block geometry is reported as the 8x8 tile (32 bytes = 4bpp for RGB8, 64 bytes = 8bpp for RGB8A4), so texc_encoded_size rounds up to whole tiles exactly as the hardware stores them. The layout was verified against devkitPro's tex3ds (which encodes via rg-etc1) and gdkchan/SPICA; note that SPICA also flips its output vertically as its own convention - that is not part of the format, so this library keeps its usual top-left origin (use texc_flip_y for the other orientation).

texc decode -i tex.bin -f PICA_ETC1_RGB8A4 -w 128 -h 128 -o out.png

GameCube / Wii (GX "TPL" formats)

Ported from Kerilk's tpl.h (noesis_bayonetta_pc), which is the decoder Project-G1M's PLATFORM::RVL path runs. "Wii swizzling" is really the GX tile layout plus per-format encoding, so it is modelled as a set of formats rather than a swizzle mode - decode/encode handle the tiling:

  • the image is a row-major grid of tiles, every tile 32 bytes (RGBA8: 64); tile size per format: I4 8x8, I8/IA4 8x4, IA8/RGB565/RGB5A3/RGBA8 4x4, CMPR 8x8, C4 8x8, C8 8x4, C14X2 4x4 - this is what texc_block_dims reports, so sizes round up to whole tiles as the hardware stores them;
  • all multi-byte values are big-endian; 4bpp formats put the even column in the high nibble; expansion is v*255/max (integer), as the reference;
  • RGB5A3: bit 15 set = RGB555 opaque, clear = A3+RGB444;
  • RGBA8: each 64-byte tile is a 32-byte AR plane then a 32-byte GB plane;
  • CMPR: an 8x8 tile is four DXT1-style 4x4 sub-blocks (TL, TR, BL, BR) with big-endian colour words and MSB-first selectors; c0 <= c1 selects 3-colour mode with a transparent 4th entry. Encoding reuses the BC1 encoder, so alpha_threshold controls punchthrough here too.

Paletted formats (WII_C4, WII_C8, WII_C14X2) need a palette of big-endian 16-bit IA8 / RGB565 / RGB5A3 entries - for G1T content, an entry from the matching G1TL file (its WiiPALETTE_TYPE maps directly onto texc_palette_format). texc_decode returns TEXC_ERR_NEEDS_PALETTE for them; use texc_decode_paletted, sizing the palette with texc_palette_size (32 / 512 / 32768 bytes). Encoding paletted formats is not supported (no palette generation).

Project-G1M's getNWiiFormat() maps G1T pixel-format IDs like so:

Wii format G1T pixel-format IDs
WII_I4 0x2B, 0x2F
WII_I8 0x18, 0x28, 0x29, 0x2A, 0x2C, 0x30
WII_IA4 0x2D
WII_IA8 0x26, 0x27, 0x2E
WII_RGB565 0x1C
WII_RGB5A3 0x25
WII_RGBA8 0x0A, 0x13, 0x22
WII_CMPR 0x10
WII_C4 / WII_C8 / WII_C14X2 0x31 / 0x32 / 0x15, 0x33

Cube maps and volume textures are simply consecutive 2D faces / slices; decode each with its own dimensions, as the reference does.

texc decode -i tex.bin -f WII_CMPR -w 256 -h 256 -o out.png
texc decode -i tex.bin -f WII_C8 -w 128 -h 128 --palette pal.bin --palette-format rgb5a3 -o out.png

Encoder options

texc_encode_ex takes an optional texc_encode_options (zero-init + texc_encode_options_init for defaults; the struct is versioned by struct_size and may grow):

texc_encode_options opts;
texc_encode_options_init(&opts);
opts.alpha_threshold = 200;   /* punchthrough cutoff for BC1 / ETC2_RGBA1 */
texc_encode_ex(TEXC_FORMAT_BC1, rgba, size, w, h, dst, dst_size, &opts);

Unswizzling / reswizzling (console layouts)

Two clearly named directions (swizzle logic credited to Piken / DwayneR):

  • texc_unswizzle - platform-tiled → linear row-major: the direction you need before decoding console data.
  • texc_reswizzle - linear → platform-tiled: the exact inverse, for putting the platform tiling back when repacking data for the target hardware (e.g. into a G1T). (texc_swizzle is the same function under its legacy name.)

Every mode's reswizzle→unswizzle roundtrip is verified byte-identical in the test suite. Supported layouts:

Mode Layout
TEXC_SWIZZLE_PS4 Orbis 8x8 micro-tile Morton
TEXC_SWIZZLE_PS5 Prospero / AMD GFX10 block tiling (arg = texc_ps5_tile_mode, 0 = standard 4 KB blocks); whole mip chains via the surface-layout API below
TEXC_SWIZZLE_SWITCH Tegra X1 block-linear GOBs (arg = block height log2, 0xFFFFFFFF = auto)
TEXC_SWIZZLE_SWITCH2 Switch 2 / NVIDIA Orin block-linear GOBs, same arg and sizes as SWITCH
TEXC_SWIZZLE_PSVITA GXM: 32x32 pixel tiles for raw formats (arg = bytes per pixel, 0 = the format's own; G1T ships 8/16/24/32bpp raw Vita textures), Morton / Z-order for block formats
TEXC_SWIZZLE_X360 Xenos macro tiling (arg = pitch override, byte order and stored-rows flag, see below)
TEXC_SWIZZLE_PSP 16-byte x 8-row tiles
TEXC_SWIZZLE_3DS 8x8 Z-order tiles
TEXC_SWIZZLE_WIIU GX2 tiled (arg = GX2 swizzle value)
TEXC_SWIZZLE_DX12_64KB D3D12 64KB tiles as KTGL stores them: always the linear size, see below

texc_decode_swizzled does unswizzle + decode in one call. Container parsing is intentionally left to the adopting project - this library is the conversion core.

Switch 2 (Orin) uses the same block-linear layout as the original Switch: the same 512-byte GOBs (64 bytes x 8 rows) stacked into blocks of 2^arg GOBs, the same block-height rules and the same tiled sizes. The only difference is the byte order inside each GOB, where two address bits trade places:

Tegra X1: ((x%64)/32)*256 + ((y%8)/2)*64  + ((x%32)/16)*32 + (y%2)*16 + x%16
Orin:     ((x%64)/32)*256 + ((y%8)/4)*128 + ((x%32)/16)*64 + (y%4)*16 + x%16

(x = byte within the row, y = row within the GOB.) So any code that already handles a Switch G1T handles a Switch 2 one by passing TEXC_SWIZZLE_SWITCH2 instead. Both patterns come from ScanMountGoat's tegra_swizzle (its isOrin path), and tests/switch2_kat.h holds known answers produced by that library for both GPUs.

When you pass a non-zero arg, size the linear side with texc_unswizzled_size(mode, format, w, h, arg) rather than texc_encoded_size() - the format alone cannot describe the PS Vita raw bytes-per-pixel override.

PS Vita raw reproduces DeswizzlePSVitaRaw exactly: the tiled buffer is width * height * bytesPerPixel (the raw width is used, not rounded up to whole 32x32 tiles), and texels whose mapped offset falls outside the image are dropped, as the reference does. That only happens when a dimension is not a multiple of 32, where the mapping is consequently not invertible. A test compares the port against the reference formula across sizes and 8/16/24/32bpp.

Raw texels of any size (RAW8 ... RAW128)

Uncompressed data other than RGBA8 (R8, RG8, RGBA16F, RGBA32F, ...) goes through the swizzle modes, the mip-chain API and PS5 tile-mode detection as TEXC_FORMAT_RAW8 / RAW16 / RAW32 / RAW64 / RAW128, named by bytes per texel alone. Each mode applies its own element-size rules:

Mode What the element size changes
PS4 8x8 Morton micro-tiles for every size, but the pitch grows by 8 until a slice is a multiple of 256 bytes (1- and 2-byte texels); matched byte for byte against Sony's GpuAddress (tools/ps4_oracle)
PS5 AMD GFX10 standard swizzle per element size, as verified against Sony's AgcGpuAddress
Wii U addrlib's micro-tile order and macro tiling for the element size (a GX2 format of that size)
X360 element size in the tile address and byte order (8in16 for 2/8 bytes, 8in32 for 4/16); the level is aligned to 4 KB, which 1- and 2-byte texels need
DX12 64KB the tile shape (256x256 down to 64x64 texels)
Switch / Switch 2 / PSP byte based, so nothing

They carry no channel layout, so texc_decode / texc_encode reject them (texc_can_decode / texc_can_encode report 0); decode the texels yourself after unswizzling. RAW32 tiles exactly like RGBA8.

D3D12 64KB tiles (KTGL PC)

KTGL streams large PC textures into D3D12 reserved resources in 64 KB tiles, and the G1T stores the data in that tile order (the "swizzle" field of the texture's extended header). Taken from Rise of the Ronin's own CPU upload path:

  • each mip is cut into tiles of the D3D12 standard 64 KB shape for its element size: 256x256 elements at 1 byte, 256x128 at 2, 128x128 at 4, 128x64 at 8 and 64x64 at 16. In pixels, BC1/BC4 tiles are 512x256 and BC2/3/5/6H/7 tiles 256x256;
  • tiles run left to right, then top to bottom;
  • each tile is stored in one piece, row by row (block rows for BCn), and tiles on the right and bottom edges are only as wide or tall as what is left.

So the stored size is always the linear size, and a mip at most one tile wide is plain linear. In a whole texture KTGL orders the data by mip, then by array slice (mip 0 of every slice first). Up to 1.7 this mode used padded edge tiles, a "64 KB or less is linear" rule and 128x128 raw tiles for every element size; all three were wrong.

X360 (Xenos) byte order and stored rows

The X360 arg packs three fields: the texel pitch override in the low byte, a texc_x360_endian in bits 8-10, and the TEXC_X360_LOADER_ROWS flag. With TEXC_X360_ENDIAN_AUTO (0) the byte order follows the D3DFORMATs Koei Tecmo's X360 loader declares (Dynasty Warriors 8): 8IN16 for DXT and 2- or 8-byte texels, 8IN32 for 4- and 16-byte texels, none for 1-byte texels. Up to 1.7 every format was swapped 8IN16, which scrambles 32-bit texels such as RGBA8; pass TEXC_X360_ENDIAN_8IN16 to get that back.

The loader does not untile anything: it reads each mip's ceil(h/bh) rows of tightly packed elements straight into the locked tiled surface, one row per surface pitch (the width in elements rounded up to 32), and never writes the rest of the surface. TEXC_X360_LOADER_ROWS makes the tiled side exactly that stored data: texc_swizzled_size returns the stored size (the linear size), texc_unswizzle places the rows at the pitch, accepts a shorter src and reads the missing tail as zeros (missing bytes = texc_swizzled_size - src_size), and texc_reswizzle writes the stored rows. Elements the stored rows never reach come out as zeros, as on the console.

Switch / Switch 2 mip chains and arrays

KTGL's Switch loader (Hyrule Warriors: Age of Calamity) creates the NVN texture and reads a tiled G1T payload straight into its storage, exactly nvnTextureGetStorageSize bytes, so the file is NVN's own block-linear surface. texc_get_surface_layout / texc_unswizzle_mip / texc_reswizzle_mip with TEXC_SWIZZLE_SWITCH or SWITCH2 and arg = 0xFFFFFFFF (NVN's own block height) describe it:

  • each slice (array layer, cube face) holds its mips back to back;
  • each mip is in GOB blocks of its own height: mip 0's, halved while the mip still fits in half of it;
  • with several slices, every slice is padded to a whole block of GOBs, from mip 0's block height halved while half of it still covers the image height in pixels.

This matches ScanMountGoat's tegra_swizzle byte for byte (tests/switch_surface_kat.h, generated by tools/switch_oracle) and the payload of every tiled Switch sample, arrays of 4 to 600 layers included; a 1024x1024 BC1 with 9 mips is 704,512 bytes a slice.

Whether a Switch texture is tiled at all depends only on its G1T format, as on Wii U, PS4 and X360: KTGL tiles 0x09-0x0D, 0x11-0x13, 0x15, 0x17, 0x18, 0x1C-0x1E, 0x20, 0x22, 0x24-0x33, 0x3B-0x3F, 0x42-0x48, 0x4B, 0x4D, 0x4F, 0x51, 0x53, 0x60-0x66, 0x6D, 0x74-0x79 and 0x7E, and uploads every other format linearly through the driver: the plain BC formats 0x59-0x5F and the linear ASTC 0x7D, whose payloads are the tight linear sizes. A tiled format's payload is always the full block-linear surface.

Volume textures (G1T type 2)

texc_get_volume_layout, texc_unswizzle_volume_mip and texc_reswizzle_volume_mip (JS: the depth option of surfaceLayout / unswizzleMip / reswizzleMip; CLI: --mips N --depth N) handle a width x height x depth texture, a mip at a time with all of its slices, slice-major on the linear side. texc_mip_layout.depth gives each mip's depth.

  • TEXC_SWIZZLE_SWITCH / SWITCH2: NVN's 3D block-linear layout. A block is one GOB wide, one GOB tall (whatever the 2D rule would pick) and bd GOBs deep, the GOBs of successive slices back to back; bd is 16, 8, 4, 2 or 1 from the mip's own depth, blocks run across x, then down y, then deeper in z, and each mip is padded to whole blocks in depth. Matches tegra_swizzle byte for byte (7 volume cases in tests/switch_surface_kat.h, mip chains and odd depths included), the payload sizes of Pokopia's Switch 2 volumes (512x512x64 BC1, 256x256x16 RGBA16F) and Age of Calamity's 32x32x32 ASTC, and all three read smooth along every axis.
  • TEXC_SWIZZLE_PS4: KTGL's volume setup asks GNM for Thick_1dThick. Raw texels of 1 to 8 bytes are thick-tiled: 8x8x4-element micro tiles, row by row, a 4-slice slab at a time, depth padded to 4, with the element order inside a tile depending on the texel size. Block-compressed formats and 16-byte texels (RGBA32F) come out Thin_1dThin a slice at a time. Checked byte for byte against Sony's GpuAddress (tests/ps4_volume_kat.h, tools/ps4_oracle), and all 779 volumes in the Nioh 2 samples (colour-grading LUTs and BC mip chains) match KTGL's sizes exactly and decode smooth along every axis.
  • TEXC_SWIZZLE_NONE: mips back to back, each mip's slices back to back.
  • Other modes return TEXC_ERR_UNSUPPORTED. PS5, X360 and Wii U have thick tile modes too, but a sweep of the known corpus found no G1T volume on those platforms, so they are deliberately not implemented.

Wii U (GX2) mip chains

KTGL's Wii U loader (Hyrule Warriors) hands GX2 the G1T bytes as the surface, with tile mode DEFAULT, which GX2 turns into 2D_TILED_THIN1. Mip 0 keeps that mode at any size, but a smaller mip that is narrower than one macro tile (32 x 16 elements for 64- and 128-bit elements) falls to 1D_TILED_THIN1: 8x8-element micro tiles, row by row, padded to whole tiles. The mips follow mip 0 back to back, each at its addrlib base alignment. texc_get_surface_layout / texc_unswizzle_mip / texc_reswizzle_mip with TEXC_SWIZZLE_WIIU give that layout; the payload sizes of real Hyrule Warriors textures match it exactly (128x128 BC3 with 3 mips = 21504 bytes: 16384 + 4096 + 1024), and each 1D mip decodes to its parent mip scaled down. Single-mip arrays and cube maps work too (faces use addrlib's per-slice bank rotation); GX2 keeps all slices of a level together, which the slice-major layout struct cannot describe, so several mips of several slices return TEXC_ERR_UNSUPPORTED.

PS5 (Prospero) tiling and mip-chain surfaces

The PS5 GPU is an AMD GFX10-class part, and a texture is a row-major raster of fixed-size blocks (256 B, 4 KB or 64 KB) whose interior uses AMD's "standard" address swizzle - the layout AMD's open-source addrlib computes for SW_4KB_S and friends, and what Sony's AgcGpuAddress library calls TileMode::kStandard4KB etc. Every known G1T PS5 texture uses the 4 KB mode (Sony's own texture tool defaults sampled textures to it), which is arg = TEXC_PS5_TILE_DEFAULT (0). TEXC_PS5_TILE_STANDARD_256B (1), _4KB (5) and _64KB (9) match Sony's enum so a header value can be passed straight through; TEXC_PS5_TILE_LEGACY (0x100) keeps the pre-1.6 layout reachable (it equals the 64 KB mode for 16-byte blocks and 32bpp raw data, but is not a hardware layout for anything else).

Koei Tecmo content is not all 4 KB: small textures (mip 0 up to 64 KB in every sample seen) use STANDARD_4KB, large ones (1024x128 and 64x2048 BC7 samples, mip 0 = 128 KB) use STANDARD_64KB, and G1T carries no tile-mode field. The two layouts have different whole-surface sizes, so the mode can be recovered from the texture's data size:

texc_ps5_tile_mode tm;
texc_ps5_detect_tile_mode(TEXC_FORMAT_BC7, 1024, 128, 6, 1,
                          /*data bytes in the G1T*/ 524288, &tm);
/* tm == TEXC_PS5_TILE_STANDARD_64KB (a 4 KB surface would be 196608) */

For the write path, texc_ps5_default_tile_mode(format, w, h) returns the choice observed in Koei Tecmo content (64 KB blocks once mip 0 exceeds 64 KB); it is a heuristic that matches every sample so far. The CLI's --arg auto uses the detector when unswizzling and the heuristic when reswizzling.

A PS5 texture stores its whole mip chain as one surface: the smallest mips that fit in half a block share a single "mip tail" block stored first, then the remaining mips follow in descending order, so mip 0 is last; array slices and cube faces repeat the chain. Because the padding, tail packing and ordering are platform-defined, the library exposes the layout instead of leaving callers to guess offsets:

texc_surface_layout lay;
texc_get_surface_layout(TEXC_SWIZZLE_PS5, TEXC_FORMAT_BC7, 256, 256,
                        /*mips*/ 7, /*slices*/ 1, /*arg*/ 0, &lay);
/* lay.total_size == 90112: 64K (mip 0) + 16K + 4K + one 4K tail block;
   lay.mips[0].offset == 24576, lay.mips[3].in_tail == 1 ... */

/* pull any mip straight out of the surface, ready to decode */
texc_unswizzle_mip(TEXC_SWIZZLE_PS5, TEXC_FORMAT_BC7, 256, 256, 7, 1, 0,
                   /*mip*/ 0, /*slice*/ 0, surface, lay.total_size,
                   linear, texc_encoded_size(TEXC_FORMAT_BC7, 256, 256));
/* and back: zero-fill total_size bytes, then texc_reswizzle_mip per mip */

texc_unswizzle / texc_reswizzle with TEXC_SWIZZLE_PS5 handle a single mip (its block-padded raster, texc_swizzled_size bytes) and are exactly texc_unswizzle_mip on a one-level surface, so mip 0 of a chain can also be converted by pointing them at lay.mips[0].offset. texc_get_surface_layout also works for TEXC_SWIZZLE_NONE (mips back to back, mip 0 first), TEXC_SWIZZLE_PS4, TEXC_SWIZZLE_SWITCH / SWITCH2 and TEXC_SWIZZLE_WIIU (above); other modes return TEXC_ERR_UNSUPPORTED.

PS4 mip chains are stored mip 0 first, back to back, each tiled at its own pitch. A chain of several mips is padded by GNM, at any size: each mip's width is mip 0's width rounded up to a power of two and halved per level, its height is its own rounded up to a power of two, then the usual 8-element alignment and 256-byte pitch rule apply. This was fitted against KTGL's own layout code on 1,000 random chains and is checked on every run of tools/ps4_oracle. A chain with several slices is not supported yet (the slice order is unknown).

The implementation is derived from AMD addrlib (MIT). It is verified bit-for-bit against Sony's AgcGpuAddress host library across tile modes, element sizes, odd sizes, mip counts and slices by tools/ps5_oracle (needs the PS5 SDK, never committed); the SDK-generated known-answer table it produces (tests/ps5_kat.h) is checked by the normal test suite. 3D (volume) textures, render-target/depth tile modes and MSAA are not implemented.

Image utilities

Ported from tex-decoder's profiler.ts and flipper.ts:

  • Colour profile converter - texc_convert_profile converts raw pixels between 72 profiles (texc_pixel_profile: RGBA8/BGRA8 and every channel permutation, RGB565, RGBA4, RGBA51, RGB10_A2, signed *I, half-float *16F, float *32F, 16/32-bit integer). Semantics match tex-decoder: proportional range scaling, a missing source alpha becomes opaque, missing colour channels become 0, float 1.0 ≙ integer max. texc_profile_bytes_per_pixel / texc_profile_name give metadata.
  • Flipper - texc_flip_y (row order, tex-decoder flipImage) and texc_flip_x (horizontal mirror), any bytes-per-pixel, in-place capable.
  • Cropper - texc_crop (tex-decoder cropImage): copy a rectangle out of a raw image with bounds validation.

Releasing

tools/make_release.py packages a release zip from the built artifacts:

tex_codec_v<version>.zip
  wasm/    tex_codec.js, tex_codec.wasm, tex_codec_api.mjs, .d.ts
  cli/     texc.exe, tex_codec.lib
  include/ tex_codec.h
  README.md, CHANGELOG.md

Build everything first (the zip needs both the native and the Emscripten output), then package:

cmake --build build --config Release
cd wasm && ./build_wasm.sh ../dist && cd ..
cmake --build build --config Release --target release   # or: python tools/make_release.py

The version comes from include/tex_codec.h, and the script refuses to package if texc.exe or dist/tex_codec_api.mjs report a different one - so a zip can never ship stale artifacts under a new version number.

To also create the GitHub release, using that version's CHANGELOG.md section verbatim as the release notes:

python tools/make_release.py --publish          # add --draft to stage it first

Publishing needs a clean working tree (it refuses otherwise) and either the gh CLI authenticated via gh auth login, or a GITHUB_TOKEN environment variable with repo scope - the token is read from the environment and never printed. It tags v<version> and attaches the zip.

Versioning

The version is declared once, in include/tex_codec.h:

#define TEXC_VERSION_MAJOR 1
#define TEXC_VERSION_MINOR 5
#define TEXC_VERSION_PATCH 0

Everything else derives from it, so nothing can drift: CMake parses those macros for the project/package version, stamps them into the Windows file-version resource of tex_codec.dll and texc.exe (visible under Properties → Details) and into SOVERSION on ELF builds, and the test suite fails if the compiled library, the header or the JS wrapper's VERSION disagree. Bumping a release means editing those three lines and adding a CHANGELOG.md entry.

New formats, profiles and swizzle modes are only ever appended to their enums, so numeric values stay stable across minor versions - which is what lets the JS wrapper and other FFI bindings mirror them by number.

Query it at runtime:

texc_version() packed (major << 16) | (minor << 8) | patch
texc_version_string() "1.5.0"
texc_build_info() tex_codec 1.5.0 (git 3f2a1b8, built Sep 2 2026, MSVC 1944, x64, Release)
texc version the same build line (texc version --short prints just 1.5.0)
await tex.version() / tex.buildInfo() from JavaScript, plus tex.checkVersion() to catch a stale .wasm beside a newer wrapper

Compile-time checks are available too:

#if TEXC_VERSION_NUMBER < TEXC_VERSION_ENCODE(1, 1, 0)
#  error "tex_codec 1.1.0 or newer is required"
#endif

CMake consumers can require a version with find_package(tex_codec 1.1 REQUIRED). The git hash in texc_build_info() is captured at configure time - re-run cmake to refresh it after committing.

Building (native)

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
ctest --test-dir build -C Release

Options: -DTEXC_BUILD_SHARED=ON (DLL/.so with exported symbols), -DTEXC_BUILD_TESTS=OFF, -DTEXC_BUILD_CLI=OFF.

Command line tool (texc)

The native build also produces texc (build/Release/texc.exe), a full front end for the library. --offset / --size open a window into any file, so you can point it straight at texture data inside a container (e.g. a G1T) without extracting it first:

# decode BC7 data at an offset inside a container straight to PNG
texc decode -i file.g1t --offset 0x1234 --size 0x8000 -f BC7 -w 256 -h 256 -o out.png

# Switch 2 (G1T platform 0x18): same flags, mode switch2
texc decode -i tex.bin -f BC7 -w 512 -h 512 --swizzle switch2 --arg auto -o out.png

# Switch-swizzled ASTC: unswizzle + decode in one step ('auto' block height)
texc decode -i tex.bin -f ASTC_8x8 -w 256 -h 256 --swizzle switch --arg auto -o out.tga

# G1T alpha atlas (final half-height dimensions, alpha folded automatically)
texc decode -i tex.bin -f ETC1_RGB_A_ATLAS -w 128 -h 128 -o out.png

# encode a TGA (dimensions come from the file) and reswizzle for PS4
texc encode -i art.tga -f BC1 --alpha-threshold 200 --swizzle ps4 -o tiled.bc1

# pure layout conversions, clearly named per direction
texc unswizzle -i tiled.bin  -f BC7 -w 256 -h 256 -m switch --arg auto -o linear.bin
texc reswizzle -i linear.bin -f BC7 -w 256 -h 256 -m switch --arg auto -o tiled.bin

# PS5: the whole mip chain is one surface - describe it, then pull a mip out
# (--arg auto recovers the 4KB / 64KB tile mode from the input size)
texc info -f BC7 -w 256 -h 256 -m ps5 --mips 7
texc unswizzle -i file.g1t --offset 0x38 -f BC7 -w 256 -h 256 -m ps5 --arg auto --mips 7 --mip 0 -o mip0.bin
texc reswizzle -i mip0.bin -f BC7 -w 256 -h 256 -m ps5 --mips 7 --mip 0 -o surface.bin

# image utilities
texc convert -i in.raw --src-profile RGBA8 --dst-profile BGRA8 -o out.raw
texc flip -i in.raw -w 64 -h 64 --dir y -o out.raw
texc crop -i in.raw -w 64 -h 64 --rect 16,16,32,32 -o out.raw

# sizes without touching data
texc info -f ASTC_8x8 -w 256 -h 256 -m switch --arg auto

Output format follows the extension: .png / .tga for decoded images, anything else raw bytes (--f32 writes float RGBA). Encode input is an uncompressed TGA or raw RGBA8 (with -w/-h). Discovery commands: texc formats, texc modes, texc profiles, texc version, texc help <command>.

Building (WebAssembly)

With an activated emsdk:

cd wasm && ./build_wasm.sh ../dist

or via CMake: emcmake cmake -S . -B build-wasm && cmake --build build-wasm.

Produces tex_codec.js (MODULARIZE loader, export name TexCodec) and tex_codec.wasm, plus copies of the ergonomic wrapper (tex_codec_api.mjs + tex_codec_api.d.ts), usable from web, workers, and Node.

Verify the module with node wasm/smoke_test.mjs dist (wrapper roundtrips, alpha-atlas fold, encoder options, swizzle identity, named exports).

Usage - C

#include "tex_codec.h"

size_t out_size = texc_decoded_size(width, height);
uint8_t *rgba = texc_alloc(out_size);
int rc = texc_decode(TEXC_FORMAT_BC7, data, data_size,
                     width, height, rgba, out_size);
if (rc != TEXC_OK) fprintf(stderr, "%s\n", texc_result_str(rc));

/* Swizzled console data in one step: */
rc = texc_decode_swizzled(TEXC_SWIZZLE_SWITCH, TEXC_FORMAT_ASTC_8x8,
                          width, height, data, data_size,
                          rgba, out_size, 0xFFFFFFFF /* auto block height */);

/* Encoding: */
size_t enc_size = texc_encoded_size(TEXC_FORMAT_ETC2_RGBA8, width, height);
uint8_t *enc = texc_alloc(enc_size);
rc = texc_encode(TEXC_FORMAT_ETC2_RGBA8, rgba, out_size,
                 width, height, enc, enc_size);

texc_free(rgba); texc_free(enc);

Usage - JavaScript / WASM

The recommended entry point is the wrapper in wasm/tex_codec_api.mjs (TypeScript declarations in wasm/tex_codec_api.d.ts; both are static, hand-maintained files that build_wasm.sh copies next to the module). It gives you real enums, async self-initialising methods, automatic WASM memory management, and one class per concern:

import { TexCodec, TexFormat, SwizzleMode, PixelProfile,
         SwitchBlockHeightAuto } from "./dist/tex_codec_api.mjs";

const tex = await TexCodec.load();   // finds ./tex_codec.js next to the file

// --- decoding -----------------------------------------------------------
const rgba  = await tex.decoder.decode(TexFormat.BC7, data, w, h);
const hdr   = await tex.decoder.decodeF32(TexFormat.BC6H_UF16, data, w, h);
const fromSwitch = await tex.decoder.decodeSwizzled(
    SwizzleMode.SWITCH, TexFormat.ASTC_8x8, tiled, w, h,
    SwitchBlockHeightAuto);

// G1T alpha atlas - pass the FINAL (half-height) dimensions:
const folded = await tex.decoder.decode(TexFormat.ETC1_RGB_A_ATLAS,
                                        g1tData, w, h);

// --- encoding -----------------------------------------------------------
const bc1 = await tex.encoder.encode(TexFormat.BC1, rgba, w, h,
                                     { alphaThreshold: 200 });
const atlas = await tex.encoder.encode(TexFormat.ETC1_RGB_A_ATLAS,
                                       rgba, w, h);

// --- unswizzling / reswizzling ------------------------------------------
const linear = await tex.swizzler.unswizzle(SwizzleMode.PS4,       // tiled -> linear
                                            TexFormat.BC7, tiled, w, h);
const tiledAgain = await tex.swizzler.reswizzle(SwizzleMode.PS4,   // linear -> tiled
                                                TexFormat.BC7, linear, w, h);
const bytes = await tex.swizzler.swizzledSize(SwizzleMode.SWITCH,
                                              TexFormat.BC1, w, h, 4);

// --- image utilities (tex.image) ----------------------------------------
const bgra    = await tex.image.convertProfile(PixelProfile.RGBA8,
                                               PixelProfile.BGRA8, rgba);
const flipped = await tex.image.flipY(rgba, w, h);          // 4 bpp default
const region  = await tex.image.crop(rgba, w, h, 4, 16, 16, 64, 64);

Per-format / per-mode helper methods

Every class also carries generated named helpers, so call sites don't need the enums - one per format and one per swizzle mode (fully typed in the .d.ts):

await tex.decoder.decodeBC7(data, w, h);
await tex.decoder.decodeETC1_RGB_A_ATLAS(g1tData, w, h);
await tex.decoder.decodePICA_ETC1_RGB8A4(tex3dsData, w, h);   // 3DS
await tex.decoder.decodeSwizzledSwitch(TexFormat.ASTC_8x8, tiled, w, h,
                                       SwitchBlockHeightAuto);
await tex.encoder.encodeETC2_RGBA8(rgba, w, h);
await tex.encoder.encodeBC1(rgba, w, h, { alphaThreshold: 200 });
await tex.encoder.encodedSizeASTC_4x4(w, h);
await tex.swizzler.unswizzlePS4(TexFormat.BC7, tiled, w, h);   // -> linear
await tex.swizzler.reswizzlePS4(TexFormat.BC7, linear, w, h);  // -> tiled

// PS5: a whole mip chain is one surface (mip tail first, mip 0 last)
const lay = await tex.swizzler.surfaceLayout(SwizzleMode.PS5, TexFormat.BC7,
                                             256, 256, /*mips*/ 7);
// lay.totalSize, lay.mips[0].offset, lay.mips[3].inTail, ...
const mip0 = await tex.swizzler.unswizzleMip(SwizzleMode.PS5, TexFormat.BC7,
                                             surface, 256, 256, 7, /*mip*/ 0);
// G1T has no tile-mode field: recover 4KB vs 64KB blocks from the data size
const tm = await tex.swizzler.detectPS5TileMode(TexFormat.BC7, 1024, 128, 6,
                                                surface.byteLength);

Mode names: PS4, PS5, Switch, Switch2, PSVita, X360, PSP, N3DS, WiiU, DX12_64KB. surfaceLayout / unswizzleMip / reswizzleMip take { sliceCount, slice, arg } options (arg = a PS5TileMode for PS5).

Every method throws TexCodecError (with a .code from TexResult) on failure. In a bundler, pass the module factory yourself: TexCodec.load({ factory: (await import("./tex_codec.js")).default }).

Raw exports

For callers that manage their own buffers, the full C API is exported on the module (tex.module) - see TexCodecModule in tex_codec_api.d.ts for typed signatures:

  • Generic: _texc_decode, _texc_encode, _texc_encode_ex, _texc_encode_with_options(..., alphaThreshold), _texc_decode_f32, _texc_unswizzle, _texc_swizzle, _texc_swizzled_size, _texc_decode_swizzled, _texc_get_surface_layout, _texc_unswizzle_mip, _texc_reswizzle_mip, sizes/metadata, _malloc / _free.
  • Per format (all 45): _texc_decode_bc7(src, srcSize, w, h, dst, dstSize), _texc_encode_etc2_rgba8(..., alphaThreshold), _texc_encoded_size_astc_12x12(w, h), …
  • Per swizzle mode (ps4, ps5, switch, switch2, psvita, x360, psp, n3ds, wiiu, dx12_64kb): _texc_unswizzle_ps4(fmt, w, h, src, srcSize, dst, dstSize, arg) (tiled→linear), _texc_reswizzle_switch(...) (linear→tiled; _texc_swizzle_<mode> is its legacy alias), and _texc_swizzled_size_wiiu(...).
  • Image utilities: _texc_convert_profile, _texc_profile_name, _texc_profile_bytes_per_pixel, _texc_flip_y, _texc_flip_x, _texc_crop.
  • Allocation helpers: _texc_decode_alloc, _texc_decode_f32_alloc, _texc_decode_swizzled_alloc, _texc_encode_alloc, _texc_swizzle_alloc, _texc_unswizzle_mip_alloc, with _texc_last_error() for the failure reason.

FFI notes (C# / Rust / Python)

The entire API is extern "C", uses only scalar types and raw pointers, and never throws or allocates on the caller's behalf (except the documented *_alloc WASM helpers). texc_format / texc_swizzle_mode are plain ints. Build with -DTEXC_BUILD_SHARED=ON and bind with P/Invoke, bindgen, or ctypes directly against include/tex_codec.h.

Layout

include/tex_codec.h      public C API
src/tex_codec.cpp        dispatch, validation, buffer management
src/codecs/              bcn.cpp, etc.cpp, astc.cpp, pvrtc.cpp, atc.cpp
src/unswizzle/           platform tiling conversions
wasm/                    Emscripten exports + build script
tests/                   roundtrip + swizzle-identity test suite

Credits / references

  • swizzle machinery credit: Piken / DwayneR, github.com/fdwr
  • Switch 2 (Orin) GOB layout: ScanMountGoat's tegra_swizzle
  • PS5 tiling: derived from AMD addrlib (MIT, GPUOpen-Drivers/pal, gfx10addrlib.cpp / gfx10SwizzlePattern.h); the pre-1.6 legacy layout is a port of id-daemon's RawTex Cooker
  • Format specifications: Khronos Data Format Spec (ASTC, ETC2/EAC), Microsoft D3D BC1-7 specs, AMD ATC extension, PowerVR PVRTC documentation
  • Reference codebases studied: tex-decoder, texture2ddecoder, Basis Universal, bimg, ctt

About

A standalone, portable C++ library (with a flat C API and WebAssembly build) for decoding and encoding GPU compressed texture formats, plus platform unswizzling and reswizzling for console texture layouts.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages