Skip to main content

rakata_formats/ssf/
mod.rs

1//! SSF binary reader and writer.
2//!
3//! SSF (sound set file) resources map predefined sound-event slots to TLK
4//! string references (`StrRef`).
5//!
6//! ## Shape of the container
7//!
8//! A 12-byte header, then 28 `i32` slots located by `sound_table_offset`, then
9//! trailing reserved values. A slot's position is its meaning -- the list of 28
10//! character actions is fixed and hardcoded -- and an unset slot holds `-1`
11//! rather than `0`, since `0` is a valid `StrRef`.
12//!
13//! Byte-level field maps live in `docs/src/formats/audio/ssf.md`, with the
14//! engine's own load sequence and a note on the trailing entries, whose count
15//! matches a community tool rather than anything measured.
16
17mod reader;
18mod writer;
19
20pub use reader::{read_ssf, read_ssf_from_bytes};
21pub use writer::{write_ssf, write_ssf_to_vec};
22
23use thiserror::Error;
24
25use rakata_core::StrRef;
26
27use crate::binary::{self, DecodeBinary, EncodeBinary};
28
29/// SSF header size in bytes.
30const FILE_HEADER_SIZE: usize = 12;
31/// SSF entry size in bytes.
32const SOUND_ENTRY_SIZE: usize = 4;
33/// Number of core KotOR sound slots.
34const SOUND_SLOT_COUNT: usize = 28;
35/// Canonical count of trailing reserved entries emitted by PyKotor writer.
36const CANONICAL_RESERVED_ENTRY_COUNT: usize = 12;
37/// SSF file signature.
38const SSF_MAGIC: [u8; 4] = *b"SSF ";
39/// SSF version used by KotOR.
40const SSF_VERSION_V11: [u8; 4] = *b"V1.1";
41/// Canonical sound-table offset used by KotOR writers.
42const CANONICAL_SOUND_TABLE_OFFSET: u32 = 12;
43
44/// Sound-slot identifiers for the SSF core table.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum SsfSoundSlot {
47    /// Battle cry variant 1.
48    BattleCry1 = 0,
49    /// Battle cry variant 2.
50    BattleCry2 = 1,
51    /// Battle cry variant 3.
52    BattleCry3 = 2,
53    /// Battle cry variant 4.
54    BattleCry4 = 3,
55    /// Battle cry variant 5.
56    BattleCry5 = 4,
57    /// Battle cry variant 6.
58    BattleCry6 = 5,
59    /// Selection voice variant 1.
60    Select1 = 6,
61    /// Selection voice variant 2.
62    Select2 = 7,
63    /// Selection voice variant 3.
64    Select3 = 8,
65    /// Attack grunt variant 1.
66    AttackGrunt1 = 9,
67    /// Attack grunt variant 2.
68    AttackGrunt2 = 10,
69    /// Attack grunt variant 3.
70    AttackGrunt3 = 11,
71    /// Pain grunt variant 1.
72    PainGrunt1 = 12,
73    /// Pain grunt variant 2.
74    PainGrunt2 = 13,
75    /// Low-health warning.
76    LowHealth = 14,
77    /// Death sound.
78    Dead = 15,
79    /// Critical-hit sound.
80    CriticalHit = 16,
81    /// Target-immune reaction.
82    TargetImmune = 17,
83    /// Lay-mine action sound.
84    LayMine = 18,
85    /// Disarm-mine action sound.
86    DisarmMine = 19,
87    /// Begin-stealth action sound.
88    BeginStealth = 20,
89    /// Begin-search action sound.
90    BeginSearch = 21,
91    /// Begin-unlock action sound.
92    BeginUnlock = 22,
93    /// Unlock-failed reaction.
94    UnlockFailed = 23,
95    /// Unlock-success reaction.
96    UnlockSuccess = 24,
97    /// Separated-from-party reaction.
98    SeparatedFromParty = 25,
99    /// Rejoined-party reaction.
100    RejoinedParty = 26,
101    /// Poisoned reaction.
102    Poisoned = 27,
103}
104
105impl SsfSoundSlot {
106    /// Ordered list of all core SSF sound slots.
107    pub const ALL: &'static [Self] = &[
108        Self::BattleCry1,
109        Self::BattleCry2,
110        Self::BattleCry3,
111        Self::BattleCry4,
112        Self::BattleCry5,
113        Self::BattleCry6,
114        Self::Select1,
115        Self::Select2,
116        Self::Select3,
117        Self::AttackGrunt1,
118        Self::AttackGrunt2,
119        Self::AttackGrunt3,
120        Self::PainGrunt1,
121        Self::PainGrunt2,
122        Self::LowHealth,
123        Self::Dead,
124        Self::CriticalHit,
125        Self::TargetImmune,
126        Self::LayMine,
127        Self::DisarmMine,
128        Self::BeginStealth,
129        Self::BeginSearch,
130        Self::BeginUnlock,
131        Self::UnlockFailed,
132        Self::UnlockSuccess,
133        Self::SeparatedFromParty,
134        Self::RejoinedParty,
135        Self::Poisoned,
136    ];
137
138    /// Returns the slot index inside the core table.
139    #[allow(clippy::as_conversions)] // Enum discriminant (0..=27) always fits in usize.
140    pub const fn index(self) -> usize {
141        self as usize
142    }
143}
144
145/// In-memory SSF container.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Ssf {
148    /// Byte offset from file start to the core sound table.
149    ///
150    /// Most files use `12`.
151    pub sound_table_offset: u32,
152    /// Core sound table entries (`-1` means unset).
153    pub sounds: [StrRef; SOUND_SLOT_COUNT],
154    /// Trailing entries after the core table.
155    ///
156    /// KotOR/PyKotor typically write 12 entries set to `-1`.
157    pub reserved_entries: Vec<StrRef>,
158}
159
160impl Default for Ssf {
161    fn default() -> Self {
162        Self {
163            sound_table_offset: CANONICAL_SOUND_TABLE_OFFSET,
164            sounds: [StrRef::invalid(); SOUND_SLOT_COUNT],
165            reserved_entries: vec![StrRef::invalid(); CANONICAL_RESERVED_ENTRY_COUNT],
166        }
167    }
168}
169
170impl Ssf {
171    /// Creates an empty SSF with canonical defaults.
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Returns the StrRef for one sound slot.
177    pub fn get(&self, slot: SsfSoundSlot) -> StrRef {
178        self.sounds[slot.index()]
179    }
180
181    /// Sets the StrRef for one sound slot.
182    pub fn set(&mut self, slot: SsfSoundSlot, strref: StrRef) {
183        self.sounds[slot.index()] = strref;
184    }
185
186    /// Returns the raw StrRef value for one sound slot.
187    pub fn get_raw(&self, slot: SsfSoundSlot) -> i32 {
188        self.get(slot).raw()
189    }
190
191    /// Sets a raw StrRef value for one sound slot.
192    pub fn set_raw(&mut self, slot: SsfSoundSlot, strref_raw: i32) {
193        self.set(slot, StrRef::from_raw(strref_raw));
194    }
195
196    /// Resets all core slots to `-1`.
197    pub fn reset(&mut self) {
198        self.sounds.fill(StrRef::invalid());
199    }
200}
201
202impl DecodeBinary for Ssf {
203    type Error = SsfBinaryError;
204
205    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
206        read_ssf_from_bytes(bytes)
207    }
208}
209
210impl EncodeBinary for Ssf {
211    type Error = SsfBinaryError;
212
213    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
214        write_ssf_to_vec(self)
215    }
216}
217
218/// Errors produced while parsing or serializing SSF binary data.
219#[derive(Debug, Error)]
220pub enum SsfBinaryError {
221    /// I/O read/write failure.
222    #[error(transparent)]
223    Io(#[from] std::io::Error),
224    /// Header signature is not `SSF `.
225    #[error("invalid SSF magic: {0:?}")]
226    InvalidMagic([u8; 4]),
227    /// Header version is unsupported.
228    #[error("invalid SSF version: {0:?}")]
229    InvalidVersion([u8; 4]),
230    /// Header/body layout is invalid or truncated.
231    #[error("invalid SSF header: {0}")]
232    InvalidHeader(String),
233    /// SSF content is structurally invalid.
234    #[error("invalid SSF data: {0}")]
235    InvalidData(String),
236    /// Value cannot fit on-disk integer width.
237    #[error("value overflow while writing field `{0}`")]
238    ValueOverflow(&'static str),
239}
240
241impl From<binary::BinaryLayoutError> for SsfBinaryError {
242    fn from(error: binary::BinaryLayoutError) -> Self {
243        Self::InvalidHeader(error.to_string())
244    }
245}