Skip to main content

genie_cpx/
read.rs

1use crate::{CPXVersion, CampaignHeader, ScenarioMeta};
2use byteorder::{ReadBytesExt, LE};
3use chardet::detect as detect_encoding;
4use encoding_rs::Encoding;
5use genie_scx::{self as scx, DLCPackage, Scenario};
6use std::convert::TryFrom;
7use std::io::{self, Cursor, Read, Seek, SeekFrom};
8
9/// Type for errrors that could occur while reading/parsing a campaign file.
10#[derive(Debug, thiserror::Error)]
11pub enum ReadCampaignError {
12    /// A string could not be decoded, its encoding may be unknown or it may be binary nonsense.
13    #[error("invalid string")]
14    DecodeStringError,
15    /// An I/O error occurred.
16    #[error("{}", .0)]
17    IoError(#[from] io::Error),
18    /// The campaign file or a scenario inside it is missing a user-facing name value.
19    #[error("campaign or scenario must have a name")]
20    MissingNameError,
21    /// The requested scenario file does not exist in this campaign file.
22    #[error("scenario not fonud in campaign")]
23    NotFoundError,
24    /// A scenario file could not be parsed.
25    #[error("{}", .0)]
26    ParseSCXError(#[from] scx::Error),
27}
28
29type Result<T> = std::result::Result<T, ReadCampaignError>;
30
31/// Decode a string with unknown encoding.
32fn decode_str(bytes: &[u8]) -> Result<String> {
33    if bytes.is_empty() {
34        return Ok("".to_string());
35    }
36
37    let (encoding_name, _confidence, _language) = detect_encoding(&bytes);
38    Encoding::for_label(encoding_name.as_bytes())
39        .ok_or(ReadCampaignError::DecodeStringError)
40        .and_then(|encoding| {
41            let (decoded, _enc, failed) = encoding.decode(&bytes);
42            if failed {
43                return Err(ReadCampaignError::DecodeStringError);
44            }
45            Ok(decoded.to_string())
46        })
47}
48
49pub fn read_fixed_str<R: Read>(input: &mut R, len: usize) -> Result<Option<String>> {
50    let mut bytes = vec![0; len];
51    input.read_exact(&mut bytes[0..len])?;
52
53    if let Some(end) = bytes.iter().position(|&byte| byte == 0) {
54        bytes.truncate(end);
55    }
56    if bytes.is_empty() {
57        Ok(None)
58    } else {
59        decode_str(&bytes).map(Some)
60    }
61}
62
63fn read_hd_or_later_string<R: Read>(input: &mut R) -> Result<Option<String>> {
64    let open = input.read_u16::<LE>()?;
65    // Check that this actually is the start of a string
66    if open != 0x0A60 {
67        return Err(ReadCampaignError::DecodeStringError);
68    }
69    let len = input.read_u16::<LE>()? as usize;
70    let mut bytes = vec![0; len];
71    input.read_exact(&mut bytes[0..len])?;
72    decode_str(&bytes).map(Some)
73}
74
75fn read_campaign_header<R: Read>(input: &mut R) -> Result<CampaignHeader> {
76    let mut version = [0; 4];
77    input.read_exact(&mut version)?;
78    let (name, num_scenarios) = if version == *b"2.00" {
79        let num_dependencies = input.read_u32::<LE>()?;
80        let mut dependencies = vec![DLCPackage::AgeOfKings; num_dependencies as usize];
81        for dependency in dependencies.iter_mut() {
82            *dependency =
83                DLCPackage::try_from(input.read_i32::<LE>()?).map_err(scx::Error::from)?;
84        }
85
86        let name = read_fixed_str(input, 256)?.ok_or(ReadCampaignError::MissingNameError)?;
87
88        let num_scenarios = input.read_u32::<LE>()? as usize;
89        (name, num_scenarios)
90    } else if version == *b"1.10" {
91        let num_scenarios = input.read_u32::<LE>()? as usize;
92        (
93            read_hd_or_later_string(input)?.ok_or(ReadCampaignError::MissingNameError)?,
94            num_scenarios,
95        )
96    } else {
97        (
98            read_fixed_str(input, 256)?.ok_or(ReadCampaignError::MissingNameError)?,
99            input.read_u32::<LE>()? as usize,
100        )
101    };
102
103    Ok(CampaignHeader {
104        version,
105        name,
106        num_scenarios,
107    })
108}
109
110fn read_scenario_meta_de2<R: Read>(input: &mut R) -> Result<ScenarioMeta> {
111    let size = input.read_u32::<LE>()? as usize;
112    let offset = input.read_u32::<LE>()? as usize;
113    let name = read_hd_or_later_string(input)?.ok_or(ReadCampaignError::MissingNameError)?;
114    let filename = read_hd_or_later_string(input)?.ok_or(ReadCampaignError::MissingNameError)?;
115
116    Ok(ScenarioMeta {
117        size,
118        offset,
119        name,
120        filename,
121    })
122}
123
124fn read_scenario_meta_de<R: Read>(input: &mut R) -> Result<ScenarioMeta> {
125    let size = input.read_u64::<LE>()? as usize;
126    let offset = input.read_u64::<LE>()? as usize;
127    let name = read_hd_or_later_string(input)?.ok_or(ReadCampaignError::MissingNameError)?;
128    let filename = read_hd_or_later_string(input)?.ok_or(ReadCampaignError::MissingNameError)?;
129
130    Ok(ScenarioMeta {
131        size,
132        offset,
133        name,
134        filename,
135    })
136}
137
138fn read_scenario_meta<R: Read>(input: &mut R) -> Result<ScenarioMeta> {
139    let size = input.read_i32::<LE>()? as usize;
140    let offset = input.read_i32::<LE>()? as usize;
141    let name = read_fixed_str(input, 255)?.ok_or(ReadCampaignError::MissingNameError)?;
142    let filename = read_fixed_str(input, 255)?.ok_or(ReadCampaignError::MissingNameError)?;
143    let mut padding = [0; 2];
144    input.read_exact(&mut padding)?;
145
146    Ok(ScenarioMeta {
147        size,
148        offset,
149        name,
150        filename,
151    })
152}
153
154/// A campaign file containing scenario files.
155#[derive(Debug, Clone)]
156pub struct Campaign<R>
157where
158    R: Read + Seek,
159{
160    reader: R,
161    header: CampaignHeader,
162    entries: Vec<ScenarioMeta>,
163}
164
165impl<R> Campaign<R>
166where
167    R: Read + Seek,
168{
169    /// Create a campaign instance from a readable input.
170    ///
171    /// This immediately reads the campaign header and scenario metadata, but not the scenario
172    /// files themselves.
173    pub fn from(mut input: R) -> Result<Self> {
174        let header = read_campaign_header(&mut input)?;
175        let mut entries = vec![];
176        let read_entry = if header.version == *b"2.00" {
177            read_scenario_meta_de2
178        } else if header.version == *b"1.10" {
179            read_scenario_meta_de
180        } else {
181            read_scenario_meta
182        };
183        for _ in 0..header.num_scenarios {
184            entries.push(read_entry(&mut input)?);
185        }
186
187        Ok(Self {
188            reader: input,
189            header,
190            entries,
191        })
192    }
193
194    /// Consume this Campaign instance and get the reader.
195    pub fn into_inner(self) -> R {
196        self.reader
197    }
198
199    /// Get the campaign file version.
200    pub fn version(&self) -> CPXVersion {
201        self.header.version
202    }
203
204    /// Get the user-facing name of this campaign.
205    pub fn name(&self) -> &str {
206        &self.header.name
207    }
208
209    /// Iterate over the scenario metadata for this campaign.
210    pub fn entries(&self) -> impl Iterator<Item = &ScenarioMeta> {
211        self.entries.iter()
212    }
213
214    /// Get the number of scenarios in this campaign.
215    pub fn len(&self) -> usize {
216        self.entries.len()
217    }
218
219    /// Returns true if this campaign contains no scenario files.
220    pub fn is_empty(&self) -> bool {
221        self.entries.is_empty()
222    }
223
224    /// Get the user-facing name of the scenario at the given index.
225    pub fn get_name(&self, id: usize) -> Option<&str> {
226        self.entries.get(id).map(|entry| entry.name.as_ref())
227    }
228
229    /// Get the filename of the scenario at the given index.
230    pub fn get_filename(&self, id: usize) -> Option<&str> {
231        self.entries.get(id).map(|entry| entry.filename.as_ref())
232    }
233
234    /// Return the index of the scenario with the given filename, if it exists.
235    fn get_id(&self, filename: &str) -> Option<usize> {
236        self.entries
237            .iter()
238            .position(|entry| entry.filename == filename)
239    }
240
241    /// Get a scenario by its file name.
242    pub fn by_name(&mut self, filename: &str) -> Result<Scenario> {
243        self.by_name_raw(filename)
244            .map(Cursor::new)
245            .and_then(|buf| Scenario::read_from(buf).map_err(ReadCampaignError::ParseSCXError))
246    }
247
248    /// Get a scenario by its campaign index.
249    pub fn by_index(&mut self, index: usize) -> Result<Scenario> {
250        self.by_index_raw(index)
251            .map(Cursor::new)
252            .and_then(|buf| Scenario::read_from(buf).map_err(ReadCampaignError::ParseSCXError))
253    }
254
255    /// Get a scenario file buffer by its file name.
256    pub fn by_name_raw(&mut self, filename: &str) -> Result<Vec<u8>> {
257        self.get_id(filename)
258            .ok_or(ReadCampaignError::NotFoundError)
259            .and_then(|index| self.by_index_raw(index))
260    }
261
262    /// Get a scenario file buffer by its campaign index.
263    pub fn by_index_raw(&mut self, index: usize) -> Result<Vec<u8>> {
264        let entry = match self.entries.get(index) {
265            Some(entry) => entry,
266            None => return Err(ReadCampaignError::NotFoundError),
267        };
268
269        let mut result = vec![];
270
271        self.reader.seek(SeekFrom::Start(entry.offset as u64))?;
272        self.reader
273            .by_ref()
274            .take(entry.size as u64)
275            .read_to_end(&mut result)?;
276
277        Ok(result)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::{AOE1_DE, AOE2_DE, AOE_AOK};
285    use anyhow::Context;
286    use std::fs::File;
287
288    /// Try to parse a file with an encoding that is not compatible with UTF-8.
289    /// Source: http://aok.heavengames.com/blacksmith/showfile.php?fileid=884
290    #[test]
291    fn detect_encoding() -> anyhow::Result<()> {
292        let f = File::open("./test/campaigns/DER FALL VON SACSAHUAMAN - TEIL I.cpx")?;
293        let cpx = Campaign::from(f)?;
294        assert_eq!(cpx.version(), AOE_AOK);
295        assert_eq!(cpx.name(), "DER FALL VON SACSAHUAMÁN - TEIL I");
296        assert_eq!(cpx.len(), 1);
297
298        let names: Vec<_> = cpx.entries().map(|e| &e.name).collect();
299        assert_eq!(names, vec!["Der Weg nach Sacsahuamán"]);
300        let filenames: Vec<_> = cpx.entries().map(|e| &e.filename).collect();
301        assert_eq!(filenames, vec!["Der Weg nach Sacsahuamán.scx"]);
302        Ok(())
303    }
304
305    /// Source: http://aoe.heavengames.com/dl-php/showfile.php?fileid=1678
306    #[test]
307    fn aoe1_trial_cpn() -> anyhow::Result<()> {
308        let f = File::open("test/campaigns/Armies at War A Combat Showcase.cpn")?;
309        let mut c = Campaign::from(f).context("could not read meta")?;
310
311        assert_eq!(c.version(), AOE_AOK);
312        assert_eq!(c.name(), "Armies at War, A Combat Showcase");
313        assert_eq!(c.len(), 1);
314        let names: Vec<_> = c.entries().map(|e| &e.name).collect();
315        assert_eq!(names, vec!["Bronze Age Art of War"]);
316        let filenames: Vec<_> = c.entries().map(|e| &e.filename).collect();
317        assert_eq!(filenames, vec!["Bronze Age Art of War.scn"]);
318
319        c.by_index_raw(0).context("could not read raw file")?;
320        c.by_name_raw("Bronze Age Art of War.scn")
321            .context("could not read raw file")?;
322        Ok(())
323    }
324
325    #[test]
326    fn aoe1_beta_cpn() -> anyhow::Result<()> {
327        let f = File::open("test/campaigns/Rise of Egypt Learning Campaign.cpn")?;
328        let c = Campaign::from(f).context("could not read meta")?;
329
330        assert_eq!(c.version(), AOE_AOK);
331        assert_eq!(c.name(), "Rise of Egypt Learning Campaign");
332        assert_eq!(c.len(), 12);
333        let filenames: Vec<_> = c.entries().map(|e| &e.filename).collect();
334        assert_eq!(
335            filenames,
336            vec![
337                "HUNTING.scn",
338                "FORAGING.scn",
339                "Discoveries.scn",
340                "Dawn of a New Age.scn",
341                "SKIRMISH.scn",
342                "Lands Unknown.scn",
343                "FARMING.scn",
344                "TRADE.scn",
345                "CRUSADE.scn",
346                "Establish a Second Colony.scn",
347                "Naval Battle.scn",
348                "Siege Battle.scn",
349            ]
350        );
351        Ok(())
352    }
353
354    #[test]
355    fn aoe_de() -> anyhow::Result<()> {
356        let f = File::open("test/campaigns/10 The First Punic War.aoecpn")?;
357        let c = Campaign::from(f)?;
358
359        assert_eq!(c.version(), AOE1_DE);
360        assert_eq!(c.name(), "10 The First Punic War");
361        assert_eq!(c.len(), 3);
362        let filenames: Vec<_> = c.entries().map(|e| &e.filename).collect();
363        assert_eq!(
364            filenames,
365            vec![
366                "Scxt1-01-The Battle of Agrigentum.aoescn",
367                "Scxt1-02-The Battle of Mylae.aoescn",
368                "Scxt1-03-The Battle of Tunis.aoescn",
369            ]
370        );
371
372        /* Enable when genie_scx supports DE1 scenarios better
373        let mut c = c;
374        for i in 0..c.len() {
375            let _scen = c.by_index(i)?;
376        }
377        */
378
379        Ok(())
380    }
381
382    #[test]
383    fn aoe_de2() -> Result<()> {
384        let f = File::open("test/campaigns/acam1.aoe2campaign")?;
385        let c = Campaign::from(f)?;
386
387        assert_eq!(c.version(), AOE2_DE);
388        assert_eq!(c.name(), "acam1");
389        assert_eq!(c.len(), 5);
390        let filenames: Vec<_> = c.entries().map(|e| &e.filename).collect();
391        assert_eq!(
392            filenames,
393            vec![
394                "A1_Tariq1.aoe2scenario",
395                "A1_Tariq2.aoe2scenario",
396                "A1_Tariq3.aoe2scenario",
397                "A1_Tariq4.aoe2scenario",
398                "A1_Tariq5.aoe2scenario",
399            ]
400        );
401
402        let f = File::open("test/campaigns/rcam3.aoe2campaign")?;
403        let c = Campaign::from(f)?;
404
405        assert_eq!(c.version(), AOE2_DE);
406        assert_eq!(c.name(), "rcam3");
407        assert_eq!(c.len(), 5);
408        let filenames: Vec<_> = c.entries().map(|e| &e.filename).collect();
409        assert_eq!(
410            filenames,
411            vec![
412                "R3_Bayinnaung_1.aoe2scenario",
413                "R3_Bayinnaung_2.aoe2scenario",
414                "R3_Bayinnaung_3.aoe2scenario",
415                "R3_Bayinnaung_4.aoe2scenario",
416                "R3_Bayinnaung_5.aoe2scenario",
417            ]
418        );
419
420        /* Enable when genie_scx supports DE2 scenarios better
421        let mut c = c;
422        for i in 0..c.len() {
423            let _scen = c.by_index(i)?;
424        }
425        */
426
427        Ok(())
428    }
429}