aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-fx/src')
-rw-r--r--crates/fparkan-fx/src/lib.rs1025
1 files changed, 1025 insertions, 0 deletions
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);
+ }
+ }
+}