"""Pure-Python DDS (DirectDraw Surface) parser for block-compressed textures.
Parses a ``.dds`` container into a :class:`DDSTexture`: the target ``VkFormat``
int, dimensions, the format's block size (8 or 16 bytes), and the list of
tightly-packed mip-level block bytes ready for ``Engine.upload_texture_blocks``.
No new dependency: just ``struct`` + ``numpy`` (numpy only for the byte slicing
helper). Vulkan is referenced for its ``VK_FORMAT_BC*`` integer constants at
call time only, never at import, so this module imports cleanly with no device.
Scope (foundation phase): 2D single-array BC1-BC7. Cubemaps, texture arrays,
and non-BC formats raise ``ValueError`` (the caller converts that to a one-time
WARNING + a -1 "couldn't resolve" sentinel).
"""
import logging
import struct
from dataclasses import dataclass
from pathlib import Path
import vulkan as vk
__all__ = ["DDSTexture", "load_dds", "vk_format_block_size"]
log = logging.getLogger(__name__)
_DDS_MAGIC = b"DDS "
# Single source of truth for block sizes, keyed by VkFormat name (BC/ASTC/ETC2).
# Both the DDS format maps below and the int-keyed ``vk_format_block_size``
# helper (used by the KTX2 reader, which carries the VkFormat int directly)
# derive their block sizes from this table so the two never drift. BC1 and BC4
# use 8-byte blocks; every other BC variant, ASTC-4x4, and ETC2-RGBA use 16.
_BC_BLOCK_SIZE: dict[str, int] = {
"VK_FORMAT_BC1_RGB_UNORM_BLOCK": 8,
"VK_FORMAT_BC1_RGB_SRGB_BLOCK": 8,
"VK_FORMAT_BC1_RGBA_UNORM_BLOCK": 8,
"VK_FORMAT_BC1_RGBA_SRGB_BLOCK": 8,
"VK_FORMAT_BC2_UNORM_BLOCK": 16,
"VK_FORMAT_BC2_SRGB_BLOCK": 16,
"VK_FORMAT_BC3_UNORM_BLOCK": 16,
"VK_FORMAT_BC3_SRGB_BLOCK": 16,
"VK_FORMAT_BC4_UNORM_BLOCK": 8,
"VK_FORMAT_BC4_SNORM_BLOCK": 8,
"VK_FORMAT_BC5_UNORM_BLOCK": 16,
"VK_FORMAT_BC5_SNORM_BLOCK": 16,
"VK_FORMAT_BC6H_UFLOAT_BLOCK": 16,
"VK_FORMAT_BC6H_SFLOAT_BLOCK": 16,
"VK_FORMAT_BC7_UNORM_BLOCK": 16,
"VK_FORMAT_BC7_SRGB_BLOCK": 16,
# ASTC 4x4 and ETC2 RGBA both pack 16 B/block over a 4x4 texel footprint,
# so the memory.py block-row math (`blocks_w*4`, `blocks_h*4`) is correct for
# them, same as BC7. SCOPE: 4x4 ASTC only. Larger ASTC footprints (5x4..12x12)
# also pack 16 B/block but cover MORE texels, so the 4-texel block-row math
# would compute the WRONG buffer size: do NOT add them here.
"VK_FORMAT_ASTC_4x4_UNORM_BLOCK": 16,
"VK_FORMAT_ASTC_4x4_SRGB_BLOCK": 16,
"VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK": 16,
"VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK": 16,
}
# DDS_HEADER.dwFlags / caps bits we care about.
_DDSCAPS2_CUBEMAP = 0x200
_DDSCAPS2_VOLUME = 0x200000
# DDS_HEADER.ddspf.dwFlags
_DDPF_FOURCC = 0x4
# Legacy four-character code -> VkFormat name. Block size comes from
# ``_BC_BLOCK_SIZE`` so there is a single authority for the 8/16 split.
_FOURCC_MAP: dict[bytes, str] = {
b"DXT1": "VK_FORMAT_BC1_RGBA_UNORM_BLOCK",
b"DXT2": "VK_FORMAT_BC2_UNORM_BLOCK",
b"DXT3": "VK_FORMAT_BC2_UNORM_BLOCK",
b"DXT4": "VK_FORMAT_BC3_UNORM_BLOCK",
b"DXT5": "VK_FORMAT_BC3_UNORM_BLOCK",
b"ATI1": "VK_FORMAT_BC4_UNORM_BLOCK",
b"BC4U": "VK_FORMAT_BC4_UNORM_BLOCK",
b"BC4S": "VK_FORMAT_BC4_SNORM_BLOCK",
b"ATI2": "VK_FORMAT_BC5_UNORM_BLOCK",
b"BC5U": "VK_FORMAT_BC5_UNORM_BLOCK",
b"BC5S": "VK_FORMAT_BC5_SNORM_BLOCK",
}
# DXGI_FORMAT enum value -> VkFormat name. SRGB variants chosen for albedo,
# UNORM for data maps; the file's own flag decides which.
_DXGI_MAP: dict[int, str] = {
70: "VK_FORMAT_BC1_RGBA_UNORM_BLOCK", # BC1_TYPELESS
71: "VK_FORMAT_BC1_RGBA_UNORM_BLOCK", # BC1_UNORM
72: "VK_FORMAT_BC1_RGBA_SRGB_BLOCK", # BC1_UNORM_SRGB
73: "VK_FORMAT_BC2_UNORM_BLOCK", # BC2_TYPELESS
74: "VK_FORMAT_BC2_UNORM_BLOCK", # BC2_UNORM
75: "VK_FORMAT_BC2_SRGB_BLOCK", # BC2_UNORM_SRGB
76: "VK_FORMAT_BC3_UNORM_BLOCK", # BC3_TYPELESS
77: "VK_FORMAT_BC3_UNORM_BLOCK", # BC3_UNORM
78: "VK_FORMAT_BC3_SRGB_BLOCK", # BC3_UNORM_SRGB
79: "VK_FORMAT_BC4_UNORM_BLOCK", # BC4_TYPELESS
80: "VK_FORMAT_BC4_UNORM_BLOCK", # BC4_UNORM
81: "VK_FORMAT_BC4_SNORM_BLOCK", # BC4_SNORM
82: "VK_FORMAT_BC5_UNORM_BLOCK", # BC5_TYPELESS
83: "VK_FORMAT_BC5_UNORM_BLOCK", # BC5_UNORM
84: "VK_FORMAT_BC5_SNORM_BLOCK", # BC5_SNORM
94: "VK_FORMAT_BC6H_UFLOAT_BLOCK", # BC6H_TYPELESS
95: "VK_FORMAT_BC6H_UFLOAT_BLOCK", # BC6H_UF16
96: "VK_FORMAT_BC6H_SFLOAT_BLOCK", # BC6H_SF16
97: "VK_FORMAT_BC7_UNORM_BLOCK", # BC7_TYPELESS
98: "VK_FORMAT_BC7_UNORM_BLOCK", # BC7_UNORM
99: "VK_FORMAT_BC7_SRGB_BLOCK", # BC7_UNORM_SRGB
}
[docs]
@dataclass(frozen=True)
class DDSTexture:
"""A parsed block-compressed DDS texture.
Attributes:
vk_format: ``VkFormat`` integer (a ``VK_FORMAT_BC*`` constant).
width / height: top-level mip dimensions in texels.
block_size: bytes per 4x4 block (8 for BC1/BC4, 16 otherwise).
mips: tightly-packed block bytes per mip level, level 0 first.
"""
vk_format: int
width: int
height: int
block_size: int
mips: list[bytes]
def _resolve_format(name: str) -> int:
"""Map a ``VK_FORMAT_BC*`` name to its int, only touching vulkan at call time."""
return int(getattr(vk, name))
# int(VkFormat) -> block_size, resolved lazily from ``_BC_BLOCK_SIZE`` the first
# time it is asked for (vulkan is only touched at call time, never at import).
_BC_BLOCK_SIZE_BY_INT: dict[int, int] = {}
[docs]
def load_dds(source: str | Path | bytes) -> DDSTexture:
"""Parse a DDS file path or raw bytes into a :class:`DDSTexture`.
Raises ``ValueError`` for a bad magic, an unknown FourCC/DXGI format, or an
unsupported container shape (cubemap / volume / array). The caller converts
that to a one-time WARNING + -1 sentinel.
"""
data = Path(source).read_bytes() if isinstance(source, str | Path) else bytes(source)
if data[:4] != _DDS_MAGIC:
raise ValueError("Not a DDS file: bad magic")
if len(data) < 128:
raise ValueError("DDS file truncated: header incomplete")
dw_size = struct.unpack_from("<I", data, 4)[0]
if dw_size != 124:
raise ValueError(f"Unexpected DDS_HEADER size {dw_size} (expected 124)")
height = struct.unpack_from("<I", data, 12)[0]
width = struct.unpack_from("<I", data, 16)[0]
mip_count = struct.unpack_from("<I", data, 28)[0] or 1
caps2 = struct.unpack_from("<I", data, 112)[0]
if caps2 & (_DDSCAPS2_CUBEMAP | _DDSCAPS2_VOLUME):
raise ValueError("DDS cubemap/volume textures are not supported in this phase")
pf_flags = struct.unpack_from("<I", data, 80)[0]
four_cc = data[84:88]
if not (pf_flags & _DDPF_FOURCC):
raise ValueError("Uncompressed DDS (no FourCC) is not supported; use PNG/JPG")
if four_cc == b"DX10":
if len(data) < 148:
raise ValueError("DDS DX10 header truncated")
dxgi_format = struct.unpack_from("<I", data, 128)[0]
array_size = struct.unpack_from("<I", data, 140)[0]
if array_size > 1:
raise ValueError("DDS texture arrays are not supported in this phase")
if dxgi_format not in _DXGI_MAP:
raise ValueError(f"Unsupported DXGI format {dxgi_format}")
fmt_name = _DXGI_MAP[dxgi_format]
data_offset = 148
else:
if four_cc not in _FOURCC_MAP:
raise ValueError(f"Unsupported DDS FourCC {four_cc!r}")
fmt_name = _FOURCC_MAP[four_cc]
data_offset = 128
block_size = _BC_BLOCK_SIZE[fmt_name]
vk_format = _resolve_format(fmt_name)
mips: list[bytes] = []
off = data_offset
for i in range(mip_count):
mw = max(1, width >> i)
mh = max(1, height >> i)
blocks_w = max(1, (mw + 3) // 4)
blocks_h = max(1, (mh + 3) // 4)
size = blocks_w * blocks_h * block_size
chunk = data[off:off + size]
if len(chunk) < size:
raise ValueError(
f"DDS data truncated at mip {i}: need {size} bytes, have {len(chunk)}"
)
mips.append(chunk)
off += size
log.debug("DDS parsed: %dx%d fmt=%d block=%d mips=%d", width, height, vk_format, block_size, len(mips))
return DDSTexture(vk_format=vk_format, width=width, height=height, block_size=block_size, mips=mips)