Skip to main content

siglus_assets/
gameexe.rs

1//! Gameexe.dat decoding and INI-like parsing.
2//!
3//! In the original Siglus engine, `Gameexe.dat` is treated as a TCHAR text
4//! blob (UTF-16LE). Some titles may store an alternative encoding (e.g.
5//! Shift-JIS) or wrap the text with obfuscation + LZSS.
6//!
7//! This module keeps the decoding pipeline explicit:
8//! - plaintext (UTF-16LE / Shift-JIS / UTF-8)
9//! - optionally XOR with a chain of angou materials
10//! - optional Siglus LZSS unpack
11
12use std::collections::BTreeMap;
13use std::env;
14use std::path::Path;
15
16use anyhow::{anyhow, bail, Result};
17use encoding_rs::SHIFT_JIS;
18
19use crate::angou::{xor_cycle_in_place, AngouChain, AngouStep, AngouStepKind};
20use crate::lzss::lzss_unpack_lenient;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GameexeTextEncoding {
24    Utf16Le,
25    ShiftJis,
26    Utf8,
27}
28
29#[derive(Debug, Clone)]
30pub struct GameexeDecodeOptions {
31    pub exe_key16: Option<[u8; 16]>,
32    pub base_angou_code: Option<Vec<u8>>,
33    pub game_angou_code: Option<Vec<u8>>,
34    pub try_lzss: bool,
35    pub chain_order: Vec<AngouStepKind>,
36}
37
38impl Default for GameexeDecodeOptions {
39    fn default() -> Self {
40        Self {
41            exe_key16: None,
42            base_angou_code: None,
43            game_angou_code: None,
44            try_lzss: true,
45            chain_order: vec![
46                AngouStepKind::ExeKey16,
47                AngouStepKind::BaseCode,
48                AngouStepKind::GameCode,
49            ],
50        }
51    }
52}
53
54impl GameexeDecodeOptions {
55    pub fn from_project_dir(project_dir: &Path) -> Result<Self> {
56        let mut opt = Self::default();
57        opt.game_angou_code = Some(crate::keys::GAMEEXE_KEY.to_vec());
58        if let Some(cfg) = crate::key_toml::load_key_toml_from_project_dir(project_dir)? {
59            opt.exe_key16 = cfg.exe_key16;
60            opt.base_angou_code = cfg.base_angou_code;
61            if cfg.game_angou_code.is_some() {
62                opt.game_angou_code = cfg.game_angou_code;
63            }
64            if let Some(order) = cfg.chain_order {
65                opt.chain_order = order;
66            }
67        } else {
68            opt.exe_key16 = crate::key_toml::load_key16_from_project_dir(project_dir)?;
69        }
70        apply_env_overrides(&mut opt)?;
71        Ok(opt)
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct GameexeDecodeReport {
77    pub encoding: GameexeTextEncoding,
78    pub applied_xor: Vec<(AngouStepKind, usize)>,
79    pub used_lzss: bool,
80}
81
82#[derive(Debug, Clone)]
83pub struct GameexeEntry {
84    pub line_no: usize,
85    pub raw_key: String,
86    pub key: String,
87    pub key_parts: Vec<String>,
88    pub value: String,
89    pub value_items: Vec<String>,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct GameexeConfig {
94    pub entries: Vec<GameexeEntry>,
95    pub map: BTreeMap<String, String>,
96}
97
98impl GameexeEntry {
99    pub fn key_index(&self, prefix: &str) -> Option<usize> {
100        let parts = normalized_key_parts(prefix);
101        if self.key_parts.len() < parts.len() + 1 {
102            return None;
103        }
104        if self.key_parts[..parts.len()] != parts[..] {
105            return None;
106        }
107        self.key_parts[parts.len()].parse::<usize>().ok()
108    }
109
110    pub fn key_field_after_index(&self, prefix: &str) -> Option<&str> {
111        let parts = normalized_key_parts(prefix);
112        if self.key_parts.len() < parts.len() + 2 {
113            return None;
114        }
115        if self.key_parts[..parts.len()] != parts[..] {
116            return None;
117        }
118        Some(self.key_parts[parts.len() + 1].as_str())
119    }
120
121    pub fn item(&self, idx: usize) -> Option<&str> {
122        self.value_items.get(idx).map(|s| s.as_str())
123    }
124
125    pub fn item_unquoted(&self, idx: usize) -> Option<&str> {
126        self.item(idx).map(unquote_token)
127    }
128
129    pub fn scalar_unquoted(&self) -> &str {
130        if self.value_items.is_empty() {
131            unquote_token(&self.value)
132        } else {
133            unquote_token(&self.value_items[0])
134        }
135    }
136}
137
138fn unquote_token(s: &str) -> &str {
139    let t = s.trim();
140    if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
141        &t[1..t.len() - 1]
142    } else {
143        t
144    }
145}
146
147impl GameexeConfig {
148    pub fn get(&self, key: &str) -> Option<&str> {
149        self.map.get(&normalize_key(key)).map(|s| s.as_str())
150    }
151
152    pub fn get_value(&self, key: &str) -> Option<&str> {
153        self.get_entry(key).map(|e| e.value.as_str())
154    }
155
156    pub fn get_entry(&self, key: &str) -> Option<&GameexeEntry> {
157        let nk = normalize_key(key);
158        self.entries.iter().rev().find(|e| e.key == nk)
159    }
160
161    pub fn get_entries<'a>(&'a self, key: &str) -> impl Iterator<Item = &'a GameexeEntry> + 'a {
162        let nk = normalize_key(key);
163        self.entries.iter().filter(move |e| e.key == nk)
164    }
165
166    pub fn get_all<'a>(&'a self, key: &str) -> impl Iterator<Item = &'a str> + 'a {
167        self.get_entries(key).map(|e| e.value.as_str())
168    }
169
170    pub fn get_unquoted(&self, key: &str) -> Option<&str> {
171        self.get_entry(key).map(|e| e.scalar_unquoted())
172    }
173
174    pub fn get_item(&self, key: &str, item: usize) -> Option<&str> {
175        self.get_entry(key).and_then(|e| e.item(item))
176    }
177
178    pub fn get_item_unquoted(&self, key: &str, item: usize) -> Option<&str> {
179        self.get_entry(key).and_then(|e| e.item_unquoted(item))
180    }
181
182    pub fn get_i64(&self, key: &str) -> Option<i64> {
183        self.get_unquoted(key).and_then(parse_i64_like)
184    }
185
186    pub fn get_usize(&self, key: &str) -> Option<usize> {
187        self.get_i64(key).and_then(|v| usize::try_from(v).ok())
188    }
189
190    pub fn get_indexed(&self, prefix: &str, index: usize) -> Option<&str> {
191        let key = format!("{}.{}", normalize_key(prefix), index);
192        self.get(&key)
193    }
194
195    pub fn get_indexed_value(&self, prefix: &str, index: usize) -> Option<&str> {
196        self.get_indexed_entry(prefix, index)
197            .map(|e| e.value.as_str())
198    }
199
200    pub fn get_indexed_unquoted(&self, prefix: &str, index: usize) -> Option<&str> {
201        self.get_indexed_entry(prefix, index)
202            .map(|e| e.scalar_unquoted())
203    }
204
205    pub fn get_indexed_item(&self, prefix: &str, index: usize, item: usize) -> Option<&str> {
206        self.get_indexed_entry(prefix, index)
207            .and_then(|e| e.item(item))
208    }
209
210    pub fn get_indexed_item_unquoted(
211        &self,
212        prefix: &str,
213        index: usize,
214        item: usize,
215    ) -> Option<&str> {
216        self.get_indexed_entry(prefix, index)
217            .and_then(|e| e.item_unquoted(item))
218    }
219
220    pub fn get_indexed_entry(&self, prefix: &str, index: usize) -> Option<&GameexeEntry> {
221        // Original Gameexe keys are usually zero-padded (for example BGM.000).
222        // Match by parsed key index instead of formatting the index back as a
223        // non-padded decimal string, otherwise table-backed subsystems silently
224        // miss registered rows. Keep reverse iteration to preserve get_entry
225        // "last definition wins" behavior.
226        self.entries
227            .iter()
228            .rev()
229            .find(|e| e.key_index(prefix) == Some(index))
230    }
231
232    pub fn get_indexed_field(&self, prefix: &str, index: usize, field: &str) -> Option<&str> {
233        let nf = normalize_key(field);
234        self.entries
235            .iter()
236            .rev()
237            .find(|e| {
238                e.key_index(prefix) == Some(index)
239                    && e.key_field_after_index(prefix) == Some(nf.as_str())
240            })
241            .map(|e| e.value.as_str())
242    }
243
244    pub fn get_indexed_field_unquoted(
245        &self,
246        prefix: &str,
247        index: usize,
248        field: &str,
249    ) -> Option<&str> {
250        let nf = normalize_key(field);
251        self.entries
252            .iter()
253            .rev()
254            .find(|e| {
255                e.key_index(prefix) == Some(index)
256                    && e.key_field_after_index(prefix) == Some(nf.as_str())
257            })
258            .map(|e| e.scalar_unquoted())
259    }
260
261    pub fn get_prefix<'a>(&'a self, prefix: &str) -> impl Iterator<Item = &'a GameexeEntry> + 'a {
262        let prefix_parts = normalized_key_parts(prefix);
263        self.entries.iter().filter(move |e| {
264            e.key_parts.len() >= prefix_parts.len()
265                && e.key_parts[..prefix_parts.len()] == prefix_parts[..]
266        })
267    }
268
269    pub fn indexed_count(&self, prefix: &str) -> usize {
270        if let Some(v) = self.get_usize(&format!("{}.CNT", normalize_key(prefix))) {
271            return v;
272        }
273        let prefix_parts = normalized_key_parts(prefix);
274        let mut max_idx: Option<usize> = None;
275        for e in &self.entries {
276            if e.key_parts.len() < prefix_parts.len() + 1 {
277                continue;
278            }
279            if e.key_parts[..prefix_parts.len()] != prefix_parts[..] {
280                continue;
281            }
282            let Some(idx) = e.key_parts[prefix_parts.len()].parse::<usize>().ok() else {
283                continue;
284            };
285            max_idx = Some(max_idx.map_or(idx, |m| m.max(idx)));
286        }
287        max_idx.map_or(0, |m| m + 1)
288    }
289
290    pub fn from_text(text: &str) -> Self {
291        let mut out = Self::default();
292        for (line_no, raw_line) in text.lines().enumerate() {
293            let mut line = raw_line.trim();
294            if line.is_empty() || line.starts_with(';') {
295                continue;
296            }
297            if let Some(rest) = line.strip_prefix('\u{feff}') {
298                line = rest.trim_start();
299            }
300            if let Some(rest) = line.strip_prefix('#') {
301                line = rest.trim_start();
302            }
303            let Some((k, v)) = line.split_once('=') else {
304                continue;
305            };
306            let key = normalize_key(k);
307            if key.is_empty() {
308                continue;
309            }
310            let value = strip_inline_comment(v.trim()).trim().to_string();
311            let entry = GameexeEntry {
312                line_no: line_no + 1,
313                raw_key: k.trim().to_string(),
314                key: key.clone(),
315                key_parts: split_key_parts(&key),
316                value_items: split_csv_like(&value),
317                value: value.clone(),
318            };
319            out.entries.push(entry);
320            out.map.insert(key, value);
321        }
322        out
323    }
324}
325
326fn normalized_key_parts(k: &str) -> Vec<String> {
327    split_key_parts(&normalize_key(k))
328}
329
330fn split_key_parts(k: &str) -> Vec<String> {
331    k.split('.')
332        .map(str::trim)
333        .filter(|s| !s.is_empty())
334        .map(ToOwned::to_owned)
335        .collect()
336}
337
338pub fn normalize_gameexe_key(k: &str) -> String {
339    normalize_key(k)
340}
341
342fn normalize_key(k: &str) -> String {
343    let mut src = k.trim();
344    if let Some(rest) = src.strip_prefix('\u{feff}') {
345        src = rest.trim_start();
346    }
347    if let Some(rest) = src.strip_prefix('#') {
348        src = rest.trim_start();
349    }
350
351    let mut out = String::new();
352    for ch in src.chars() {
353        if ch.is_ascii_whitespace() {
354            continue;
355        }
356        out.push(ch);
357    }
358    out.make_ascii_uppercase();
359    out
360}
361
362fn split_csv_like(s: &str) -> Vec<String> {
363    let mut out = Vec::new();
364    let mut cur = String::new();
365    let mut in_str = false;
366    let mut escaped = false;
367    for ch in s.chars() {
368        match ch {
369            '"' if !escaped => {
370                in_str = !in_str;
371                cur.push(ch);
372            }
373            ',' if !in_str => {
374                out.push(cur.trim().to_string());
375                cur.clear();
376            }
377            _ => cur.push(ch),
378        }
379        if ch == '\\' {
380            escaped = !escaped;
381        } else {
382            escaped = false;
383        }
384    }
385    if !cur.is_empty() || s.contains(',') {
386        out.push(cur.trim().to_string());
387    }
388    out.retain(|v| !v.is_empty());
389    out
390}
391
392fn parse_i64_like(s: &str) -> Option<i64> {
393    let t = s.trim();
394    if t.is_empty() {
395        return None;
396    }
397    if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
398        return i64::from_str_radix(hex.trim(), 16).ok();
399    }
400    t.parse::<i64>().ok()
401}
402
403fn strip_inline_comment(s: &str) -> &str {
404    let mut in_str = false;
405    let mut escaped = false;
406    for (idx, ch) in s.char_indices() {
407        match ch {
408            '"' if !escaped => in_str = !in_str,
409            ';' if !in_str => return &s[..idx],
410            _ => {}
411        }
412        if ch == '\\' {
413            escaped = !escaped;
414        } else {
415            escaped = false;
416        }
417    }
418    s
419}
420
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn lookup_accepts_hash_and_spaced_keys() {
428        let cfg = GameexeConfig::from_text(
429            "#MSGBK . WINDOW_SIZE = 1280, 720\nMSGBK_ITEM . SLIDER . POS = 1076,70,590\n",
430        );
431        assert_eq!(cfg.get_value("MSGBK.WINDOW_SIZE"), Some("1280, 720"));
432        assert_eq!(cfg.get_value("#MSGBK.WINDOW_SIZE"), Some("1280, 720"));
433        assert_eq!(cfg.get_value("MSGBK_ITEM.SLIDER.POS"), Some("1076,70,590"));
434    }
435
436    #[test]
437    fn scalar_unquoted_and_full_value_are_distinct() {
438        let cfg = GameexeConfig::from_text(
439            "#MSGBK.WINDOW_SIZE = 1280, 720\n#MSGBK.BACK_FILE = \"mn_mw_log00a00\"\n",
440        );
441        assert_eq!(cfg.get_unquoted("MSGBK.WINDOW_SIZE"), Some("1280"));
442        assert_eq!(cfg.get_value("MSGBK.WINDOW_SIZE"), Some("1280, 720"));
443        assert_eq!(cfg.get_unquoted("#MSGBK.BACK_FILE"), Some("mn_mw_log00a00"));
444    }
445
446    #[test]
447    fn indexed_lookup_accepts_zero_padded_source_keys() {
448        let cfg = GameexeConfig::from_text("#WAKU.000.EXTEND_TYPE = 2\n");
449        assert_eq!(cfg.get_indexed_field("WAKU", 0, "EXTEND_TYPE"), Some("2"));
450    }
451
452    #[test]
453    fn indexed_value_preserves_full_rhs_tuple() {
454        let cfg = GameexeConfig::from_text("#COLOR_TABLE.000 = 255, 255, 255\n");
455        assert_eq!(cfg.get_indexed_value("COLOR_TABLE", 0), Some("255, 255, 255"));
456        assert_eq!(cfg.get_indexed_unquoted("COLOR_TABLE", 0), Some("255"));
457    }
458}
459
460pub fn decode_gameexe_dat_bytes(
461    raw: &[u8],
462    opt: &GameexeDecodeOptions,
463) -> Result<(String, GameexeDecodeReport)> {
464    if let Ok(v) = decode_gameexe_with_header(raw, opt) {
465        return Ok(v);
466    }
467
468    if let Ok((s, enc)) = decode_text_guess(raw) {
469        return Ok((
470            s,
471            GameexeDecodeReport {
472                encoding: enc,
473                applied_xor: Vec::new(),
474                used_lzss: false,
475            },
476        ));
477    }
478
479    if opt.try_lzss {
480        if let Ok(unpacked) = lzss_unpack_lenient(raw) {
481            if let Ok((s, enc)) = decode_text_guess(&unpacked) {
482                return Ok((
483                    s,
484                    GameexeDecodeReport {
485                        encoding: enc,
486                        applied_xor: Vec::new(),
487                        used_lzss: true,
488                    },
489                ));
490            }
491        }
492    }
493
494    let (xor_chain, applied) = build_chain(opt)?;
495    if !xor_chain.steps.is_empty() {
496        let mut buf = raw.to_vec();
497        xor_chain.apply_in_place(&mut buf);
498        if let Ok((s, enc)) = decode_text_guess(&buf) {
499            return Ok((
500                s,
501                GameexeDecodeReport {
502                    encoding: enc,
503                    applied_xor: applied.clone(),
504                    used_lzss: false,
505                },
506            ));
507        }
508
509        if opt.try_lzss {
510            if let Ok(unpacked) = lzss_unpack_lenient(&buf) {
511                if let Ok((s, enc)) = decode_text_guess(&unpacked) {
512                    return Ok((
513                        s,
514                        GameexeDecodeReport {
515                            encoding: enc,
516                            applied_xor: applied,
517                            used_lzss: true,
518                        },
519                    ));
520                }
521            }
522        }
523    }
524
525    bail!("failed to decode Gameexe.dat as plaintext or (xor/lzss) wrapped text")
526}
527
528fn decode_gameexe_with_header(
529    raw: &[u8],
530    opt: &GameexeDecodeOptions,
531) -> Result<(String, GameexeDecodeReport)> {
532    if raw.len() < 8 {
533        bail!("gameexe header: too short");
534    }
535    let version = i32::from_le_bytes(raw[0..4].try_into().unwrap());
536    let exe_angou_mode = i32::from_le_bytes(raw[4..8].try_into().unwrap());
537    let mut buf = raw[8..].to_vec();
538
539    let mut applied = Vec::new();
540
541    if exe_angou_mode != 0 {
542        if let Some(k16) = opt.exe_key16 {
543            let step = AngouStep::new(AngouStepKind::ExeKey16, k16.to_vec())?;
544            xor_cycle_in_place(&mut buf, &step.key);
545            applied.push((AngouStepKind::ExeKey16, step.key.len()));
546        }
547    }
548    if let Some(code) = &opt.game_angou_code {
549        let step = AngouStep::new(AngouStepKind::GameCode, code.clone())?;
550        xor_cycle_in_place(&mut buf, &step.key);
551        applied.push((AngouStepKind::GameCode, step.key.len()));
552    }
553
554    if let Ok(unpacked) = lzss_unpack_lenient(&buf) {
555        if let Ok((s, enc)) = decode_text_guess(&unpacked) {
556            return Ok((
557                s,
558                GameexeDecodeReport {
559                    encoding: enc,
560                    applied_xor: applied,
561                    used_lzss: true,
562                },
563            ));
564        }
565    }
566
567    if let Ok((s, enc)) = decode_text_guess(&buf) {
568        return Ok((
569            s,
570            GameexeDecodeReport {
571                encoding: enc,
572                applied_xor: applied,
573                used_lzss: false,
574            },
575        ));
576    }
577
578    bail!("gameexe header decode failed (version={version})")
579}
580
581fn build_chain(opt: &GameexeDecodeOptions) -> Result<(AngouChain, Vec<(AngouStepKind, usize)>)> {
582    let mut chain = AngouChain::default();
583    for kind in &opt.chain_order {
584        match kind {
585            AngouStepKind::ExeKey16 => {
586                if let Some(k16) = opt.exe_key16 {
587                    chain
588                        .steps
589                        .push(AngouStep::new(AngouStepKind::ExeKey16, k16.to_vec())?);
590                }
591            }
592            AngouStepKind::BaseCode => {
593                if let Some(code) = &opt.base_angou_code {
594                    chain
595                        .steps
596                        .push(AngouStep::new(AngouStepKind::BaseCode, code.clone())?);
597                }
598            }
599            AngouStepKind::GameCode => {
600                if let Some(code) = &opt.game_angou_code {
601                    chain
602                        .steps
603                        .push(AngouStep::new(AngouStepKind::GameCode, code.clone())?);
604                }
605            }
606        }
607    }
608    let applied = chain.describe();
609    Ok((chain, applied))
610}
611
612fn apply_env_overrides(opt: &mut GameexeDecodeOptions) -> Result<()> {
613    if let Ok(hex) = env::var("SIGLUS_EXE_ANGOU_HEX") {
614        let bytes = crate::angou::parse_hex_bytes(&hex)?;
615        if bytes.len() != 16 {
616            bail!("SIGLUS_EXE_ANGOU_HEX must be 16 bytes, got {}", bytes.len());
617        }
618        let mut key16 = [0u8; 16];
619        key16.copy_from_slice(&bytes);
620        opt.exe_key16 = Some(key16);
621    }
622    if let Ok(hex) = env::var("SIGLUS_BASE_ANGOU_CODE_HEX") {
623        opt.base_angou_code = Some(crate::angou::parse_hex_bytes(&hex)?);
624    }
625    if let Ok(hex) = env::var("SIGLUS_GAME_ANGOU_CODE_HEX") {
626        opt.game_angou_code = Some(crate::angou::parse_hex_bytes(&hex)?);
627    }
628    if let Ok(order_raw) = env::var("SIGLUS_ANGOU_CHAIN_ORDER") {
629        let mut order = Vec::new();
630        for part in order_raw.split(',') {
631            let tok = part.trim().to_ascii_lowercase();
632            if tok.is_empty() {
633                continue;
634            }
635            let kind = match tok.as_str() {
636                "exe" | "exe_key16" => AngouStepKind::ExeKey16,
637                "base" | "base_code" => AngouStepKind::BaseCode,
638                "game" | "game_code" => AngouStepKind::GameCode,
639                other => bail!("SIGLUS_ANGOU_CHAIN_ORDER: unknown item {other}"),
640            };
641            order.push(kind);
642        }
643        if !order.is_empty() {
644            opt.chain_order = order;
645        }
646    }
647    Ok(())
648}
649
650fn decode_text_guess(raw: &[u8]) -> Result<(String, GameexeTextEncoding)> {
651    if let Ok(s) = decode_utf16le_text(raw) {
652        if looks_like_gameexe(&s) {
653            return Ok((s, GameexeTextEncoding::Utf16Le));
654        }
655    }
656
657    if let Ok(s) = decode_shift_jis(raw) {
658        if looks_like_gameexe(&s) {
659            return Ok((s, GameexeTextEncoding::ShiftJis));
660        }
661    }
662
663    if let Ok(s) = std::str::from_utf8(raw) {
664        let s = s.to_string();
665        if looks_like_gameexe(&s) {
666            return Ok((s, GameexeTextEncoding::Utf8));
667        }
668    }
669
670    Err(anyhow!("text guess failed"))
671}
672
673fn decode_shift_jis(raw: &[u8]) -> Result<String> {
674    let (cow, _, had_err) = SHIFT_JIS.decode(raw);
675    if had_err {
676        bail!("shift-jis decode error")
677    }
678    Ok(cow.into_owned())
679}
680
681fn decode_utf16le_text(raw: &[u8]) -> Result<String> {
682    if raw.len() < 2 {
683        bail!("too short")
684    }
685
686    let (start, has_bom) = if raw.len() >= 2 && raw[0] == 0xFF && raw[1] == 0xFE {
687        (2usize, true)
688    } else {
689        (0usize, false)
690    };
691
692    if !has_bom {
693        let mut zero_odd = 0usize;
694        let mut total_odd = 0usize;
695        for i in (1..raw.len()).step_by(2) {
696            total_odd += 1;
697            if raw[i] == 0 {
698                zero_odd += 1;
699            }
700        }
701        if total_odd > 0 {
702            let ratio = (zero_odd as f32) / (total_odd as f32);
703            if ratio < 0.30 {
704                bail!("utf16le heuristic ratio too low")
705            }
706        }
707    }
708
709    let mut u16s = Vec::with_capacity((raw.len() - start) / 2);
710    for i in (start..raw.len()).step_by(2) {
711        if i + 1 >= raw.len() {
712            break;
713        }
714        u16s.push(u16::from_le_bytes([raw[i], raw[i + 1]]));
715    }
716    let s = String::from_utf16(&u16s)?;
717    Ok(s)
718}
719
720fn looks_like_gameexe(s: &str) -> bool {
721    let mut cnt = 0usize;
722    for line in s.lines().take(200) {
723        let line = line.trim();
724        if line.starts_with('#') {
725            cnt += 1;
726            if cnt >= 3 {
727                return true;
728            }
729        }
730    }
731    false
732}