diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 12:12:27 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 12:13:32 +0300 |
| commit | d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch) | |
| tree | a0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-fx | |
| parent | 7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff) | |
| download | fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip | |
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-fx')
| -rw-r--r-- | crates/fparkan-fx/Cargo.toml | 15 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1025 |
2 files changed, 1040 insertions, 0 deletions
diff --git a/crates/fparkan-fx/Cargo.toml b/crates/fparkan-fx/Cargo.toml new file mode 100644 index 0000000..9bdae4d --- /dev/null +++ b/crates/fparkan-fx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-fx" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-binary = { path = "../fparkan-binary" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } + +[lints] +workspace = true diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs new file mode 100644 index 0000000..9675507 --- /dev/null +++ b/crates/fparkan-fx/src/lib.rs @@ -0,0 +1,1025 @@ +#![forbid(unsafe_code)] +//! FXID effect contracts. + +use fparkan_binary::{Cursor, DecodeError}; +use std::sync::Arc; + +/// `FXID` `NRes` entry type. +pub const FXID_KIND: u32 = 0x4449_5846; +const HEADER_SIZE: usize = 60; + +/// FX document. +#[derive(Clone, Debug)] +pub struct FxDocument { + bytes: Arc<[u8]>, + header: FxHeader, + commands: Vec<FxCommand>, +} + +/// FX header. +#[derive(Clone, Debug, PartialEq)] +pub struct FxHeader { + /// Number of commands in the stream. + pub command_count: u32, + /// Time mode. + pub time_mode: u32, + /// Duration in seconds. + pub duration_seconds: f32, + /// Phase jitter. + pub phase_jitter: f32, + /// Opaque flags. + pub flags: u32, + /// Opaque settings id. + pub settings_id: u32, + /// Random spatial shift. + pub random_shift: [f32; 3], + /// Local pivot. + pub pivot: [f32; 3], + /// Base scale. + pub scale: [f32; 3], +} + +/// FX opcode. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum FxOpcode { + /// Opcode 1. + Op1, + /// Opcode 2. + Op2, + /// Opcode 3. + Op3, + /// Opcode 4. + Op4, + /// Opcode 5. + Op5, + /// Opcode 6. + Op6, + /// Opcode 7. + Op7, + /// Opcode 8. + Op8, + /// Opcode 9. + Op9, + /// Opcode 10. + Op10, +} + +/// FX resource reference. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxResourceRef { + /// Fixed archive field bytes. + pub archive_raw: [u8; 32], + /// Fixed name field bytes. + pub name_raw: [u8; 32], +} + +/// FX command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxCommand { + /// Raw command word. + pub word: u32, + /// Decoded opcode. + pub opcode: FxOpcode, + /// Enabled bit. + pub enabled: bool, + /// Command body after the word. + pub raw_body: Arc<[u8]>, + /// Resource references discovered in known command layouts. + pub resource_refs: Vec<FxResourceRef>, +} + +/// FX instance id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FxInstanceId(pub u64); + +/// FX seed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FxSeed(pub u64); + +/// External transform snapshot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Transform { + /// Translation. + pub translation: [f32; 3], + /// Rotation quaternion. + pub rotation: [f32; 4], + /// Scale. + pub scale: [f32; 3], +} + +impl Default for Transform { + fn default() -> Self { + Self { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + scale: [1.0; 3], + } + } +} + +/// Game time in ticks. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GameTime(pub u64); + +/// FX runtime state. +#[derive(Clone, Debug)] +pub struct FxState { + /// Instance id. + pub id: FxInstanceId, + /// Source document. + pub document: Arc<FxDocument>, + /// Seed. + pub seed: FxSeed, + /// Transform at creation time. + pub transform: Transform, + /// Last updated time. + pub time: GameTime, + /// RNG call count reserved for deterministic captures. + pub rng_calls: u64, + /// Lifecycle phase. + pub lifecycle: FxLifecycle, +} + +/// FX lifecycle phase. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FxLifecycle { + /// Running and eligible to emit. + #[default] + Running, + /// Stopped and not eligible to emit. + Stopped, + /// Ended permanently for the current instance. + Ended, +} + +/// Visual FX emission produced from a command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxPrimitive { + /// Command index. + pub command_index: u32, + /// Opcode. + pub opcode: FxOpcode, +} + +/// Sound FX emission produced from a command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxSoundEvent { + /// Command index. + pub command_index: u32, +} + +/// FX emission. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FxEmission { + /// Visual primitive. + Primitive(FxPrimitive), + /// Sound event. + Sound(FxSoundEvent), +} + +/// FX decode/runtime error. +#[derive(Debug)] +pub enum FxError { + /// Binary decode error. + Decode(DecodeError), + /// Unknown opcode. + UnknownOpcode { + /// Command index. + index: u32, + /// Raw opcode byte. + opcode: u8, + }, + /// Command stream exceeds payload. + CommandOutOfBounds { + /// Command index. + index: u32, + /// Expected command end. + expected_end: u64, + /// Payload size. + payload_size: u64, + }, + /// Resource reference cannot be framed from body. + InvalidResourceRef { + /// Command index. + index: u32, + /// Opcode. + opcode: FxOpcode, + }, + /// A referenced dependency is missing. + MissingDependency { + /// Effect name or stable effect id. + effect: String, + /// Command index. + command_index: u32, + /// Archive name. + archive: String, + /// Resource name. + name: String, + }, +} + +impl From<DecodeError> for FxError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl std::fmt::Display for FxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::UnknownOpcode { index, opcode } => { + write!(f, "unknown FX opcode {opcode} at command {index}") + } + Self::CommandOutOfBounds { + index, + expected_end, + payload_size, + } => write!( + f, + "FX command {index} out of bounds: expected_end={expected_end}, payload_size={payload_size}" + ), + Self::InvalidResourceRef { index, opcode } => { + write!(f, "invalid FX resource reference in command {index} ({opcode:?})") + } + Self::MissingDependency { + effect, + command_index, + archive, + name, + } => write!( + f, + "missing FX dependency: effect={effect}, command={command_index}, archive={archive}, name={name}" + ), + } + } +} + +impl std::error::Error for FxError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::UnknownOpcode { .. } + | Self::CommandOutOfBounds { .. } + | Self::InvalidResourceRef { .. } + | Self::MissingDependency { .. } => None, + } + } +} + +/// Decodes an `FXID` payload. +/// +/// # Errors +/// +/// Returns [`FxError`] when the 60-byte header, fixed-size command stream, or +/// exact EOF framing is invalid. +pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> { + let mut cursor = Cursor::new(&bytes); + let header = read_header(&mut cursor)?; + debug_assert_eq!(cursor.offset(), HEADER_SIZE as u64); + let mut commands = Vec::with_capacity( + usize::try_from(header.command_count) + .map_err(|_| FxError::Decode(DecodeError::IntegerOverflow))?, + ); + for index in 0..header.command_count { + let start = cursor.offset(); + let word = cursor.read_u32_le()?; + let opcode_byte = (word & 0xFF) as u8; + let opcode = opcode_from_byte(opcode_byte).ok_or(FxError::UnknownOpcode { + index, + opcode: opcode_byte, + })?; + let command_size = command_size(opcode); + let expected_end = start + .checked_add(u64::try_from(command_size).map_err(|_| DecodeError::IntegerOverflow)?) + .ok_or(DecodeError::IntegerOverflow)?; + if expected_end > bytes.len() as u64 { + return Err(FxError::CommandOutOfBounds { + index, + expected_end, + payload_size: bytes.len() as u64, + }); + } + let body_size = command_size + .checked_sub(4) + .ok_or(DecodeError::IntegerOverflow)?; + let body = cursor.read_exact(body_size)?; + let raw_body = Arc::from(body.to_vec().into_boxed_slice()); + let resource_refs = resource_refs(index, opcode, body)?; + commands.push(FxCommand { + word, + opcode, + enabled: ((word >> 8) & 1) != 0, + raw_body, + resource_refs, + }); + } + cursor.require_eof()?; + Ok(FxDocument { + bytes, + header, + commands, + }) +} + +/// Creates an FX instance. +/// +/// # Errors +/// +/// Currently returns [`FxError`] only for future resource/lifecycle validation +/// hooks; creation is deterministic for a decoded document. +pub fn create_instance( + document: Arc<FxDocument>, + seed: FxSeed, + transform: Transform, +) -> Result<FxState, FxError> { + Ok(FxState { + id: FxInstanceId(seed.0), + document, + seed, + transform, + time: GameTime::default(), + rng_calls: 0, + lifecycle: FxLifecycle::Running, + }) +} + +/// Updates FX simulation time without emitting side effects. +/// +/// # Errors +/// +/// Reserved for future runtime validation. +pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> { + state.time = time; + Ok(()) +} + +/// Emits active commands without advancing state. +/// +/// # Errors +/// +/// Reserved for future resource/runtime validation. +pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> { + if state.lifecycle != FxLifecycle::Running { + return Ok(()); + } + for (index, command) in state.document.commands.iter().enumerate() { + if !command.enabled { + continue; + } + let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; + if command.opcode == FxOpcode::Op2 { + out.push(FxEmission::Sound(FxSoundEvent { command_index })); + } else { + out.push(FxEmission::Primitive(FxPrimitive { + command_index, + opcode: command.opcode, + })); + } + } + Ok(()) +} + +/// Stops an FX instance. +pub fn stop(state: &mut FxState) { + state.lifecycle = FxLifecycle::Stopped; +} + +/// Restarts a stopped FX instance from a time. +pub fn restart(state: &mut FxState, time: GameTime) { + state.lifecycle = FxLifecycle::Running; + state.time = time; +} + +/// Ends an FX instance permanently. +pub fn end(state: &mut FxState) { + state.lifecycle = FxLifecycle::Ended; +} + +/// Validates resource references through a caller-provided dependency probe. +/// +/// # Errors +/// +/// Returns [`FxError::MissingDependency`] with effect, command, archive and +/// resource name context when the probe reports a missing resource. +pub fn validate_dependencies( + document: &FxDocument, + effect: &str, + exists: impl Fn(&[u8], &[u8]) -> bool, +) -> Result<(), FxError> { + for (index, command) in document.commands.iter().enumerate() { + for reference in &command.resource_refs { + if !exists(reference.archive_name(), reference.resource_name()) { + return Err(FxError::MissingDependency { + effect: effect.to_string(), + command_index: u32::try_from(index) + .map_err(|_| DecodeError::IntegerOverflow)?, + archive: String::from_utf8_lossy(reference.archive_name()).into_owned(), + name: String::from_utf8_lossy(reference.resource_name()).into_owned(), + }); + } + } + } + Ok(()) +} + +/// Builds a byte-stable capture for emitted commands. +/// +/// # Errors +/// +/// Returns [`FxError`] when emission fails. +pub fn canonical_emission_capture(state: &FxState) -> Result<Vec<u8>, FxError> { + let mut emissions = Vec::new(); + emit(state, &mut emissions)?; + let mut out = Vec::new(); + for emission in emissions { + match emission { + FxEmission::Primitive(primitive) => { + out.extend_from_slice( + format!("P,{}, {:?}\n", primitive.command_index, primitive.opcode).as_bytes(), + ); + } + FxEmission::Sound(sound) => { + out.extend_from_slice(format!("S,{}\n", sound.command_index).as_bytes()); + } + } + } + Ok(out) +} + +impl FxDocument { + /// Returns original bytes. + #[must_use] + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the parsed header. + #[must_use] + pub fn header(&self) -> &FxHeader { + &self.header + } + + /// Returns commands in disk order. + #[must_use] + pub fn commands(&self) -> &[FxCommand] { + &self.commands + } +} + +impl FxResourceRef { + /// Archive name before first NUL, ASCII-trimmed. + #[must_use] + pub fn archive_name(&self) -> &[u8] { + bounded_cstr(&self.archive_raw) + } + + /// Resource name before first NUL, ASCII-trimmed. + #[must_use] + pub fn resource_name(&self) -> &[u8] { + bounded_cstr(&self.name_raw) + } +} + +fn read_header(cursor: &mut Cursor<'_>) -> Result<FxHeader, FxError> { + Ok(FxHeader { + command_count: cursor.read_u32_le()?, + time_mode: cursor.read_u32_le()?, + duration_seconds: cursor.read_f32_le()?, + phase_jitter: cursor.read_f32_le()?, + flags: cursor.read_u32_le()?, + settings_id: cursor.read_u32_le()?, + random_shift: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + pivot: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + scale: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + }) +} + +fn opcode_from_byte(opcode: u8) -> Option<FxOpcode> { + match opcode { + 1 => Some(FxOpcode::Op1), + 2 => Some(FxOpcode::Op2), + 3 => Some(FxOpcode::Op3), + 4 => Some(FxOpcode::Op4), + 5 => Some(FxOpcode::Op5), + 6 => Some(FxOpcode::Op6), + 7 => Some(FxOpcode::Op7), + 8 => Some(FxOpcode::Op8), + 9 => Some(FxOpcode::Op9), + 10 => Some(FxOpcode::Op10), + _ => None, + } +} + +fn command_size(opcode: FxOpcode) -> usize { + match opcode { + FxOpcode::Op1 => 224, + FxOpcode::Op2 => 148, + FxOpcode::Op3 => 200, + FxOpcode::Op4 => 204, + FxOpcode::Op5 => 112, + FxOpcode::Op6 => 4, + FxOpcode::Op7 | FxOpcode::Op9 | FxOpcode::Op10 => 208, + FxOpcode::Op8 => 248, + } +} + +fn resource_refs(index: u32, opcode: FxOpcode, body: &[u8]) -> Result<Vec<FxResourceRef>, FxError> { + if !has_resource_ref(opcode) { + return Ok(Vec::new()); + } + let raw = body + .get(..64) + .ok_or(FxError::InvalidResourceRef { index, opcode })?; + let mut archive_raw = [0; 32]; + let mut name_raw = [0; 32]; + archive_raw.copy_from_slice(&raw[..32]); + name_raw.copy_from_slice(&raw[32..64]); + Ok(vec![FxResourceRef { + archive_raw, + name_raw, + }]) +} + +fn has_resource_ref(opcode: FxOpcode) -> bool { + matches!( + opcode, + FxOpcode::Op2 + | FxOpcode::Op3 + | FxOpcode::Op4 + | FxOpcode::Op5 + | FxOpcode::Op7 + | FxOpcode::Op8 + | FxOpcode::Op9 + | FxOpcode::Op10 + ) +} + +fn bounded_cstr(raw: &[u8]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + trim_ascii(&raw[..len]) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + &bytes[start..end] +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + #[test] + fn decodes_synthetic_opcodes_and_refs() { + let mut bytes = header(2); + bytes.extend_from_slice(&command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav")); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(document.header().command_count, 2); + assert_eq!(document.commands()[0].opcode, FxOpcode::Op2); + assert!(document.commands()[0].enabled); + assert_eq!( + document.commands()[0].resource_refs[0].archive_name(), + b"sounds.lib" + ); + assert_eq!(document.commands()[1].opcode, FxOpcode::Op6); + assert!(document.commands()[1].raw_body.is_empty()); + } + + #[test] + fn header_is_exactly_sixty_bytes_and_command_sizes_are_fixed() { + let mut bytes = header(10); + for opcode in 1..=10_u32 { + bytes.extend_from_slice(&command(0x0100 | opcode, opcode_size(opcode))); + } + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(header(0).len(), HEADER_SIZE); + assert_eq!(document.commands().len(), 10); + for (index, command) in document.commands().iter().enumerate() { + let opcode = u32::try_from(index + 1).expect("opcode"); + assert_eq!(command.raw_body.len() + 4, opcode_size(opcode)); + } + } + + #[test] + fn opcode6_four_byte_command_is_accepted() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(document.commands()[0].opcode, FxOpcode::Op6); + assert!(document.commands()[0].raw_body.is_empty()); + } + + #[test] + fn rejects_unknown_opcode_at_command_index() { + let mut bytes = header(1); + bytes.extend_from_slice(&99_u32.to_le_bytes()); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("unknown opcode"); + + assert!(matches!( + err, + FxError::UnknownOpcode { + index: 0, + opcode: 99 + } + )); + } + + #[test] + fn rejects_command_count_that_exceeds_payload() { + let mut bytes = header(2); + bytes.extend_from_slice(&command(0x0106, 4)); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("out of bounds"); + + assert!(matches!( + err, + FxError::Decode(DecodeError::UnexpectedEof { .. }) | FxError::CommandOutOfBounds { .. } + )); + } + + #[test] + fn rejects_trailing_bytes_after_command_stream() { + let mut bytes = header(0); + bytes.push(0); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("trailing"); + + assert!(matches!( + err, + FxError::Decode(DecodeError::TrailingBytes { .. }) + )); + } + + #[test] + fn fixed_resource_refs_preserve_tails() { + let mut bytes = header(1); + let mut command = command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav"); + command[4 + 20] = 0xAB; + command[36 + 20] = 0xCD; + bytes.extend_from_slice(&command); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + let reference = &document.commands()[0].resource_refs[0]; + assert_eq!(reference.archive_name(), b"sounds.lib"); + assert_eq!(reference.resource_name(), b"boom.wav"); + assert_eq!(reference.archive_raw[20], 0xAB); + assert_eq!(reference.name_raw[20], 0xCD); + } + + #[test] + fn missing_dependency_error_contains_effect_command_archive_and_name() { + let mut bytes = header(1); + bytes.extend_from_slice(&command_with_ref( + 0x0102, + 148, + b"sounds.lib", + b"missing.wav", + )); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + let err = validate_dependencies(&document, "spark", |_, _| false) + .expect_err("missing dependency"); + + assert!(matches!( + err, + FxError::MissingDependency { + ref effect, + command_index: 0, + ref archive, + ref name, + } if effect == "spark" && archive == "sounds.lib" && name == "missing.wav" + )); + assert!(err.to_string().contains("spark")); + assert!(err.to_string().contains("missing.wav")); + } + + #[test] + fn update_and_emit_are_separate() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut state = create_instance(document, FxSeed(7), Transform::default()).expect("state"); + update(&mut state, GameTime(42)).expect("update"); + let before = state.time; + let mut out = Vec::new(); + + emit(&state, &mut out).expect("emit"); + + assert_eq!(state.time, before); + assert_eq!(out.len(), 1); + } + + #[test] + fn create_records_seed_transform_and_start_time() { + let bytes = header(0); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let transform = Transform { + translation: [1.0, 2.0, 3.0], + rotation: [0.0, 0.0, 0.0, 1.0], + scale: [4.0, 5.0, 6.0], + }; + + let state = create_instance(document, FxSeed(77), transform).expect("state"); + + assert_eq!(state.id, FxInstanceId(77)); + assert_eq!(state.seed, FxSeed(77)); + assert_eq!(state.transform, transform); + assert_eq!(state.time, GameTime(0)); + assert_eq!(state.rng_calls, 0); + assert_eq!(state.lifecycle, FxLifecycle::Running); + } + + #[test] + fn stable_command_order_and_emission_capture_are_seed_stable() { + let mut bytes = header(3); + bytes.extend_from_slice(&command(0x0101, 224)); + bytes.extend_from_slice(&command(0x0102, 148)); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut first = + create_instance(document.clone(), FxSeed(5), Transform::default()).expect("first"); + let mut second = + create_instance(document, FxSeed(5), Transform::default()).expect("second"); + + update(&mut first, GameTime(9)).expect("update"); + update(&mut second, GameTime(9)).expect("update"); + + assert_eq!( + canonical_emission_capture(&first).expect("first capture"), + canonical_emission_capture(&second).expect("second capture") + ); + assert_eq!( + canonical_emission_capture(&first).expect("capture"), + b"P,0, Op1\nS,1\nP,2, Op6\n" + ); + } + + #[test] + fn stop_restart_end_lifecycle_controls_emission() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut state = create_instance(document, FxSeed(1), Transform::default()).expect("state"); + + assert!(!canonical_emission_capture(&state) + .expect("running") + .is_empty()); + stop(&mut state); + assert_eq!(state.lifecycle, FxLifecycle::Stopped); + assert!(canonical_emission_capture(&state) + .expect("stopped") + .is_empty()); + restart(&mut state, GameTime(12)); + assert_eq!(state.lifecycle, FxLifecycle::Running); + assert_eq!(state.time, GameTime(12)); + assert!(!canonical_emission_capture(&state) + .expect("restarted") + .is_empty()); + end(&mut state); + assert_eq!(state.lifecycle, FxLifecycle::Ended); + assert!(canonical_emission_capture(&state) + .expect("ended") + .is_empty()); + } + + #[test] + fn unrelated_rng_stream_use_does_not_perturb_fx_capture() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let state = create_instance(document, FxSeed(3), Transform::default()).expect("state"); + let before = canonical_emission_capture(&state).expect("before"); + + let mut unrelated = 0x1234_u64; + for _ in 0..32 { + unrelated = unrelated.rotate_left(7).wrapping_mul(17); + } + + assert_ne!(unrelated, 0); + assert_eq!(canonical_emission_capture(&state).expect("after"), before); + } + + #[test] + fn arbitrary_command_streams_are_bounded_and_panic_free() { + for len in 0..256usize { + let mut bytes = vec![0xA5; len]; + if len >= HEADER_SIZE { + bytes[0..4].copy_from_slice(&1_u32.to_le_bytes()); + } + let result = std::panic::catch_unwind(|| { + let _ = decode_fxid(Arc::from(bytes.into_boxed_slice())); + }); + assert!(result.is_ok()); + } + } + + #[test] + fn licensed_corpus_fxid_exact_eof_and_distribution() { + for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + let mut opcodes = BTreeMap::<FxOpcode, usize>::new(); + let mut time_modes = BTreeMap::<u32, usize>::new(); + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| entry.meta().type_id == FXID_KIND) + { + let payload = archive.payload(entry.id()).expect("payload"); + let document = decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())) + .unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; + *time_modes.entry(document.header().time_mode).or_insert(0) += 1; + for command in document.commands() { + *opcodes.entry(command.opcode).or_insert(0) += 1; + } + } + } + + assert_eq!(count, expected_count, "{corpus} FXID count"); + assert!(!opcodes.contains_key(&FxOpcode::Op6), "{corpus} opcode 6"); + for mode in time_modes.keys() { + assert!( + matches!(*mode, 0 | 1 | 2 | 4 | 5 | 14 | 15 | 16 | 17), + "{corpus} unexpected time mode {mode}" + ); + } + } + } + + #[test] + fn licensed_corpus_fxid_emission_captures_are_approved() { + for (corpus, expected_count, expected_emitting, expected_hash) in [ + ("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64), + ("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + let mut emitting = 0usize; + let mut hash = FNV_OFFSET; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| entry.meta().type_id == FXID_KIND) + { + let payload = archive.payload(entry.id()).expect("payload"); + let document = Arc::new( + decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).unwrap_or_else( + |err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()), + ), + ); + let state = + create_instance(document, FxSeed(count as u64), Transform::default()) + .expect("fx state"); + let capture = canonical_emission_capture(&state).expect("capture"); + if !capture.is_empty() { + emitting += 1; + } + hash_bytes(&mut hash, entry.name_bytes()); + hash_bytes(&mut hash, &capture); + count += 1; + } + } + + assert_eq!(count, expected_count, "{corpus} FXID count"); + assert_eq!(emitting, expected_emitting, "{corpus} emitting FXID count"); + assert_eq!(hash, expected_hash, "{corpus} FXID capture hash"); + } + } + + fn header(command_count: u32) -> Vec<u8> { + let mut out = Vec::with_capacity(HEADER_SIZE); + out.extend_from_slice(&command_count.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + for _ in 0..9 { + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + } + assert_eq!(out.len(), HEADER_SIZE); + out + } + + fn command(word: u32, size: usize) -> Vec<u8> { + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&word.to_le_bytes()); + out.resize(size, 0); + out + } + + fn command_with_ref(word: u32, size: usize, archive: &[u8], name: &[u8]) -> Vec<u8> { + let mut out = command(word, size); + copy_cstr(&mut out[4..36], archive); + copy_cstr(&mut out[36..68], name); + out + } + + fn opcode_size(opcode: u32) -> usize { + match opcode { + 1 => 224, + 2 => 148, + 3 => 200, + 4 => 204, + 5 => 112, + 6 => 4, + 7 | 9 | 10 => 208, + 8 => 248, + _ => unreachable!("test opcode"), + } + } + + fn copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn corpus_root(name: &str) -> Option<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec<PathBuf> { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } + + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + fn hash_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(FNV_PRIME); + } + } +} |
