Skip to main content

siglus_assets/
key_toml.rs

1use std::fs::{self, OpenOptions};
2#[cfg(target_os = "horizon")]
3use std::io::Read;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8
9use crate::angou::{self, AngouStepKind};
10
11fn find_case_insensitive_child(parent: &Path, name: &str) -> Result<Option<PathBuf>> {
12    let exact = parent.join(name);
13
14    // libnx fsdev accepts sdmc:/ paths through open(2), but metadata/is_file
15    // can report false for the same file on the custom Horizon std target.
16    // The shipped key.toml filename is exact, so verify it by opening it and
17    // avoid a directory scan that would otherwise hide the configured key.
18    #[cfg(target_os = "horizon")]
19    {
20        return Ok(fs::File::open(&exact).ok().map(|_| exact));
21    }
22
23    #[cfg(not(target_os = "horizon"))]
24    if exact.is_file() {
25        return Ok(Some(exact));
26    }
27    let entries = match fs::read_dir(parent) {
28        Ok(entries) => entries,
29        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
30        Err(err) => return Err(err).with_context(|| format!("read_dir {}", parent.display())),
31    };
32    let mut matches = Vec::new();
33    for entry in entries {
34        let entry = entry?;
35        if entry
36            .file_name()
37            .to_string_lossy()
38            .eq_ignore_ascii_case(name)
39            && entry.path().is_file()
40        {
41            matches.push(entry.path());
42        }
43    }
44    matches.sort();
45    match matches.len() {
46        0 => Ok(None),
47        1 => Ok(matches.pop()),
48        _ => bail!(
49            "case-insensitive path conflict for {} under {}: {}",
50            name,
51            parent.display(),
52            matches
53                .iter()
54                .map(|p| p.display().to_string())
55                .collect::<Vec<_>>()
56                .join(", ")
57        ),
58    }
59}
60
61fn project_key_toml_path(project_dir: &Path) -> Result<Option<PathBuf>> {
62    find_case_insensitive_child(project_dir, "key.toml")
63}
64
65pub fn load_key16_from_project_dir(project_dir: &Path) -> Result<Option<[u8; 16]>> {
66    let Some(path) = project_key_toml_path(project_dir)? else {
67        return Ok(None);
68    };
69    load_key16_from_file(&path)
70}
71
72pub fn load_emote_key_from_project_dir(project_dir: &Path) -> Result<Option<u32>> {
73    let Some(path) = project_key_toml_path(project_dir)? else {
74        return Ok(None);
75    };
76    load_emote_key_from_file(&path)
77}
78
79pub fn load_emote_key_from_file(path: &Path) -> Result<Option<u32>> {
80    let text = read_key_toml_text(path)?;
81    parse_emote_key_toml(&text)
82}
83
84fn read_key_toml_text(path: &Path) -> Result<String> {
85    #[cfg(target_os = "horizon")]
86    {
87        let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
88        let mut bytes = Vec::new();
89        let mut chunk = [0u8; 512];
90        loop {
91            let count = file
92                .read(&mut chunk)
93                .with_context(|| format!("read {}", path.display()))?;
94            if count == 0 {
95                break;
96            }
97            bytes.extend_from_slice(&chunk[..count]);
98        }
99        return String::from_utf8(bytes)
100            .with_context(|| format!("decode UTF-8 {}", path.display()));
101    }
102
103    #[cfg(not(target_os = "horizon"))]
104    {
105        fs::read_to_string(path).with_context(|| format!("read {}", path.display()))
106    }
107}
108
109pub fn parse_emote_key_toml(text: &str) -> Result<Option<u32>> {
110    parse_named_u32(text, "emote_key")
111}
112
113pub fn load_key16_from_file(path: &Path) -> Result<Option<[u8; 16]>> {
114    let text = read_key_toml_text(path)?;
115    parse_key16_toml(&text)
116}
117
118pub fn parse_key16_toml(text: &str) -> Result<Option<[u8; 16]>> {
119    let Some(bytes) = parse_named_bytes(text, "key", "key_hex")? else {
120        return Ok(None);
121    };
122    if bytes.len() != 16 {
123        bail!(
124            "key.toml: key must contain exactly 16 bytes, got {}",
125            bytes.len()
126        );
127    }
128    let mut out = [0u8; 16];
129    out.copy_from_slice(&bytes);
130    Ok(Some(out))
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum StringEncryptionOverride {
135    #[default]
136    Xor,
137    None,
138    Mdl,
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct KeyTomlConfig {
143    pub exe_key16: Option<[u8; 16]>,
144    pub base_angou_code: Option<Vec<u8>>,
145    pub game_angou_code: Option<Vec<u8>>,
146    pub chain_order: Option<Vec<AngouStepKind>>,
147    /// Scene string-table decoding policy. Missing means the historical
148    /// runtime default (`xor`). `none` forces plain UTF-16, while `mdl` and
149    /// every other value request package-level MDL detection.
150    pub override_string_encryption: StringEncryptionOverride,
151    /// Single variable DWORD from the canonical Emote PSB key state
152    /// `0x075BCD15, 0x159A55E5, 0x1F123BB5, emote_key, 0, 0`.
153    pub emote_key: Option<u32>,
154}
155
156pub fn load_key_toml_from_project_dir(project_dir: &Path) -> Result<Option<KeyTomlConfig>> {
157    let Some(path) = project_key_toml_path(project_dir)? else {
158        return Ok(None);
159    };
160    load_key_toml_from_file(&path).map(Some)
161}
162
163pub fn load_key_toml_from_file(path: &Path) -> Result<KeyTomlConfig> {
164    let text = read_key_toml_text(path)?;
165    parse_key_toml(&text)
166}
167
168pub fn parse_key_toml(text: &str) -> Result<KeyTomlConfig> {
169    Ok(KeyTomlConfig {
170        exe_key16: parse_key16_toml(text)?,
171        base_angou_code: parse_named_bytes(text, "base_angou_code", "base_angou_hex")?,
172        game_angou_code: parse_named_bytes(text, "game_angou_code", "game_angou_hex")?,
173        chain_order: parse_chain_order(text)?,
174        override_string_encryption: parse_string_encryption_override_toml(text),
175        emote_key: parse_named_u32(text, "emote_key")?,
176    })
177}
178
179/// Atomically add or replace the 16-byte Siglus executable encryption key in
180/// `<project>/key.toml`, preserving unrelated settings and comments.
181pub fn write_key16_to_project_dir(project_dir: &Path, key: [u8; 16]) -> Result<PathBuf> {
182    let path = project_key_toml_path(project_dir)?.unwrap_or_else(|| project_dir.join("key.toml"));
183    write_key16_to_file(&path, key)?;
184    Ok(path)
185}
186
187pub fn write_key16_to_file(path: &Path, key: [u8; 16]) -> Result<()> {
188    let old = match fs::read_to_string(path) {
189        Ok(text) => text,
190        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
191        Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
192    };
193    let updated = update_key16_toml_text(&old, key);
194    atomic_write(path, updated.as_bytes())?;
195    Ok(())
196}
197
198/// Text-only helper used by the writer and regression tests.
199///
200/// `key` is preferred over the legacy `key_hex` spelling because the parser
201/// also gives `key` precedence. Existing multiline `key = [...]` assignments
202/// are collapsed to one canonical line when their closing bracket can be
203/// identified without crossing another assignment/table header.
204pub fn update_key16_toml_text(text: &str, key: [u8; 16]) -> String {
205    let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
206    let key_array = format_key16_array(&key);
207    let key_hex = key.iter().map(|b| format!("{b:02X}")).collect::<String>();
208    let lines = text.split_inclusive('\n').collect::<Vec<_>>();
209
210    let has_key = lines.iter().any(|line| {
211        let body = line.trim_end_matches(['\r', '\n']);
212        assignment_eq_for_key(body, "key").is_some()
213    });
214    let target_name = if has_key { "key" } else { "key_hex" };
215    let target_idx = lines.iter().position(|line| {
216        let body = line.trim_end_matches(['\r', '\n']);
217        assignment_eq_for_key(body, target_name).is_some()
218    });
219
220    if let Some(target_idx) = target_idx {
221        let line = lines[target_idx];
222        let body_with_cr = line.strip_suffix('\n').unwrap_or(line);
223        let body = body_with_cr.strip_suffix('\r').unwrap_or(body_with_cr);
224        let eq = assignment_eq_for_key(body, target_name).expect("target assignment");
225        let comment = comment_start(body).unwrap_or(body.len());
226        let rhs = &body[eq + 1..comment];
227        let leading_ws_len = rhs.len() - rhs.trim_start().len();
228        let trailing_ws_len = rhs.len() - rhs.trim_end().len();
229        let leading_ws = &rhs[..leading_ws_len];
230        let trailing_ws = if trailing_ws_len == 0 {
231            ""
232        } else {
233            &rhs[rhs.len() - trailing_ws_len..]
234        };
235        let replacement = if target_name == "key" {
236            key_array.clone()
237        } else {
238            format!("\"{key_hex}\"")
239        };
240
241        let mut end_idx = target_idx;
242        if target_name == "key" && rhs.contains('[') && !rhs.contains(']') {
243            for (idx, candidate) in lines.iter().enumerate().skip(target_idx + 1) {
244                let candidate_body = candidate.trim_end_matches(['\r', '\n']);
245                let code = &candidate_body
246                    [..comment_start(candidate_body).unwrap_or(candidate_body.len())];
247                let trimmed = code.trim();
248                if trimmed.contains(']') {
249                    end_idx = idx;
250                    break;
251                }
252                if trimmed.starts_with('[') || trimmed.contains('=') {
253                    break;
254                }
255            }
256        }
257
258        let mut out = String::with_capacity(text.len().saturating_add(96));
259        for item in &lines[..target_idx] {
260            out.push_str(item);
261        }
262        out.push_str(&body[..eq + 1]);
263        out.push_str(leading_ws);
264        out.push_str(&replacement);
265        out.push_str(trailing_ws);
266        out.push_str(&body[comment..]);
267        if line.ends_with("\r\n") {
268            out.push_str("\r\n");
269        } else if line.ends_with('\n') {
270            out.push('\n');
271        }
272        for item in &lines[end_idx + 1..] {
273            out.push_str(item);
274        }
275        return out;
276    }
277
278    let assignment = format!("key = {key_array}{newline}");
279    let mut out = text.to_string();
280    if contains_toml_table_header(&out) {
281        let at = root_setting_insertion_offset(&out);
282        let mut rooted = String::with_capacity(out.len() + assignment.len() + newline.len());
283        rooted.push_str(&out[..at]);
284        if at != 0 && !rooted.ends_with('\n') {
285            rooted.push_str(newline);
286        }
287        rooted.push_str(&assignment);
288        rooted.push_str(&out[at..]);
289        return rooted;
290    }
291    if !out.is_empty() && !out.ends_with('\n') {
292        out.push_str(newline);
293    }
294    out.push_str(&assignment);
295    out
296}
297
298fn format_key16_array(key: &[u8; 16]) -> String {
299    let values = key
300        .iter()
301        .map(|b| format!("0x{b:02X}"))
302        .collect::<Vec<_>>()
303        .join(", ");
304    format!("[{values}]")
305}
306
307/// Atomically add or replace `emote_key` in `<project>/key.toml` while
308/// preserving every unrelated line and comment in the existing file.
309///
310/// The DWORD is written in the conventional fixed-width hexadecimal form when
311/// the key is new. If an existing `emote_key` uses decimal notation, its radix
312/// is retained.
313pub fn write_emote_key_to_project_dir(project_dir: &Path, key: u32) -> Result<PathBuf> {
314    let path = project_key_toml_path(project_dir)?.unwrap_or_else(|| project_dir.join("key.toml"));
315    write_emote_key_to_file(&path, key)?;
316    Ok(path)
317}
318
319pub fn write_emote_key_to_file(path: &Path, key: u32) -> Result<()> {
320    let old = match fs::read_to_string(path) {
321        Ok(text) => text,
322        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
323        Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
324    };
325    let updated = update_emote_key_toml_text(&old, key);
326    atomic_write(path, updated.as_bytes())?;
327    Ok(())
328}
329
330/// Text-only helper used by the writer and regression tests.
331pub fn update_emote_key_toml_text(text: &str, key: u32) -> String {
332    let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
333    let mut out = String::with_capacity(text.len().saturating_add(32));
334    let mut replaced = false;
335
336    for line_with_nl in text.split_inclusive('\n') {
337        let (body_with_cr, line_nl) = line_with_nl
338            .strip_suffix('\n')
339            .map(|body| (body, "\n"))
340            .unwrap_or((line_with_nl, ""));
341        let (body, cr) = body_with_cr
342            .strip_suffix('\r')
343            .map(|body| (body, "\r"))
344            .unwrap_or((body_with_cr, ""));
345
346        if !replaced && let Some(eq) = assignment_eq_for_key(body, "emote_key") {
347            let comment = comment_start(body).unwrap_or(body.len());
348            if eq < comment {
349                let rhs = &body[eq + 1..comment];
350                let leading_ws_len = rhs.len() - rhs.trim_start().len();
351                let trailing_ws_len = rhs.len() - rhs.trim_end().len();
352                let leading_ws = &rhs[..leading_ws_len];
353                let trailing_ws = if trailing_ws_len == 0 {
354                    ""
355                } else {
356                    &rhs[rhs.len() - trailing_ws_len..]
357                };
358                let old_value = rhs.trim();
359                let value = format_u32_like(old_value, key);
360
361                out.push_str(&body[..eq + 1]);
362                out.push_str(leading_ws);
363                out.push_str(&value);
364                out.push_str(trailing_ws);
365                out.push_str(&body[comment..]);
366                out.push_str(cr);
367                out.push_str(line_nl);
368                replaced = true;
369                continue;
370            }
371        }
372
373        out.push_str(body);
374        out.push_str(cr);
375        out.push_str(line_nl);
376    }
377
378    // `split_inclusive` yields no item for an empty input, and an input without
379    // a trailing newline has already been copied above.
380    if !replaced {
381        let assignment = format!("emote_key = 0x{key:08X}{newline}");
382        if contains_toml_table_header(&out) {
383            // Appending after a `[table]` header would silently place emote_key
384            // inside that table. Keep the new setting at TOML root, after only
385            // an optional leading comment/blank-file header.
386            let at = root_setting_insertion_offset(&out);
387            let mut rooted = String::with_capacity(out.len() + assignment.len() + newline.len());
388            rooted.push_str(&out[..at]);
389            if at != 0 && !rooted.ends_with('\n') {
390                rooted.push_str(newline);
391            }
392            rooted.push_str(&assignment);
393            rooted.push_str(&out[at..]);
394            return rooted;
395        }
396        if !out.is_empty() && !out.ends_with('\n') {
397            out.push_str(newline);
398        }
399        out.push_str(&assignment);
400    }
401
402    out
403}
404
405fn contains_toml_table_header(text: &str) -> bool {
406    text.lines().any(|line| {
407        let code = &line[..comment_start(line).unwrap_or(line.len())];
408        let code = code.trim();
409        code.starts_with('[') && code.ends_with(']') && !code.contains('=')
410    })
411}
412
413fn root_setting_insertion_offset(text: &str) -> usize {
414    let mut offset = 0usize;
415    for line in text.split_inclusive('\n') {
416        let body = line.trim_end_matches(['\r', '\n']);
417        let trimmed = body.trim();
418        if trimmed.is_empty() || trimmed.starts_with('#') {
419            offset += line.len();
420        } else {
421            break;
422        }
423    }
424    offset
425}
426
427fn parse_named_bytes(text: &str, key: &str, alt_hex_key: &str) -> Result<Option<Vec<u8>>> {
428    if let Some(raw) = collect_rhs_for_key(text, key) {
429        return parse_bytes_value(&raw, key);
430    }
431    if let Some(raw) = collect_rhs_for_key(text, alt_hex_key) {
432        return parse_hex_value(&raw, alt_hex_key);
433    }
434    Ok(None)
435}
436
437pub fn parse_string_encryption_override_toml(text: &str) -> StringEncryptionOverride {
438    let Some(raw) = collect_scalar_rhs_for_key(text, "override_string_encryption") else {
439        return StringEncryptionOverride::Xor;
440    };
441    let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
442    match raw.to_ascii_lowercase().as_str() {
443        "xor" => StringEncryptionOverride::Xor,
444        "none" => StringEncryptionOverride::None,
445        // `mdl` is the documented spelling, but unknown values deliberately
446        // select auto detection rather than making key.toml fatal.
447        _ => StringEncryptionOverride::Mdl,
448    }
449}
450
451fn parse_named_u32(text: &str, key: &str) -> Result<Option<u32>> {
452    let Some(raw) = collect_scalar_rhs_for_key(text, key) else {
453        return Ok(None);
454    };
455    let raw = raw.trim();
456    if raw.is_empty() {
457        return Ok(None);
458    }
459    let raw = raw.trim_matches('"').trim_matches('\'').trim();
460    let normalized = raw.replace('_', "");
461    let value = if let Some(hex) = normalized
462        .strip_prefix("0x")
463        .or_else(|| normalized.strip_prefix("0X"))
464    {
465        u32::from_str_radix(hex, 16)
466            .with_context(|| format!("key.toml: invalid hex DWORD for {key}: {raw}"))?
467    } else {
468        normalized
469            .parse::<u32>()
470            .with_context(|| format!("key.toml: invalid DWORD for {key}: {raw}"))?
471    };
472    Ok(Some(value))
473}
474
475fn collect_scalar_rhs_for_key<'a>(text: &'a str, key: &str) -> Option<&'a str> {
476    for raw_line in text.lines() {
477        let comment = comment_start(raw_line).unwrap_or(raw_line.len());
478        let code = &raw_line[..comment];
479        let Some((lhs, rhs)) = code.split_once('=') else {
480            continue;
481        };
482        if lhs.trim() == key {
483            return Some(rhs.trim());
484        }
485    }
486    None
487}
488
489fn assignment_eq_for_key(line: &str, key: &str) -> Option<usize> {
490    let comment = comment_start(line).unwrap_or(line.len());
491    let code = &line[..comment];
492    let eq = code.find('=')?;
493    (code[..eq].trim() == key).then_some(eq)
494}
495
496fn comment_start(line: &str) -> Option<usize> {
497    let mut quote: Option<char> = None;
498    let mut escaped = false;
499    for (idx, ch) in line.char_indices() {
500        if escaped {
501            escaped = false;
502            continue;
503        }
504        if ch == '\\' && quote == Some('"') {
505            escaped = true;
506            continue;
507        }
508        match ch {
509            '"' | '\'' => {
510                if quote == Some(ch) {
511                    quote = None;
512                } else if quote.is_none() {
513                    quote = Some(ch);
514                }
515            }
516            '#' if quote.is_none() => return Some(idx),
517            _ => {}
518        }
519    }
520    None
521}
522
523fn format_u32_like(old_value: &str, key: u32) -> String {
524    let old = old_value.trim_matches('"').trim_matches('\'').trim();
525    if old.starts_with("0x") || old.starts_with("0X") {
526        if old
527            .chars()
528            .any(|ch| ch.is_ascii_hexdigit() && ch.is_ascii_lowercase())
529        {
530            format!("0x{key:08x}")
531        } else {
532            format!("0x{key:08X}")
533        }
534    } else if old.is_empty() {
535        format!("0x{key:08X}")
536    } else {
537        key.to_string()
538    }
539}
540
541fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
542    let parent = path
543        .parent()
544        .ok_or_else(|| anyhow::anyhow!("path has no parent: {}", path.display()))?;
545    let existing_permissions = match fs::metadata(path) {
546        Ok(metadata) => {
547            let permissions = metadata.permissions();
548            if permissions.readonly() {
549                bail!("{} is read-only", path.display());
550            }
551            Some(permissions)
552        }
553        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
554        Err(err) => return Err(err).with_context(|| format!("stat {}", path.display())),
555    };
556    let file_name = path
557        .file_name()
558        .and_then(|v| v.to_str())
559        .unwrap_or("key.toml");
560    let pid = std::process::id();
561
562    let mut temp_path = None;
563    let mut temp_file = None;
564    for attempt in 0..128u32 {
565        let candidate = parent.join(format!(".{file_name}.emote-key-{pid}-{attempt}.tmp"));
566        match OpenOptions::new()
567            .write(true)
568            .create_new(true)
569            .open(&candidate)
570        {
571            Ok(file) => {
572                temp_path = Some(candidate);
573                temp_file = Some(file);
574                break;
575            }
576            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
577            Err(err) => {
578                return Err(err).with_context(|| {
579                    format!("create temporary key.toml beside {}", path.display())
580                });
581            }
582        }
583    }
584
585    let temp_path = temp_path.ok_or_else(|| {
586        anyhow::anyhow!(
587            "could not allocate temporary file beside {}",
588            path.display()
589        )
590    })?;
591    let mut file = temp_file.expect("temporary file accompanies temporary path");
592    let write_result = (|| -> Result<()> {
593        file.write_all(bytes)
594            .with_context(|| format!("write {}", temp_path.display()))?;
595        if let Some(permissions) = existing_permissions {
596            file.set_permissions(permissions)
597                .with_context(|| format!("preserve permissions on {}", temp_path.display()))?;
598        }
599        file.sync_all()
600            .with_context(|| format!("sync {}", temp_path.display()))?;
601        drop(file);
602        replace_file_from_temp(&temp_path, path)?;
603        sync_parent_directory_best_effort(parent);
604        Ok(())
605    })();
606
607    if write_result.is_err() {
608        let _ = fs::remove_file(&temp_path);
609    }
610    write_result
611}
612
613#[cfg(unix)]
614fn sync_parent_directory_best_effort(parent: &Path) {
615    if let Ok(dir) = fs::File::open(parent) {
616        let _ = dir.sync_all();
617    }
618}
619
620#[cfg(not(unix))]
621fn sync_parent_directory_best_effort(_parent: &Path) {}
622
623#[cfg(not(windows))]
624fn replace_file_from_temp(temp_path: &Path, path: &Path) -> Result<()> {
625    fs::rename(temp_path, path).with_context(|| {
626        format!(
627            "replace {} with temporary file {}",
628            path.display(),
629            temp_path.display()
630        )
631    })
632}
633
634#[cfg(windows)]
635fn replace_file_from_temp(temp_path: &Path, path: &Path) -> Result<()> {
636    if !path.exists() {
637        return fs::rename(temp_path, path).with_context(|| {
638            format!(
639                "install {} from temporary file {}",
640                path.display(),
641                temp_path.display()
642            )
643        });
644    }
645
646    // std::fs::rename does not replace an existing destination on Windows.
647    // Keep a same-directory backup so a failed install can restore the exact
648    // previous key.toml instead of truncating or recreating it.
649    let parent = path
650        .parent()
651        .ok_or_else(|| anyhow::anyhow!("path has no parent: {}", path.display()))?;
652    let file_name = path
653        .file_name()
654        .and_then(|v| v.to_str())
655        .unwrap_or("key.toml");
656    let backup = parent.join(format!(".{file_name}.emote-key-replace.bak"));
657    let _ = fs::remove_file(&backup);
658    fs::rename(path, &backup).with_context(|| format!("move existing {} aside", path.display()))?;
659    match fs::rename(temp_path, path) {
660        Ok(()) => {
661            let _ = fs::remove_file(&backup);
662            Ok(())
663        }
664        Err(err) => {
665            let restore = fs::rename(&backup, path);
666            if let Err(restore_err) = restore {
667                return Err(anyhow::anyhow!(
668                    "replace {} failed: {}; restoring backup {} also failed: {}",
669                    path.display(),
670                    err,
671                    backup.display(),
672                    restore_err
673                ));
674            }
675            Err(err).with_context(|| {
676                format!(
677                    "replace {} with temporary file {}",
678                    path.display(),
679                    temp_path.display()
680                )
681            })
682        }
683    }
684}
685
686fn parse_bytes_value(raw: &str, key: &str) -> Result<Option<Vec<u8>>> {
687    let raw = raw.trim();
688    if raw.is_empty() {
689        return Ok(None);
690    }
691    if raw.contains('[') {
692        let (inner, _) = extract_bracketed(raw)
693            .ok_or_else(|| anyhow::anyhow!("key.toml: {key} array missing closing ]"))?;
694        return Ok(Some(parse_byte_array(inner, key)?));
695    }
696    if raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2 {
697        let inner = &raw[1..raw.len() - 1];
698        let bytes = angou::parse_hex_bytes(inner)
699            .with_context(|| format!("key.toml: invalid hex for {key}"))?;
700        return Ok(Some(bytes));
701    }
702    Ok(None)
703}
704
705fn parse_hex_value(raw: &str, key: &str) -> Result<Option<Vec<u8>>> {
706    let raw = raw.trim();
707    if raw.is_empty() {
708        return Ok(None);
709    }
710    let inner = raw.trim_matches('"');
711    let bytes = angou::parse_hex_bytes(inner)
712        .with_context(|| format!("key.toml: invalid hex for {key}"))?;
713    Ok(Some(bytes))
714}
715
716fn parse_byte_array(inner: &str, key: &str) -> Result<Vec<u8>> {
717    let mut bytes = Vec::new();
718    for part in inner.split(',') {
719        let tok = part.trim();
720        if tok.is_empty() {
721            continue;
722        }
723        let value = if let Some(hex) = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) {
724            u8::from_str_radix(hex, 16)
725                .with_context(|| format!("key.toml: invalid hex byte {tok}"))?
726        } else {
727            let v: u16 = tok
728                .parse()
729                .with_context(|| format!("key.toml: invalid byte {tok}"))?;
730            if v > 0xFF {
731                bail!("key.toml: byte out of range {tok}");
732            }
733            v as u8
734        };
735        bytes.push(value);
736    }
737    if bytes.is_empty() {
738        bail!("key.toml: {key} array is empty");
739    }
740    Ok(bytes)
741}
742
743fn collect_rhs_for_key(text: &str, key: &str) -> Option<String> {
744    let mut collecting = false;
745    let mut out = String::new();
746
747    for raw_line in text.lines() {
748        let line = raw_line.split('#').next().unwrap_or("").trim();
749        if line.is_empty() {
750            continue;
751        }
752
753        if !collecting {
754            let Some((lhs, rhs)) = line.split_once('=') else {
755                continue;
756            };
757            if lhs.trim() != key {
758                continue;
759            }
760            collecting = true;
761            out.push_str(rhs.trim());
762            if rhs.contains(']') {
763                break;
764            }
765        } else {
766            out.push(' ');
767            out.push_str(line);
768            if line.contains(']') {
769                break;
770            }
771        }
772    }
773
774    if out.is_empty() { None } else { Some(out) }
775}
776
777fn extract_bracketed(raw: &str) -> Option<(&str, &str)> {
778    let start = raw.find('[')?;
779    let end = raw[start + 1..].find(']').map(|v| start + 1 + v)?;
780    Some((&raw[start + 1..end], &raw[end + 1..]))
781}
782
783fn parse_chain_order(text: &str) -> Result<Option<Vec<AngouStepKind>>> {
784    let Some(raw) = collect_rhs_for_key(text, "chain_order") else {
785        return Ok(None);
786    };
787    let raw = raw.trim();
788    if raw.is_empty() {
789        return Ok(None);
790    }
791    let inner = if raw.contains('[') {
792        let (inner, _) = extract_bracketed(raw)
793            .ok_or_else(|| anyhow::anyhow!("key.toml: chain_order missing closing ]"))?;
794        inner
795    } else {
796        raw
797    };
798    let mut out = Vec::new();
799    for part in inner.split(',') {
800        let tok = part.trim().trim_matches('"').trim_matches('\'');
801        if tok.is_empty() {
802            continue;
803        }
804        let kind = match tok.to_ascii_lowercase().as_str() {
805            "exe" | "exe_key16" => AngouStepKind::ExeKey16,
806            "base" | "base_code" => AngouStepKind::BaseCode,
807            "game" | "game_code" => AngouStepKind::GameCode,
808            other => bail!("key.toml: unknown chain_order item {other}"),
809        };
810        out.push(kind);
811    }
812    if out.is_empty() {
813        return Ok(None);
814    }
815    Ok(Some(out))
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn string_encryption_override_defaults_to_xor_and_accepts_auto_values() {
824        assert_eq!(
825            parse_key_toml("title = \"demo\"\n")
826                .unwrap()
827                .override_string_encryption,
828            StringEncryptionOverride::Xor
829        );
830        assert_eq!(
831            parse_key_toml("override_string_encryption = xor\n")
832                .unwrap()
833                .override_string_encryption,
834            StringEncryptionOverride::Xor
835        );
836        assert_eq!(
837            parse_key_toml("override_string_encryption = \"none\"\n")
838                .unwrap()
839                .override_string_encryption,
840            StringEncryptionOverride::None
841        );
842        assert_eq!(
843            parse_key_toml("override_string_encryption = \"mdl\"\n")
844                .unwrap()
845                .override_string_encryption,
846            StringEncryptionOverride::Mdl
847        );
848        assert_eq!(
849            parse_key_toml("override_string_encryption = future_mode\n")
850                .unwrap()
851                .override_string_encryption,
852            StringEncryptionOverride::Mdl
853        );
854    }
855
856    #[test]
857    fn updates_existing_key16_without_touching_other_settings() {
858        let src = "# keep\nkey = [0x00, 0x01] # old\nemote_key = 7\n";
859        let key = [0x10u8; 16];
860        let out = update_key16_toml_text(src, key);
861        assert_eq!(
862            out,
863            "# keep\nkey = [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10] # old\nemote_key = 7\n"
864        );
865    }
866
867    #[test]
868    fn updates_key_hex_when_key_array_is_absent() {
869        let src = "key_hex = \"00112233445566778899AABBCCDDEEFF\"\n";
870        let key = [0xABu8; 16];
871        let out = update_key16_toml_text(src, key);
872        assert_eq!(out, "key_hex = \"ABABABABABABABABABABABABABABABAB\"\n");
873    }
874
875    #[test]
876    fn inserts_missing_key16_at_root_before_tables() {
877        let src = "# keys\n\n[legacy]\nvalue = 1\n";
878        let key = [0x01u8; 16];
879        let out = update_key16_toml_text(src, key);
880        assert_eq!(
881            out,
882            "# keys\n\nkey = [0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]\n[legacy]\nvalue = 1\n"
883        );
884    }
885
886    #[test]
887    fn parses_emote_key_hex_and_decimal() {
888        let hex = parse_key_toml("emote_key = 0x89ABCDEF\n").unwrap();
889        assert_eq!(hex.emote_key, Some(0x89AB_CDEF));
890
891        let decimal = parse_key_toml("emote_key = 2309737967 # keep\n").unwrap();
892        assert_eq!(decimal.emote_key, Some(0x89AB_CDEF));
893    }
894
895    #[test]
896    fn updates_existing_emote_key_without_touching_other_lines() {
897        let src = "key = [0x01, 0x02]\nemote_key = 123 # old\nchain_order = [\"exe\"]\n";
898        let out = update_emote_key_toml_text(src, 0x1234_ABCD);
899        assert_eq!(
900            out,
901            "key = [0x01, 0x02]\nemote_key = 305441741 # old\nchain_order = [\"exe\"]\n"
902        );
903    }
904
905    #[test]
906    fn appends_new_emote_key_and_preserves_crlf() {
907        let src = "key_hex = \"00112233445566778899AABBCCDDEEFF\"\r\n";
908        let out = update_emote_key_toml_text(src, 0x89AB_CDEF);
909        assert_eq!(
910            out,
911            "key_hex = \"00112233445566778899AABBCCDDEEFF\"\r\nemote_key = 0x89ABCDEF\r\n"
912        );
913    }
914
915    #[test]
916    fn keeps_hex_style_for_existing_emote_key() {
917        let src = "emote_key = 0x00000001\n";
918        let out = update_emote_key_toml_text(src, 0x89AB_CDEF);
919        assert_eq!(out, "emote_key = 0x89ABCDEF\n");
920    }
921
922    #[test]
923    fn inserts_missing_emote_key_at_root_before_tables() {
924        let src = "# project keys\n\n[legacy]\nvalue = 7\n";
925        let out = update_emote_key_toml_text(src, 0x1234_ABCD);
926        assert_eq!(
927            out,
928            "# project keys\n\nemote_key = 0x1234ABCD\n[legacy]\nvalue = 7\n"
929        );
930    }
931}