use flate2::read::ZlibDecoder;
use std::io::Read;
use crate::formats::{War3MapW3i, War3MapWts};
const MASK_ZLIB: u8 = 0x02;
const ZLIB_FLG: [u8; 4] = [0x01, 0x5E, 0x9C, 0xDA];
const MAX_CARVED: u64 = 8 << 20;
const WTS_KEYWORD: &[u8] = b"STRING ";
#[derive(Debug)]
pub struct Carved {
pub info: War3MapW3i,
pub strings: Option<War3MapWts>,
}
fn plausible(info: &War3MapW3i) -> bool {
info.version.is_known()
&& !info.name.trim().is_empty()
&& !info.name.chars().any(char::is_control)
}
fn starts_sector(buffer: &[u8], at: usize) -> bool {
matches!(buffer.get(at..at + 3), Some([MASK_ZLIB, 0x78, flg]) if ZLIB_FLG.contains(flg))
}
fn sector_offsets(buffer: &[u8]) -> impl Iterator<Item = usize> + '_ {
(0..buffer.len().saturating_sub(2)).filter(|&at| starts_sector(buffer, at))
}
fn inflate(buffer: &[u8], offset: usize) -> Option<(Vec<u8>, usize)> {
let mut out = Vec::new();
let mut decoder = ZlibDecoder::new(&buffer[offset + 1..]);
(&mut decoder).take(MAX_CARVED).read_to_end(&mut out).ok()?;
let consumed = decoder.total_in() as usize;
(!out.is_empty()).then_some((out, consumed))
}
fn inflate_chain(buffer: &[u8], offset: usize) -> (Vec<u8>, usize) {
let mut joined = Vec::new();
let mut at = offset;
while starts_sector(buffer, at) {
let Some((data, consumed)) = inflate(buffer, at) else {
break;
};
joined.extend_from_slice(&data);
at += 1 + consumed;
if joined.len() as u64 >= MAX_CARVED {
break;
}
}
(joined, at)
}
fn parse_table(data: &[u8]) -> Option<War3MapWts> {
let text = String::from_utf8_lossy(data);
let table = War3MapWts::parse(&text).ok()?;
(!table.string_map.is_empty()).then_some(table)
}
pub fn carve(buffer: &[u8]) -> Option<Carved> {
let mut info: Option<War3MapW3i> = None;
let mut strings: Option<War3MapWts> = None;
let mut table_scanned_until = 0;
for offset in sector_offsets(buffer) {
let Some((data, _)) = inflate(buffer, offset) else {
continue;
};
if info.is_none() {
if let Ok(candidate) = War3MapW3i::parse(&data) {
if plausible(&candidate) {
info = Some(candidate);
continue;
}
}
}
if offset < table_scanned_until
|| !data.windows(WTS_KEYWORD.len()).any(|w| w == WTS_KEYWORD)
{
continue;
}
let (joined, end) = inflate_chain(buffer, offset);
table_scanned_until = end;
if let Some(table) = parse_table(&joined) {
let best = strings.as_ref().map_or(0, |t| t.string_map.len());
if table.string_map.len() > best {
strings = Some(table);
}
}
}
info.map(|info| Carved { info, strings })
}
impl Carved {
pub fn resolve_trigger_strings(&mut self) {
let Some(strings) = self.strings.as_ref() else {
return;
};
self.info.visit_strings(|text| {
if let Some(value) = strings.resolve(text) {
*text = value.to_string();
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
fn sector(payload: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(payload).unwrap();
let mut out = vec![MASK_ZLIB];
out.extend(encoder.finish().unwrap());
out
}
fn w3i(name: &str) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend(25u32.to_le_bytes());
buf.extend(0u32.to_le_bytes());
buf.extend(0u32.to_le_bytes());
for field in [name, "An Author", "", ""] {
buf.extend(field.as_bytes());
buf.push(0);
}
buf.resize(buf.len() + 256, 0);
buf
}
fn wts_entry(id: u32, value: &str) -> String {
format!("STRING {id}\n{{\n{value}\n}}\n")
}
#[test]
fn carves_a_w3i_out_of_surrounding_noise() {
let mut archive = vec![0xAB; 4096];
archive.extend(sector(&w3i("守卫剑阁")));
archive.extend(vec![0xCD; 4096]);
let carved = carve(&archive).expect("w3i should be carved");
assert_eq!(carved.info.name, "守卫剑阁");
assert_eq!(carved.info.author, "An Author");
}
#[test]
fn resolves_trigger_strings_from_a_carved_string_table() {
let mut archive = sector(&w3i("TRIGSTR_001"));
archive.extend(sector(b"STRING 1\n{\n\xe6\x94\xbb\xe5\xae\x88\n}\n"));
let mut carved = carve(&archive).expect("w3i should be carved");
assert_eq!(carved.info.name, "TRIGSTR_001");
carved.resolve_trigger_strings();
assert_eq!(carved.info.name, "攻守");
}
#[test]
fn joins_a_string_table_split_across_sectors() {
let mut archive = sector(&w3i("TRIGSTR_900"));
archive.extend(sector(wts_entry(1, "first sector").as_bytes()));
archive.extend(sector(wts_entry(900, "跨扇区的标题").as_bytes()));
let carved = carve(&archive).expect("w3i should be carved");
let strings = carved.strings.as_ref().expect("table should be carved");
assert_eq!(strings.get(1), Some("first sector"));
assert_eq!(strings.get(900), Some("跨扇区的标题"));
}
#[test]
fn prefers_the_richest_string_table() {
let mut archive = sector(&w3i("TRIGSTR_002"));
archive.extend(vec![0xAB; 16]);
archive.extend(sector(b"call BJDebugMsg(\"STRING \")"));
archive.extend(vec![0xAB; 16]);
archive.extend(sector(
format!("{}{}", wts_entry(1, "one"), wts_entry(2, "two")).as_bytes(),
));
let mut carved = carve(&archive).expect("w3i should be carved");
carved.resolve_trigger_strings();
assert_eq!(carved.info.name, "two");
}
#[test]
fn a_payload_with_no_w3i_yields_nothing() {
let mut archive = vec![0xAB; 2048];
archive.extend(sector(b"not a w3i, just bytes"));
assert!(carve(&archive).is_none());
}
#[test]
fn rejects_an_inflatable_sector_that_is_not_a_w3i() {
let mut bogus = Vec::new();
bogus.extend(99u32.to_le_bytes()); bogus.extend(b"\0\0\0\0");
bogus.resize(512, 0);
assert!(carve(§or(&bogus)).is_none());
}
}