use std::{
io::Cursor,
path::{Path, PathBuf},
process::Command,
};
use image::{DynamicImage, ImageFormat, RgbaImage};
use image_dds::{ddsfile::Dds, image_from_dds};
use tempfile::TempDir;
use crate::{
error::{DoomError, Result},
paths::RepoContext,
};
const BIM_HEADER_SIZE: usize = 63;
const BIM_MIPMAP_SIZE: usize = 36;
const DIVINITY_MAGIC: &[u8] = b"DIVINITY";
const BIM_MAGIC: &[u8] = b"BIM";
const FORMAT_BC1_LINEAR: u32 = 0x0A;
const FORMAT_BC3_LINEAR: u32 = 0x0B;
const FORMAT_RGBA8: u32 = 0x03;
const FORMAT_ALPHA: u32 = 0x05;
const FORMAT_BC1_SRGB: u32 = 0x21;
const FORMAT_BC3_SRGB: u32 = 0x22;
const FORMAT_BC1_ZERO_ALPHA: u32 = 0x36;
const FORMAT_BC4_LINEAR: u32 = 0x18;
const FORMAT_BC5_LINEAR: u32 = 0x19;
const FORMAT_BC7_LINEAR: u32 = 0x17;
const FORMAT_BC7_SRGB: u32 = 0x23;
const MATERIAL_ALBEDO: u32 = 0x01;
const MATERIAL_SPECULAR: u32 = 0x02;
const MATERIAL_NORMAL: u32 = 0x03;
const MATERIAL_SMOOTHNESS: u32 = 0x04;
const MATERIAL_BLOOMMASK: u32 = 0x08;
const MATERIAL_HEIGHTMAP: u32 = 0x09;
const MATERIAL_DECALALBEDO: u32 = 0x0A;
const MATERIAL_DECALNORMAL: u32 = 0x0B;
const MATERIAL_DECALSPECULAR: u32 = 0x0C;
const MATERIAL_PARTICLE: u32 = 0x0E;
const MATERIAL_UI: u32 = 0x12;
const MATERIAL_FONT: u32 = 0x13;
#[derive(Debug, Clone)]
pub struct BimMetadata {
pub texture_format: u32,
pub texture_material_kind: u32,
pub pixel_width: u32,
pub pixel_height: u32,
pub mip_count: u32,
pub bool_is_streamed: u8,
pub bool_no_mips: u8,
pub first_mip_decompressed_size: u32,
pub first_mip_compressed_size: u32,
pub raw_payload_offset: usize,
}
pub fn default_autoheckin_path(repo: &RepoContext) -> PathBuf {
repo.root().join("AutoHeckinTextureConverter-win64.exe")
}
pub fn read_bim_metadata(path: &Path) -> Result<BimMetadata> {
let data = read_standalone_bim_bytes(path)?;
let mip_count = u32::from_le_bytes(data[24..28].try_into().expect("fixed BIM header"));
if mip_count == 0 {
return Err(DoomError::message(format!(
"Invalid BIM mip count in {}: {mip_count}",
path.display()
)));
}
let raw_payload_offset = BIM_HEADER_SIZE + mip_count as usize * BIM_MIPMAP_SIZE;
if raw_payload_offset >= data.len() {
return Err(DoomError::message(format!(
"Invalid BIM payload offset in {}: {raw_payload_offset}",
path.display()
)));
}
Ok(BimMetadata {
texture_material_kind: u32::from_le_bytes(
data[8..12].try_into().expect("fixed BIM header"),
),
pixel_width: u32::from_le_bytes(data[12..16].try_into().expect("fixed BIM header")),
pixel_height: u32::from_le_bytes(data[16..20].try_into().expect("fixed BIM header")),
mip_count,
texture_format: u32::from_le_bytes(data[41..45].try_into().expect("fixed BIM header")),
bool_is_streamed: data[55],
bool_no_mips: data[57],
first_mip_decompressed_size: u32::from_le_bytes(
data[BIM_HEADER_SIZE + 20..BIM_HEADER_SIZE + 24]
.try_into()
.expect("fixed BIM mip header"),
),
first_mip_compressed_size: u32::from_le_bytes(
data[BIM_HEADER_SIZE + 28..BIM_HEADER_SIZE + 32]
.try_into()
.expect("fixed BIM mip header"),
),
raw_payload_offset,
})
}
pub fn supports_builtin_decode(path: &Path) -> bool {
read_bim_metadata(path)
.map(|metadata| {
matches!(
metadata.texture_format,
FORMAT_BC1_LINEAR
| FORMAT_BC1_SRGB
| FORMAT_BC1_ZERO_ALPHA
| FORMAT_BC3_LINEAR
| FORMAT_BC3_SRGB
| FORMAT_RGBA8
| FORMAT_ALPHA
)
})
.unwrap_or(false)
}
pub fn decode_bim_to_path(source_bim: &Path, output_path: &Path, dry_run: bool) -> Result<()> {
if dry_run {
println!(
"[dry-run] decode BIM {} -> {}",
source_bim.display(),
output_path.display()
);
return Ok(());
}
let dds_bytes = build_dds_bytes(source_bim)?;
let Some(parent) = output_path.parent() else {
return Err(DoomError::message(format!(
"Editable output path has no parent directory: {}",
output_path.display()
)));
};
std::fs::create_dir_all(parent)?;
match output_path
.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.as_deref()
{
Some("dds") => {
std::fs::write(output_path, dds_bytes)?;
}
Some("png") | Some("tif") | Some("tiff") => {
let image = dds_bytes_to_image(&dds_bytes)?;
DynamicImage::ImageRgba8(image).save(output_path)?;
}
other => {
return Err(DoomError::message(format!(
"Built-in decode does not support editable extension {:?}.",
other.unwrap_or("")
)));
}
}
println!(
"Decoded {} -> {}",
source_bim.display(),
output_path.display()
);
Ok(())
}
pub fn load_editable_image(path: &Path) -> Result<RgbaImage> {
match path
.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.as_deref()
{
Some("dds") => {
let bytes = std::fs::read(path)?;
dds_bytes_to_image(&bytes)
}
_ => Ok(image::open(path)?.to_rgba8()),
}
}
pub fn resolve_autoheckin_converter(
repo: &RepoContext,
converter_path: Option<&Path>,
) -> Result<PathBuf> {
let candidate = converter_path
.map(|path| repo.repo_path(path))
.unwrap_or_else(|| default_autoheckin_path(repo));
if !candidate.is_file() {
return Err(DoomError::message(format!(
"Missing AutoHeckin converter executable: {}. Place AutoHeckinTextureConverter-win64.exe in the repo root or pass --converter-path.",
candidate.display()
)));
}
Ok(candidate)
}
pub fn encode_image_to_bim(
repo: &RepoContext,
editable_image: &Path,
source_bim: &Path,
destination_bim: &Path,
converter_path: Option<&Path>,
dry_run: bool,
) -> Result<()> {
let converter = resolve_autoheckin_converter(repo, converter_path)?;
let staged_name = autoheckin_input_name(destination_bim, source_bim)?;
if dry_run {
println!(
"[dry-run] encode image {} -> {} via {}",
editable_image.display(),
destination_bim.display(),
converter.display()
);
return Ok(());
}
let Some(parent) = destination_bim.parent() else {
return Err(DoomError::message(format!(
"Output BIM path has no parent directory: {}",
destination_bim.display()
)));
};
std::fs::create_dir_all(parent)?;
let temp_dir = TempDir::new()?;
let staged_input = temp_dir.path().join(staged_name);
let generated_output = if staged_input.to_string_lossy().contains('$') {
staged_input.with_extension("")
} else {
staged_input.with_extension("tga")
};
let editable = image::open(editable_image)?.to_rgba8();
DynamicImage::ImageRgba8(editable).save_with_format(&staged_input, ImageFormat::Png)?;
let status = Command::new(&converter)
.arg(&staged_input)
.current_dir(temp_dir.path())
.env("AUTOHECKIN_SKIP_COMPRESSION", "1")
.output()?;
if !status.status.success() {
return Err(DoomError::message(
[
format!("AutoHeckin encode failed for {}.", editable_image.display()),
String::from_utf8_lossy(&status.stdout).trim().to_string(),
String::from_utf8_lossy(&status.stderr).trim().to_string(),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n"),
));
}
if !generated_output.is_file() {
return Err(DoomError::message(format!(
"AutoHeckin did not produce the expected BIM output: {}",
generated_output.display()
)));
}
std::fs::rename(generated_output, destination_bim)?;
println!(
"Encoded {} -> {}",
editable_image.display(),
destination_bim.display()
);
Ok(())
}
fn read_standalone_bim_bytes(path: &Path) -> Result<Vec<u8>> {
let data = std::fs::read(path)?;
if data.starts_with(DIVINITY_MAGIC) {
return Err(DoomError::message(format!(
"Compressed DIVINITY-wrapped BIM is not supported for built-in decode: {}",
path.display()
)));
}
if !data.starts_with(BIM_MAGIC) {
return Err(DoomError::message(format!(
"Expected a standalone BIM file starting with 'BIM': {}",
path.display()
)));
}
Ok(data)
}
fn build_dds_bytes(source_bim: &Path) -> Result<Vec<u8>> {
let data = read_standalone_bim_bytes(source_bim)?;
let metadata = read_bim_metadata(source_bim)?;
let payload_end = metadata.raw_payload_offset + metadata.first_mip_compressed_size as usize;
let payload = data
.get(metadata.raw_payload_offset..payload_end)
.ok_or_else(|| {
DoomError::message(format!(
"Unexpected BIM payload length in {}",
source_bim.display()
))
})?;
let mut dds_bytes = build_dds_header(&metadata)?;
dds_bytes.extend_from_slice(payload);
Ok(dds_bytes)
}
fn dds_bytes_to_image(dds_bytes: &[u8]) -> Result<RgbaImage> {
let dds = Dds::read(&mut Cursor::new(dds_bytes)).map_err(|error| {
DoomError::message(format!("Could not read generated DDS payload: {error}"))
})?;
image_from_dds(&dds, 0).map_err(|error| {
DoomError::message(format!("Could not decode generated DDS payload: {error}"))
})
}
fn build_dds_header(metadata: &BimMetadata) -> Result<Vec<u8>> {
let (
dds_type,
pf_flags,
rgb_bits,
r_bit_mask,
g_bit_mask,
b_bit_mask,
a_bit_mask,
img_flags,
linear_size,
) = match metadata.texture_format {
FORMAT_BC1_LINEAR | FORMAT_BC1_SRGB | FORMAT_BC1_ZERO_ALPHA => (
827611204_u32,
4_u32,
0_u32,
0_u32,
0_u32,
0_u32,
0_i32,
659463_u32,
metadata.first_mip_decompressed_size,
),
FORMAT_BC3_LINEAR | FORMAT_BC3_SRGB => (
894720068_u32,
4_u32,
0_u32,
0_u32,
0_u32,
0_u32,
0_i32,
659463_u32,
metadata.first_mip_decompressed_size,
),
FORMAT_RGBA8 => (
0_u32,
65_u32,
32_u32,
16_711_680_u32,
65_280_u32,
255_u32,
-16_777_216_i32,
135_183_u32,
(metadata.pixel_width * 16).div_ceil(8),
),
FORMAT_ALPHA => (
0_u32,
2_u32,
8_u32,
0_u32,
0_u32,
0_u32,
255_i32,
135_183_u32,
(metadata.pixel_width * 16).div_ceil(8),
),
other => {
return Err(DoomError::message(format!(
"Built-in decode does not support BIM texture format {other}."
)));
}
};
let mut header = Vec::with_capacity(128);
header.extend_from_slice(b"DDS ");
header.extend_from_slice(&124_u32.to_le_bytes());
header.extend_from_slice(&img_flags.to_le_bytes());
header.extend_from_slice(&metadata.pixel_height.to_le_bytes());
header.extend_from_slice(&metadata.pixel_width.to_le_bytes());
header.extend_from_slice(&linear_size.to_le_bytes());
header.extend_from_slice(&1_u32.to_le_bytes());
header.extend_from_slice(&1_u32.to_le_bytes());
for _ in 0..11 {
header.extend_from_slice(&0_u32.to_le_bytes());
}
header.extend_from_slice(&32_u32.to_le_bytes());
header.extend_from_slice(&pf_flags.to_le_bytes());
header.extend_from_slice(&dds_type.to_le_bytes());
header.extend_from_slice(&rgb_bits.to_le_bytes());
header.extend_from_slice(&r_bit_mask.to_le_bytes());
header.extend_from_slice(&g_bit_mask.to_le_bytes());
header.extend_from_slice(&b_bit_mask.to_le_bytes());
header.extend_from_slice(&a_bit_mask.to_le_bytes());
header.extend_from_slice(&4096_u32.to_le_bytes());
header.extend_from_slice(&0_u32.to_le_bytes());
header.extend_from_slice(&0_u32.to_le_bytes());
header.extend_from_slice(&0_u32.to_le_bytes());
header.extend_from_slice(&0_u32.to_le_bytes());
Ok(header)
}
fn guess_material_kind_from_stem(stem: &str, default_format_is_bc1: bool) -> u32 {
let lowered = stem.to_ascii_lowercase();
if lowered.ends_with("_n") || lowered.ends_with("_normal") {
return MATERIAL_NORMAL;
}
if lowered.ends_with("_s") {
return MATERIAL_SPECULAR;
}
if lowered.ends_with("_g") {
return MATERIAL_SMOOTHNESS;
}
if lowered.ends_with("_e") {
return MATERIAL_BLOOMMASK;
}
if lowered.ends_with("_h") {
return MATERIAL_HEIGHTMAP;
}
if lowered.ends_with("_sss") {
return 0x06;
}
if default_format_is_bc1 {
MATERIAL_ALBEDO
} else {
0
}
}
fn format_token(texture_format: u32) -> &'static str {
match texture_format {
FORMAT_BC3_LINEAR | FORMAT_BC3_SRGB => "bc3",
FORMAT_BC4_LINEAR => "bc4",
FORMAT_BC5_LINEAR => "bc5",
FORMAT_BC7_LINEAR | FORMAT_BC7_SRGB => "bc7",
FORMAT_ALPHA => "alpha",
_ => "",
}
}
fn material_kind_token(material_kind: u32) -> &'static str {
match material_kind {
MATERIAL_UI => "ui",
MATERIAL_DECALNORMAL => "decalnormal",
MATERIAL_DECALALBEDO => "decalalbedo",
MATERIAL_DECALSPECULAR => "decalspecular",
MATERIAL_PARTICLE => "particle",
MATERIAL_HEIGHTMAP => "heightmap",
MATERIAL_FONT => "font",
MATERIAL_BLOOMMASK => "bloommask",
_ => "",
}
}
fn autoheckin_input_name(destination_bim: &Path, source_bim: &Path) -> Result<String> {
let metadata = read_bim_metadata(source_bim)?;
let destination_stem = destination_bim
.file_stem()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| destination_bim.display().to_string());
let destination_name = destination_bim
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| destination_stem.clone());
let default_format_is_bc1 = matches!(
metadata.texture_format,
FORMAT_BC1_LINEAR | FORMAT_BC1_SRGB | FORMAT_BC1_ZERO_ALPHA
);
let mut tokens = Vec::new();
let format_token = format_token(metadata.texture_format);
if !format_token.is_empty() {
tokens.push(format_token.to_string());
}
if metadata.bool_no_mips != 0 {
tokens.push("nomips".to_string());
}
let inferred_material_kind =
guess_material_kind_from_stem(&destination_stem, default_format_is_bc1);
let material_kind_token = material_kind_token(metadata.texture_material_kind);
if !material_kind_token.is_empty() && inferred_material_kind != metadata.texture_material_kind {
tokens.push(format!("mtlkind={material_kind_token}"));
}
if tokens.is_empty() {
return Ok(format!("{destination_stem}.png"));
}
Ok(format!("{destination_name}${}.png", tokens.join("$")))
}