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 #[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 pub override_string_encryption: StringEncryptionOverride,
151 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
179pub 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
198pub 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
307pub 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
330pub 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 if !replaced {
381 let assignment = format!("emote_key = 0x{key:08X}{newline}");
382 if contains_toml_table_header(&out) {
383 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 _ => 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 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}