1use bytemuck::{Pod, Zeroable};
2
3use super::AssetPath;
4use crate::diagnostics::AssetError;
5
6pub const SIDECAR_FILE_SUFFIX: &str = ".prefilter.bin";
7const SIDECAR_MAGIC: [u8; 16] = *b"SCENA_ENV_PF_V2\0";
8const SIDECAR_VERSION: u32 = 2;
9const SIDE_CAR_FACE_COUNT: usize = 6;
10const RGBA_CHANNELS: usize = 4;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[repr(u32)]
14pub enum EnvironmentSidecarProfile {
15 InteractiveWebGl2 = 1,
16 Reference = 2,
17}
18
19impl EnvironmentSidecarProfile {
20 pub const fn name(self) -> &'static str {
21 match self {
22 Self::InteractiveWebGl2 => "InteractiveWebGl2",
23 Self::Reference => "Reference",
24 }
25 }
26
27 fn from_raw(value: u32) -> Option<Self> {
28 match value {
29 1 => Some(Self::InteractiveWebGl2),
30 2 => Some(Self::Reference),
31 _ => None,
32 }
33 }
34}
35
36#[repr(C)]
37#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
38pub struct EnvironmentSidecarHeader {
39 magic: [u8; 16],
40 version: u32,
41 profile: u32,
42 source_sha256: [u8; 32],
43 cubemap_resolution: u32,
44 mip_count: u32,
45 brdf_lut_size: u32,
46 reserved: u32,
47 diffuse_rgb: [f32; 3],
48 reserved_f32: f32,
49 specular_float_count: u64,
50 brdf_lut_float_count: u64,
51}
52
53impl EnvironmentSidecarHeader {
54 fn new(input: EnvironmentSidecarHeaderInput<'_>) -> Result<Self, AssetError> {
55 Ok(Self {
56 magic: SIDECAR_MAGIC,
57 version: SIDECAR_VERSION,
58 profile: input.profile as u32,
59 source_sha256: decode_sha256_hex(input.source_sha256_hex)?,
60 cubemap_resolution: input.cubemap_resolution,
61 mip_count: input.mip_count,
62 brdf_lut_size: input.brdf_lut_size,
63 reserved: 0,
64 diffuse_rgb: input.diffuse_rgb,
65 reserved_f32: 0.0,
66 specular_float_count: input.specular_float_count,
67 brdf_lut_float_count: input.brdf_lut_float_count,
68 })
69 }
70
71 pub fn profile(&self) -> Option<EnvironmentSidecarProfile> {
72 EnvironmentSidecarProfile::from_raw(self.profile)
73 }
74
75 pub fn profile_name(&self) -> &'static str {
76 self.profile()
77 .map(EnvironmentSidecarProfile::name)
78 .unwrap_or("unknown")
79 }
80
81 pub const fn cubemap_resolution(&self) -> u32 {
82 self.cubemap_resolution
83 }
84
85 pub const fn mip_count(&self) -> u32 {
86 self.mip_count
87 }
88
89 pub const fn brdf_lut_size(&self) -> u32 {
90 self.brdf_lut_size
91 }
92
93 pub const fn source_sha256_bytes(&self) -> [u8; 32] {
94 self.source_sha256
95 }
96
97 pub fn source_sha256_hex(&self) -> String {
98 encode_sha256_hex(&self.source_sha256)
99 }
100
101 fn validate(&self, path: &AssetPath) -> Result<(), AssetError> {
102 if self.magic != SIDECAR_MAGIC {
103 return Err(sidecar_parse_error(path, "invalid sidecar magic"));
104 }
105 if self.version != SIDECAR_VERSION {
106 return Err(sidecar_parse_error(
107 path,
108 format!("unsupported sidecar version {}", self.version),
109 ));
110 }
111 if self.profile().is_none() {
112 return Err(sidecar_parse_error(
113 path,
114 format!("unsupported sidecar profile {}", self.profile),
115 ));
116 }
117 if self.cubemap_resolution == 0 || self.mip_count == 0 || self.brdf_lut_size == 0 {
118 return Err(sidecar_parse_error(
119 path,
120 "sidecar dimensions must be non-zero",
121 ));
122 }
123 if self.specular_float_count == 0 || self.brdf_lut_float_count == 0 {
124 return Err(sidecar_parse_error(
125 path,
126 "sidecar payload counts must be non-zero",
127 ));
128 }
129 Ok(())
130 }
131}
132
133struct EnvironmentSidecarHeaderInput<'a> {
134 profile: EnvironmentSidecarProfile,
135 source_sha256_hex: &'a str,
136 cubemap_resolution: u32,
137 mip_count: u32,
138 brdf_lut_size: u32,
139 diffuse_rgb: [f32; 3],
140 specular_float_count: u64,
141 brdf_lut_float_count: u64,
142}
143
144#[derive(Debug, Clone, PartialEq)]
145pub struct EnvironmentPrefilterSidecar {
146 header: EnvironmentSidecarHeader,
147 mips: Vec<[Vec<f32>; 6]>,
148 brdf_lut: Vec<f32>,
149}
150
151impl EnvironmentPrefilterSidecar {
152 pub fn new(
153 profile: EnvironmentSidecarProfile,
154 source_sha256_hex: &str,
155 cubemap_resolution: u32,
156 mips: Vec<[Vec<f32>; 6]>,
157 brdf_lut: Vec<f32>,
158 brdf_lut_size: u32,
159 diffuse_rgb: [f32; 3],
160 ) -> Result<Self, AssetError> {
161 let specular_float_count = mips
162 .iter()
163 .flat_map(|mip| mip.iter())
164 .map(Vec::len)
165 .sum::<usize>();
166 let header = EnvironmentSidecarHeader::new(EnvironmentSidecarHeaderInput {
167 profile,
168 source_sha256_hex,
169 cubemap_resolution,
170 mip_count: mips.len() as u32,
171 brdf_lut_size,
172 diffuse_rgb,
173 specular_float_count: specular_float_count as u64,
174 brdf_lut_float_count: brdf_lut.len() as u64,
175 })?;
176 Ok(Self {
177 header,
178 mips,
179 brdf_lut,
180 })
181 }
182
183 pub const fn header(&self) -> &EnvironmentSidecarHeader {
184 &self.header
185 }
186
187 pub fn profile(&self) -> EnvironmentSidecarProfile {
188 self.header
189 .profile()
190 .expect("EnvironmentPrefilterSidecar always carries a valid profile")
191 }
192
193 pub fn source_sha256_hex(&self) -> String {
194 self.header.source_sha256_hex()
195 }
196
197 pub const fn cubemap_resolution(&self) -> u32 {
198 self.header.cubemap_resolution
199 }
200
201 pub const fn mip_count(&self) -> u32 {
202 self.header.mip_count
203 }
204
205 pub const fn brdf_lut_size(&self) -> u32 {
206 self.header.brdf_lut_size
207 }
208
209 pub const fn diffuse_rgb(&self) -> [f32; 3] {
210 self.header.diffuse_rgb
211 }
212
213 pub fn mips(&self) -> &[[Vec<f32>; 6]] {
214 &self.mips
215 }
216
217 pub fn brdf_lut(&self) -> &[f32] {
218 &self.brdf_lut
219 }
220
221 pub fn to_bytes(&self) -> Vec<u8> {
222 let mut bytes = Vec::with_capacity(
223 std::mem::size_of::<EnvironmentSidecarHeader>()
224 + (self.header.specular_float_count as usize + self.brdf_lut.len())
225 * std::mem::size_of::<f32>(),
226 );
227 bytes.extend_from_slice(bytemuck::bytes_of(&self.header));
228 for mip in &self.mips {
229 for face in mip {
230 bytes.extend_from_slice(bytemuck::cast_slice(face));
231 }
232 }
233 bytes.extend_from_slice(bytemuck::cast_slice(&self.brdf_lut));
234 bytes
235 }
236
237 pub fn parse(path: impl Into<AssetPath>, bytes: &[u8]) -> Result<Self, AssetError> {
238 let path = path.into();
239 let header_size = std::mem::size_of::<EnvironmentSidecarHeader>();
240 if bytes.len() < header_size {
241 return Err(sidecar_parse_error(
242 &path,
243 format!(
244 "sidecar is {} byte(s), smaller than {header_size}-byte header",
245 bytes.len()
246 ),
247 ));
248 }
249 let header =
250 bytemuck::pod_read_unaligned::<EnvironmentSidecarHeader>(&bytes[..header_size]);
251 header.validate(&path)?;
252 let payload = &bytes[header_size..];
253 if !payload.len().is_multiple_of(std::mem::size_of::<f32>()) {
254 return Err(sidecar_parse_error(
255 &path,
256 "sidecar payload length is not f32-aligned",
257 ));
258 }
259 let floats = payload
260 .chunks_exact(std::mem::size_of::<f32>())
261 .map(|chunk| f32::from_le_bytes(chunk.try_into().expect("chunk size is 4")))
262 .collect::<Vec<_>>();
263 let expected_float_count =
264 (header.specular_float_count + header.brdf_lut_float_count) as usize;
265 if floats.len() != expected_float_count {
266 return Err(sidecar_parse_error(
267 &path,
268 format!(
269 "sidecar payload contains {} f32 value(s), expected {expected_float_count}",
270 floats.len()
271 ),
272 ));
273 }
274 let mut cursor = 0usize;
275 let mut mips = Vec::with_capacity(header.mip_count as usize);
276 for mip in 0..header.mip_count {
277 let mip_resolution = (header.cubemap_resolution >> mip).max(1);
278 let face_float_count = (mip_resolution as usize).pow(2) * RGBA_CHANNELS;
279 let mut faces: [Vec<f32>; SIDE_CAR_FACE_COUNT] = std::array::from_fn(|_| Vec::new());
280 for face in &mut faces {
281 let end = cursor.saturating_add(face_float_count);
282 let Some(slice) = floats.get(cursor..end) else {
283 return Err(sidecar_parse_error(
284 &path,
285 "sidecar mip chain ended before all faces were decoded",
286 ));
287 };
288 *face = slice.to_vec();
289 cursor = end;
290 }
291 mips.push(faces);
292 }
293 if cursor != header.specular_float_count as usize {
294 return Err(sidecar_parse_error(
295 &path,
296 "sidecar specular mip count does not match header",
297 ));
298 }
299 let brdf_lut = floats[cursor..].to_vec();
300 let expected_brdf_len = (header.brdf_lut_size as usize).pow(2) * 2;
301 if brdf_lut.len() != expected_brdf_len {
302 return Err(sidecar_parse_error(
303 &path,
304 format!(
305 "BRDF LUT has {} f32 value(s), expected {expected_brdf_len}",
306 brdf_lut.len()
307 ),
308 ));
309 }
310 Ok(Self {
311 header,
312 mips,
313 brdf_lut,
314 })
315 }
316}
317
318pub fn sidecar_path_for_environment(path: &AssetPath) -> AssetPath {
319 AssetPath::from(format!("{}{}", path.as_str(), SIDECAR_FILE_SUFFIX))
320}
321
322pub fn parse_sidecar_header(
323 path: impl Into<AssetPath>,
324 bytes: &[u8],
325) -> Result<EnvironmentSidecarHeader, AssetError> {
326 let path = path.into();
327 let header_size = std::mem::size_of::<EnvironmentSidecarHeader>();
328 if bytes.len() < header_size {
329 return Err(sidecar_parse_error(
330 &path,
331 format!(
332 "sidecar is {} byte(s), smaller than {header_size}-byte header",
333 bytes.len()
334 ),
335 ));
336 }
337 let header = bytemuck::pod_read_unaligned::<EnvironmentSidecarHeader>(&bytes[..header_size]);
338 header.validate(&path)?;
339 Ok(header)
340}
341
342pub fn sha256_hex(bytes: &[u8]) -> String {
343 use sha2::{Digest, Sha256};
344
345 let mut hasher = Sha256::new();
346 hasher.update(bytes);
347 encode_sha256_hex(&hasher.finalize().into())
348}
349
350fn decode_sha256_hex(value: &str) -> Result<[u8; 32], AssetError> {
351 if value.len() != 64 {
352 return Err(AssetError::Parse {
353 path: "environment-sidecar".to_string(),
354 reason: format!("SHA-256 must be 64 hex characters, got {}", value.len()),
355 });
356 }
357 let mut out = [0_u8; 32];
358 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
359 out[index] = decode_hex_byte(pair[0], pair[1]).ok_or_else(|| AssetError::Parse {
360 path: "environment-sidecar".to_string(),
361 reason: "SHA-256 contains non-hex characters".to_string(),
362 })?;
363 }
364 Ok(out)
365}
366
367fn encode_sha256_hex(bytes: &[u8; 32]) -> String {
368 const HEX: &[u8; 16] = b"0123456789abcdef";
369 let mut out = String::with_capacity(64);
370 for byte in bytes {
371 out.push(HEX[(byte >> 4) as usize] as char);
372 out.push(HEX[(byte & 0x0f) as usize] as char);
373 }
374 out
375}
376
377fn decode_hex_byte(high: u8, low: u8) -> Option<u8> {
378 Some(hex_nibble(high)? << 4 | hex_nibble(low)?)
379}
380
381fn hex_nibble(byte: u8) -> Option<u8> {
382 match byte {
383 b'0'..=b'9' => Some(byte - b'0'),
384 b'a'..=b'f' => Some(byte - b'a' + 10),
385 b'A'..=b'F' => Some(byte - b'A' + 10),
386 _ => None,
387 }
388}
389
390fn sidecar_parse_error(path: &AssetPath, reason: impl Into<String>) -> AssetError {
391 AssetError::Parse {
392 path: path.as_str().to_string(),
393 reason: reason.into(),
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn sidecar_round_trip_preserves_header_and_payload() {
403 let mips = vec![
404 std::array::from_fn(|face| vec![face as f32; 4 * 4 * 4]),
405 std::array::from_fn(|face| vec![10.0 + face as f32; 2 * 2 * 4]),
406 ];
407 let source_sha = "ae94a965734e6306216feb48d6dd7154b1dbc484a605200bf13cb9ae23799b7b";
408 let sidecar = EnvironmentPrefilterSidecar::new(
409 EnvironmentSidecarProfile::InteractiveWebGl2,
410 source_sha,
411 4,
412 mips,
413 vec![0.25; 8],
414 2,
415 [0.1, 0.2, 0.3],
416 )
417 .expect("sidecar builds");
418
419 let parsed =
420 EnvironmentPrefilterSidecar::parse("test.hdr.prefilter.bin", &sidecar.to_bytes())
421 .expect("sidecar parses");
422
423 assert_eq!(
424 parsed.profile(),
425 EnvironmentSidecarProfile::InteractiveWebGl2
426 );
427 assert_eq!(parsed.source_sha256_hex(), source_sha);
428 assert_eq!(parsed.cubemap_resolution(), 4);
429 assert_eq!(parsed.mip_count(), 2);
430 assert_eq!(parsed.brdf_lut_size(), 2);
431 assert_eq!(parsed.diffuse_rgb(), [0.1, 0.2, 0.3]);
432 assert_eq!(parsed.mips()[1][3], vec![13.0; 16]);
433 assert_eq!(parsed.brdf_lut(), &[0.25; 8]);
434 }
435
436 #[test]
437 fn legacy_v1_sidecar_is_rejected_after_khronos_baker_change() {
438 let mips = vec![std::array::from_fn(|face| vec![face as f32; 4 * 4 * 4])];
439 let source_sha = "ae94a965734e6306216feb48d6dd7154b1dbc484a605200bf13cb9ae23799b7b";
440 let sidecar = EnvironmentPrefilterSidecar::new(
441 EnvironmentSidecarProfile::InteractiveWebGl2,
442 source_sha,
443 4,
444 mips,
445 vec![0.25; 8],
446 2,
447 [0.1, 0.2, 0.3],
448 )
449 .expect("sidecar builds");
450 let mut legacy_bytes = sidecar.to_bytes();
451 legacy_bytes[..16].copy_from_slice(b"SCENA_ENV_PF_V1\0");
452 legacy_bytes[16..20].copy_from_slice(&1_u32.to_le_bytes());
453
454 let error = EnvironmentPrefilterSidecar::parse("legacy.hdr.prefilter.bin", &legacy_bytes)
455 .expect_err("legacy sidecar must not bypass the current baker");
456
457 let AssetError::Parse { reason, .. } = error else {
458 panic!("legacy sidecar should fail with a parse error");
459 };
460 assert!(
461 reason.contains("invalid sidecar magic")
462 || reason.contains("unsupported sidecar version"),
463 "legacy sidecar rejection must be explicit, got {reason}"
464 );
465 }
466}