Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions arma-file-formats/src/real_virtuality/p3d/lod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use crate::real_virtuality::{
},
types::{D3DColorValue, STPair, TransformMatrix, XYZTriplet},
};
use std::{collections::HashMap, ops::Range};

use binrw::{BinRead, BinResult, NullString};

use super::ODOLArgs;
Expand Down Expand Up @@ -135,6 +137,38 @@ pub struct Lod {
unk_end_2: Option<u8>,
}

impl Lod {
/// The faces of each of [`Lod::sections`], as ranges of [`Lod::faces`]. `None` for a section
/// whose offsets don't fall between faces.
///
/// Sections follow each other without gaps. Checked against the test models (versions 40 to
/// 75) and all models of Arma 3.
#[must_use]
pub fn section_faces(&self) -> Vec<Option<Range<usize>>> {
let index_size = match self.sections.first() {
Some(section) if section.short_indices => 2,
_ => 4,
};
// Face index by offset, including the end of the last face.
let mut offsets = HashMap::with_capacity(self.faces.len() + 1);
let mut offset = 0;
for (index, face) in self.faces.iter().enumerate() {
offsets.insert(offset, index);
offset += index_size * (1 + face.vertex_indices.len());
}
offsets.insert(offset, self.faces.len());

self.sections
.iter()
.map(|section| {
let start = *offsets.get(&(section.face_lower_index as usize))?;
let end = *offsets.get(&(section.face_upper_index as usize))?;
(start <= end).then_some(start..end)
})
.collect()
}
}

#[derive(Debug, Default, PartialEq, Clone, BinRead)]
#[br(import(args: ODOLArgs))]
pub struct Proxy {
Expand Down Expand Up @@ -287,6 +321,9 @@ pub struct LodSection {
#[br(calc = args.version < 69)]
pub short_indices: bool,

/// Start and end of the section's faces in [`Lod::faces`] as byte offsets, with the vertex
/// count and each vertex index of a face taking the size of an index: 4 bytes from version
/// 69 (a triangle takes 16 bytes), 2 before. See [`Lod::section_faces`] for face indices.
pub face_lower_index: u32,
pub face_upper_index: u32,

Expand All @@ -309,10 +346,12 @@ pub struct LodSection {

#[br(if(args.version >= 67))]
#[br(map = |x: i32| x >= 1)]
pub unk_matrix_exists: bool,
pub has_unk_floats: bool,

#[br(if(args.version >= 67 && unk_matrix_exists))]
pub unk_matrix: TransformMatrix,
/// 11 floats, not a 4x3 matrix. Only seen on sections of collimator sights (ACO, ARCO, ...)
/// in Arma 3, the flag before them was always 0 or 1.
#[br(if(args.version >= 67 && has_unk_floats))]
pub unk_floats: Option<[f32; 11]>,
}

#[derive(Debug, Default, PartialEq, Eq, Clone, BinRead)]
Expand Down Expand Up @@ -390,6 +429,47 @@ pub struct UVSet {
pub uv_data: Option<Vec<u8>>,
}

impl UVSet {
/// The coordinates, one `[u, v]` per vertex.
///
/// Discretized sets (version 45 and up) store each coordinate as an `i16` spread over the
/// set's `min..=max` range, older ones store `f32`s.
#[must_use]
pub fn uvs(&self) -> Vec<[f32; 2]> {
let count = self.vertices_count as usize;
let data = match (&self.uv_data, &self.default_value) {
(Some(data), _) => data.as_slice(),
(None, Some(value)) => value.as_slice(),
(None, None) => return vec![[0.0; 2]; count],
};
let size = self.value_size as usize;
let decode = |value: &[u8]| -> [f32; 2] {
if self.is_discretized {
// In f64: ranges can be thousands wide, f32 put values off by 1e-4.
let range = |raw: [u8; 2], min: Option<f32>, max: Option<f32>| {
let (min, max) = (f64::from(min.unwrap_or(0.0)), f64::from(max.unwrap_or(0.0)));
let step = (f64::from(i16::from_le_bytes(raw)) + 32767.0) / 65534.0;
step.mul_add(max - min, min) as f32
};
[
range([value[0], value[1]], self.min_u, self.max_u),
range([value[2], value[3]], self.min_v, self.max_v),
]
} else {
let float = |raw: &[u8]| f32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]);
[float(&value[0..4]), float(&value[4..8])]
}
};

if self.uv_data.is_none() {
return data
.get(..size)
.map_or_else(|| vec![[0.0; 2]; count], |v| vec![decode(v); count]);
}
data.chunks_exact(size).take(count).map(decode).collect()
}
}

#[derive(Debug, Default, PartialEq, Eq, Clone, BinRead)]
pub struct AnimationRTWeight {
pub small_count: i32,
Expand Down
1 change: 0 additions & 1 deletion arma-file-formats/src/real_virtuality/p3d/odol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ pub fn read_lods(

#[allow(clippy::needless_range_loop)]
for i in 0..count {
println!("Lod Index: {i}");
reader.seek(SeekFrom::Start(start_address_of_lods[i].into()))?;
lods.push(Lod::read_options(reader, endian, (args,))?);
}
Expand Down
22 changes: 21 additions & 1 deletion arma-file-formats/src/real_virtuality/paa/mipmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ impl Mipmap {
}

pub fn read<T>(&mut self, reader: &mut T, paa_type: PaaType) -> Result<(), PaaError>
where
T: Read + Seek,
{
self.read_with(reader, paa_type, false)
}

/// Like [`Mipmap::read`], but DXT1 and DXT5 data stays BC1 and BC3 blocks, see
/// [`super::Paa::from_reader_blocks`].
pub(crate) fn read_with<T>(
&mut self,
reader: &mut T,
paa_type: PaaType,
keep_blocks: bool,
) -> Result<(), PaaError>
where
T: Read + Seek,
{
Expand All @@ -55,7 +69,13 @@ impl Mipmap {
if let Some(data_pos) = self.data_pos {
reader.seek(SeekFrom::Start(data_pos))?;
self.data = reader.read_bytes(self.data_size as usize)?;
self.decompress_data(paa_type)?;
if keep_blocks && matches!(paa_type, PaaType::DXT1 | PaaType::DXT5) {
if self.is_lzo_compressed {
self.data = decompress_all(&self.data, None)?;
}
} else {
self.decompress_data(paa_type)?;
}
Ok(())
} else {
Err(PaaError::InvalidState)
Expand Down
26 changes: 25 additions & 1 deletion arma-file-formats/src/real_virtuality/paa/paa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ impl Paa {
reader: &mut R,
indicies_to_load: Option<&[u32]>,
) -> Result<Self, PaaError>
where
R: Read + Seek,
{
Self::read_with(reader, indicies_to_load, false)
}

/// Reads like [`Paa::from_reader`], but keeps the data of DXT1 and DXT5 mipmaps as BC1 and
/// BC3 blocks instead of decoding it to RGBA, e.g. to upload them to a GPU as they are.
/// Mipmaps of other formats are decoded as usual.
pub fn from_reader_blocks<R>(
reader: &mut R,
indicies_to_load: Option<&[u32]>,
) -> Result<Self, PaaError>
where
R: Read + Seek,
{
Self::read_with(reader, indicies_to_load, true)
}

fn read_with<R>(
reader: &mut R,
indicies_to_load: Option<&[u32]>,
keep_blocks: bool,
) -> Result<Self, PaaError>
where
R: Read + Seek,
{
Expand Down Expand Up @@ -104,7 +128,7 @@ impl Paa {
|| indicies_to_load.unwrap().is_empty()
|| indicies_to_load.unwrap().contains(&index_counter)
{
mipmap.read(reader, paa.magic_number)?;
mipmap.read_with(reader, paa.magic_number, keep_blocks)?;
}

paa.mipmaps.push(mipmap);
Expand Down
90 changes: 90 additions & 0 deletions arma-file-formats/tests/real_virtuality/p3d_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,93 @@ fn test_all() {
}
});
}

fn test_models() -> impl Iterator<Item = (String, ODOL)> {
let mut paths: Vec<_> = fs::read_dir(INPUT_PATH_PREFIX)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "p3d"))
.collect();
paths.sort();
paths.into_iter().map(|path| {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let odol = ODOL::from_path(&path).unwrap_or_else(|err| panic!("{name}: {err}"));
(name, odol)
})
}

#[test]
fn section_faces() {
for (name, odol) in test_models() {
for (index, lod) in odol.lods.iter().enumerate() {
let ranges = lod.section_faces();
assert_eq!(ranges.len(), lod.sections.len());
let mut end = 0;
for range in &ranges {
let range = range
.clone()
.unwrap_or_else(|| panic!("{name} LOD {index}"));
assert_eq!(range.start, end, "{name} LOD {index}: sections leave a gap");
end = range.end;
}
if !ranges.is_empty() {
assert_eq!(
end,
lod.faces.len(),
"{name} LOD {index}: faces after the sections"
);
}
}
}
}

#[test]
fn uvs() {
let mut discretized = false;
let mut floats = false;
for (name, odol) in test_models() {
for lod in odol.lods.iter().filter(|lod| !lod.faces.is_empty()) {
for set in std::iter::once(&lod.default_uv_set).chain(&lod.uv_sets) {
let uvs = set.uvs();
assert_eq!(uvs.len(), lod.vertices.len(), "{name}");
assert!(uvs.iter().flatten().all(|v| v.is_finite()), "{name}");
if set.is_discretized {
discretized = true;
let (min_u, max_u) = (set.min_u.unwrap(), set.max_u.unwrap());
let (min_v, max_v) = (set.min_v.unwrap(), set.max_v.unwrap());
// `i16::MIN` lies one step below the range.
let slack_u = (max_u - min_u) / 65534.0 + 1e-6;
let slack_v = (max_v - min_v) / 65534.0 + 1e-6;
assert!(
uvs.iter().all(|&[u, v]| u >= min_u - slack_u
&& u <= max_u + slack_u
&& v >= min_v - slack_v
&& v <= max_v + slack_v),
"{name}: UVs outside of their range"
);
} else {
floats = true;
}
}
}
}
assert!(discretized && floats);
}

/// Sections of collimator sights have 11 floats after the stages, which used to be read as a
/// 12 float matrix and broke the next section.
#[test]
fn section_unk_floats() {
let odol = ODOL::from_path(format!("{}acco_aco_f.p3d", INPUT_PATH_PREFIX)).unwrap();
assert_eq!(odol.version, 73);
let sections = odol.lods.iter().flat_map(|lod| &lod.sections);
let with_floats: Vec<_> = sections.filter(|s| s.has_unk_floats).collect();
assert!(!with_floats.is_empty());
for section in with_floats {
let floats = section.unk_floats.unwrap();
assert!(floats.iter().all(|v| v.is_finite() && v.abs() <= 1.0));
}
for lod in &odol.lods {
assert!(lod.section_faces().iter().all(Option::is_some));
}
}
39 changes: 39 additions & 0 deletions arma-file-formats/tests/real_virtuality/paa_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,42 @@ fn rgba5551_djalka_decoding() {

assert_eq!(paa.pixel_type, PixelType::Rgba);
}

#[test]
fn blocks() {
for (name, format, block_size) in [
("logo_dxt1_2048.paa", squish::Format::Bc1, 8),
("logo_dxt5_128.paa", squish::Format::Bc3, 16),
("medic_cross_ca_dxt5.paa", squish::Format::Bc3, 16),
] {
let data = fs::read(format!("{}{}", INPUT_PATH_PREFIX, name)).unwrap();
let blocks = Paa::from_reader_blocks(&mut Cursor::new(&data), None).unwrap();
let decoded = Paa::from_reader(&mut Cursor::new(&data), None).unwrap();
assert_eq!(blocks.mipmaps.len(), decoded.mipmaps.len());
for (raw, rgba) in blocks.mipmaps.iter().zip(&decoded.mipmaps) {
let (width, height) = (raw.width as usize, raw.height as usize);
let block_count = width.div_ceil(4) * height.div_ceil(4);
assert_eq!(
raw.data.len(),
block_count * block_size,
"{name} {width}x{height}"
);
let mut pixels = vec![0; width * height * 4];
format.decompress(&raw.data, width, height, &mut pixels);
assert_eq!(pixels, rgba.data, "{name} {width}x{height}");
}
}
}

#[test]
fn blocks_other_formats() {
// Formats other than DXT1 and DXT5 are decoded like `from_reader` does.
let data = fs::read(format!(
"{}argb4444_StaszowWinter_ca.paa",
INPUT_PATH_PREFIX
))
.unwrap();
let blocks = Paa::from_reader_blocks(&mut Cursor::new(&data), Some(&[0])).unwrap();
let decoded = Paa::from_reader(&mut Cursor::new(&data), Some(&[0])).unwrap();
assert_eq!(blocks.mipmaps[0].data, decoded.mipmaps[0].data);
}
2 changes: 1 addition & 1 deletion arma-file-formats/tests/test-data-setup/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fn main() {
println!(".env not found...");
}

for i in 1..4 {
for i in 1..5 {
let file = format!("arma-file-formats/tests/test-data/rv-test-data-part-{i}.7z");

println!("Extracting test data: {file}");
Expand Down
Binary file not shown.
Loading