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