2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
//! Stage-1 `RsLi` archive contract.
|
|
|
|
|
|
|
|
|
|
use std::fmt;
|
|
|
|
|
use std::io::Read;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
/// Read profile.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum ReadProfile {
|
|
|
|
|
/// Reject compatibility quirks.
|
|
|
|
|
Strict,
|
|
|
|
|
/// Accept registered retail compatibility quirks.
|
|
|
|
|
Compatible,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:58:59 +04:00
|
|
|
/// Detailed read profile.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum RsliReadProfile {
|
|
|
|
|
/// Reject compatibility quirks.
|
|
|
|
|
Strict,
|
|
|
|
|
/// Accept selected retail compatibility quirks.
|
|
|
|
|
Compatible(RsliCompatibilityProfile),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<ReadProfile> for RsliReadProfile {
|
|
|
|
|
fn from(value: ReadProfile) -> Self {
|
|
|
|
|
match value {
|
|
|
|
|
ReadProfile::Strict => Self::Strict,
|
|
|
|
|
ReadProfile::Compatible => Self::Compatible(RsliCompatibilityProfile::default()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RsliReadProfile {
|
|
|
|
|
/// Strict profile with every compatibility quirk disabled.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn strict() -> Self {
|
|
|
|
|
Self::Strict
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Retail-compatible profile with the default approved quirk set.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn compatible() -> Self {
|
|
|
|
|
Self::Compatible(RsliCompatibilityProfile::retail())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Retail-compatible profile with a caller-provided quirk set.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn compatible_with(profile: RsliCompatibilityProfile) -> Self {
|
|
|
|
|
Self::Compatible(profile)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Write profile.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum WriteProfile {
|
|
|
|
|
/// Return the original byte image.
|
|
|
|
|
Lossless,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `RsLi` compatibility switches.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct RsliCompatibilityProfile {
|
|
|
|
|
/// Allow the registered `AO` trailer overlay.
|
|
|
|
|
pub allow_ao_trailer: bool,
|
|
|
|
|
/// Allow retail Deflate entries whose declared size is one byte past EOF.
|
|
|
|
|
pub allow_deflate_eof_plus_one: bool,
|
|
|
|
|
/// Rebuild lookup order when a retail presorted table is corrupt.
|
|
|
|
|
pub allow_invalid_presorted_fallback: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for RsliCompatibilityProfile {
|
|
|
|
|
fn default() -> Self {
|
2026-06-22 16:58:59 +04:00
|
|
|
Self::retail()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RsliCompatibilityProfile {
|
|
|
|
|
/// Retail-compatible profile with every approved quirk enabled.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn retail() -> Self {
|
2026-06-22 13:12:27 +04:00
|
|
|
Self {
|
|
|
|
|
allow_ao_trailer: true,
|
|
|
|
|
allow_deflate_eof_plus_one: true,
|
|
|
|
|
allow_invalid_presorted_fallback: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-22 16:58:59 +04:00
|
|
|
|
|
|
|
|
/// Profile with every compatibility quirk disabled.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn none() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
allow_ao_trailer: false,
|
|
|
|
|
allow_deflate_eof_plus_one: false,
|
|
|
|
|
allow_invalid_presorted_fallback: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `RsLi` packing method.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum RsliMethod {
|
|
|
|
|
/// Stored without packing.
|
|
|
|
|
Stored,
|
|
|
|
|
/// XOR only.
|
|
|
|
|
XorOnly,
|
|
|
|
|
/// Simple LZSS.
|
|
|
|
|
Lzss,
|
|
|
|
|
/// XOR plus simple LZSS.
|
|
|
|
|
XorLzss,
|
|
|
|
|
/// Adaptive LZSS/Huffman method `0x080`.
|
|
|
|
|
AdaptiveLzss,
|
|
|
|
|
/// XOR plus adaptive LZSS/Huffman method `0x0A0`.
|
|
|
|
|
XorAdaptiveLzss,
|
|
|
|
|
/// Raw Deflate.
|
|
|
|
|
RawDeflate,
|
|
|
|
|
/// Unsupported method bits.
|
|
|
|
|
Unknown(u32),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Entry identifier in original table order.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
|
|
|
pub struct EntryId(pub u32);
|
|
|
|
|
|
|
|
|
|
/// Archive header summary.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct RsliHeader {
|
|
|
|
|
/// Raw 32-byte header.
|
|
|
|
|
pub raw: [u8; 32],
|
|
|
|
|
/// Format version.
|
|
|
|
|
pub version: u8,
|
|
|
|
|
/// Entry count.
|
|
|
|
|
pub entry_count: u16,
|
|
|
|
|
/// Presorted flag from the header.
|
|
|
|
|
pub presorted_flag: u16,
|
|
|
|
|
/// XOR seed used for the entry table.
|
|
|
|
|
pub xor_seed: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `AO` trailer summary.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct AoTrailer {
|
|
|
|
|
/// Raw six-byte trailer.
|
|
|
|
|
pub raw: [u8; 6],
|
|
|
|
|
/// Media overlay byte offset.
|
|
|
|
|
pub overlay: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Entry metadata.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct EntryMeta {
|
|
|
|
|
/// Decoded byte-for-byte name adapter.
|
|
|
|
|
pub name: String,
|
|
|
|
|
/// Raw fixed-size name field.
|
|
|
|
|
pub name_raw: [u8; 12],
|
|
|
|
|
/// Original flags.
|
|
|
|
|
pub flags: i32,
|
|
|
|
|
/// Packing method.
|
|
|
|
|
pub method: RsliMethod,
|
|
|
|
|
/// Effective payload offset after overlay.
|
|
|
|
|
pub data_offset: u64,
|
|
|
|
|
/// Declared packed size.
|
|
|
|
|
pub packed_size: u32,
|
|
|
|
|
/// Declared unpacked size.
|
|
|
|
|
pub unpacked_size: u32,
|
|
|
|
|
/// Sort table value.
|
|
|
|
|
pub sort_to_original: i16,
|
|
|
|
|
/// Raw data offset stored in the table.
|
|
|
|
|
pub data_offset_raw: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parsed `RsLi` document.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct RsliDocument {
|
|
|
|
|
bytes: Arc<[u8]>,
|
|
|
|
|
header: RsliHeader,
|
|
|
|
|
ao_trailer: Option<AoTrailer>,
|
|
|
|
|
entries: Vec<EntryMeta>,
|
|
|
|
|
records: Vec<EntryRecord>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Packed resource bytes and metadata.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct PackedResource {
|
|
|
|
|
/// Entry metadata.
|
|
|
|
|
pub meta: EntryMeta,
|
|
|
|
|
/// Packed bytes as stored in the archive.
|
|
|
|
|
pub packed: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `RsLi` parse or decode error.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum RsliError {
|
|
|
|
|
/// Invalid magic.
|
|
|
|
|
InvalidMagic {
|
|
|
|
|
/// Observed magic.
|
|
|
|
|
got: [u8; 2],
|
|
|
|
|
},
|
|
|
|
|
/// Reserved header byte has an unexpected value.
|
|
|
|
|
InvalidReserved {
|
|
|
|
|
/// Observed reserved byte.
|
|
|
|
|
got: u8,
|
|
|
|
|
},
|
|
|
|
|
/// Unsupported version.
|
|
|
|
|
UnsupportedVersion {
|
|
|
|
|
/// Observed version.
|
|
|
|
|
got: u8,
|
|
|
|
|
},
|
|
|
|
|
/// Invalid entry count.
|
|
|
|
|
InvalidEntryCount {
|
|
|
|
|
/// Observed signed count.
|
|
|
|
|
got: i16,
|
|
|
|
|
},
|
|
|
|
|
/// Too many entries for stable ids.
|
|
|
|
|
TooManyEntries {
|
|
|
|
|
/// Observed count.
|
|
|
|
|
got: usize,
|
|
|
|
|
},
|
|
|
|
|
/// Entry table is outside the archive.
|
|
|
|
|
EntryTableOutOfBounds {
|
|
|
|
|
/// Table byte offset.
|
|
|
|
|
table_offset: u64,
|
|
|
|
|
/// Table byte length.
|
|
|
|
|
table_len: u64,
|
|
|
|
|
/// Archive byte length.
|
|
|
|
|
file_len: u64,
|
|
|
|
|
},
|
|
|
|
|
/// Entry table is structurally corrupt.
|
|
|
|
|
CorruptEntryTable(&'static str),
|
|
|
|
|
/// Entry id is outside this archive.
|
|
|
|
|
EntryIdOutOfRange {
|
|
|
|
|
/// Entry id.
|
|
|
|
|
id: u32,
|
|
|
|
|
/// Entry count.
|
|
|
|
|
entry_count: u32,
|
|
|
|
|
},
|
|
|
|
|
/// Entry payload is outside the archive.
|
|
|
|
|
EntryDataOutOfBounds {
|
|
|
|
|
/// Entry id.
|
|
|
|
|
id: u32,
|
|
|
|
|
/// Payload offset.
|
|
|
|
|
offset: u64,
|
|
|
|
|
/// Payload declared size.
|
|
|
|
|
size: u32,
|
|
|
|
|
/// Archive byte length.
|
|
|
|
|
file_len: u64,
|
|
|
|
|
},
|
|
|
|
|
/// `AO` media overlay points outside the archive.
|
|
|
|
|
MediaOverlayOutOfBounds {
|
|
|
|
|
/// Overlay byte offset.
|
|
|
|
|
overlay: u32,
|
|
|
|
|
/// Archive byte length.
|
|
|
|
|
file_len: u64,
|
|
|
|
|
},
|
2026-06-22 16:58:59 +04:00
|
|
|
/// Registered `AO` overlay is rejected by the selected profile.
|
|
|
|
|
AoTrailerQuirkRejected {
|
|
|
|
|
/// Overlay byte offset.
|
|
|
|
|
overlay: u32,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Unsupported packing method.
|
|
|
|
|
UnsupportedMethod {
|
|
|
|
|
/// Raw method bits.
|
|
|
|
|
raw: u32,
|
|
|
|
|
},
|
|
|
|
|
/// Packed range ends past EOF.
|
|
|
|
|
PackedSizePastEof {
|
|
|
|
|
/// Entry id.
|
|
|
|
|
id: u32,
|
|
|
|
|
/// Payload offset.
|
|
|
|
|
offset: u64,
|
|
|
|
|
/// Declared packed size.
|
|
|
|
|
packed_size: u32,
|
|
|
|
|
/// Archive byte length.
|
|
|
|
|
file_len: u64,
|
|
|
|
|
},
|
|
|
|
|
/// Registered retail quirk is rejected by the selected profile.
|
|
|
|
|
DeflateEofPlusOneQuirkRejected {
|
|
|
|
|
/// Entry id.
|
|
|
|
|
id: u32,
|
|
|
|
|
},
|
|
|
|
|
/// Payload decompression failed.
|
|
|
|
|
DecompressionFailed(&'static str),
|
|
|
|
|
/// Decoded payload size does not match the declared size.
|
|
|
|
|
OutputSizeMismatch {
|
|
|
|
|
/// Expected decoded size.
|
|
|
|
|
expected: u32,
|
|
|
|
|
/// Observed decoded size.
|
|
|
|
|
got: u32,
|
|
|
|
|
},
|
|
|
|
|
/// Integer conversion or arithmetic overflow.
|
|
|
|
|
IntegerOverflow,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for RsliError {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
|
|
|
|
|
Self::InvalidReserved { got } => write!(f, "invalid RsLi reserved byte: {got:#x}"),
|
|
|
|
|
Self::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
|
|
|
|
|
Self::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
|
|
|
|
|
Self::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
|
|
|
|
|
Self::EntryTableOutOfBounds {
|
|
|
|
|
table_offset,
|
|
|
|
|
table_len,
|
|
|
|
|
file_len,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
|
|
|
|
|
),
|
|
|
|
|
Self::CorruptEntryTable(message) => write!(f, "corrupt entry table: {message}"),
|
|
|
|
|
Self::EntryIdOutOfRange { id, entry_count } => {
|
|
|
|
|
write!(f, "RsLi entry id out of range: {id} >= {entry_count}")
|
|
|
|
|
}
|
|
|
|
|
Self::EntryDataOutOfBounds {
|
|
|
|
|
id,
|
|
|
|
|
offset,
|
|
|
|
|
size,
|
|
|
|
|
file_len,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
|
|
|
|
|
),
|
|
|
|
|
Self::MediaOverlayOutOfBounds { overlay, file_len } => {
|
|
|
|
|
write!(
|
|
|
|
|
f,
|
|
|
|
|
"media overlay out of bounds: overlay={overlay}, file={file_len}"
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-22 16:58:59 +04:00
|
|
|
Self::AoTrailerQuirkRejected { overlay } => {
|
|
|
|
|
write!(f, "AO trailer quirk rejected: overlay={overlay}")
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
|
|
|
|
|
Self::PackedSizePastEof {
|
|
|
|
|
id,
|
|
|
|
|
offset,
|
|
|
|
|
packed_size,
|
|
|
|
|
file_len,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
|
|
|
|
|
),
|
|
|
|
|
Self::DeflateEofPlusOneQuirkRejected { id } => {
|
|
|
|
|
write!(f, "deflate EOF+1 quirk rejected for entry {id}")
|
|
|
|
|
}
|
|
|
|
|
Self::DecompressionFailed(message) => write!(f, "decompression failed: {message}"),
|
|
|
|
|
Self::OutputSizeMismatch { expected, got } => {
|
|
|
|
|
write!(f, "output size mismatch: expected={expected}, got={got}")
|
|
|
|
|
}
|
|
|
|
|
Self::IntegerOverflow => write!(f, "integer overflow"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::error::Error for RsliError {}
|
|
|
|
|
|
|
|
|
|
/// Decodes an `RsLi` document.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`RsliError`] when the header, table, payload ranges, registered
|
|
|
|
|
/// compatibility quirks, or packed payloads are invalid for the selected
|
|
|
|
|
/// profile.
|
|
|
|
|
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
2026-06-22 16:58:59 +04:00
|
|
|
decode_with_profile(bytes, profile.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decodes an `RsLi` document with explicit compatibility switches.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`RsliError`] when the header, table, payload ranges, registered
|
|
|
|
|
/// compatibility quirks, or packed payloads are invalid for the selected
|
|
|
|
|
/// profile.
|
|
|
|
|
pub fn decode_with_profile(
|
|
|
|
|
bytes: Arc<[u8]>,
|
|
|
|
|
profile: RsliReadProfile,
|
|
|
|
|
) -> Result<RsliDocument, RsliError> {
|
2026-06-22 13:12:27 +04:00
|
|
|
let options = match profile {
|
2026-06-22 16:58:59 +04:00
|
|
|
RsliReadProfile::Strict => ParseOptions {
|
2026-06-22 13:12:27 +04:00
|
|
|
allow_ao_trailer: false,
|
|
|
|
|
allow_deflate_eof_plus_one: false,
|
|
|
|
|
allow_invalid_presorted_fallback: false,
|
|
|
|
|
},
|
2026-06-22 16:58:59 +04:00
|
|
|
RsliReadProfile::Compatible(profile) => ParseOptions {
|
|
|
|
|
allow_ao_trailer: profile.allow_ao_trailer,
|
|
|
|
|
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
|
|
|
|
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
let ParsedRsli {
|
|
|
|
|
header,
|
|
|
|
|
ao_trailer,
|
|
|
|
|
records,
|
|
|
|
|
} = parse_rsli(&bytes, options)?;
|
|
|
|
|
let entries = records.iter().map(|record| record.meta.clone()).collect();
|
|
|
|
|
Ok(RsliDocument {
|
|
|
|
|
bytes,
|
|
|
|
|
header,
|
|
|
|
|
ao_trailer,
|
|
|
|
|
entries,
|
|
|
|
|
records,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RsliDocument {
|
|
|
|
|
/// Header summary.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn header(&self) -> &RsliHeader {
|
|
|
|
|
&self.header
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optional `AO` trailer.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
|
|
|
|
|
self.ao_trailer.as_ref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Entry count.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn entry_count(&self) -> usize {
|
|
|
|
|
self.entries.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Entries in original table order.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn entries(&self) -> &[EntryMeta] {
|
|
|
|
|
&self.entries
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Finds an entry by name.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn find(&self, name: &str) -> Option<EntryId> {
|
|
|
|
|
self.find_bytes(name.as_bytes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Finds an entry by raw ASCII-case-insensitive name bytes.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn find_bytes(&self, name: &[u8]) -> Option<EntryId> {
|
|
|
|
|
let len = name
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|byte| *byte == 0)
|
|
|
|
|
.unwrap_or(name.len());
|
|
|
|
|
let query = name[..len]
|
|
|
|
|
.iter()
|
|
|
|
|
.map(u8::to_ascii_uppercase)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
self.find_impl(&query)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns an entry by id.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn entry(&self, id: EntryId) -> Option<&EntryMeta> {
|
|
|
|
|
self.entries.get(usize::try_from(id.0).ok()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads and unpacks an entry.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
|
|
|
|
|
/// be decoded to the declared size.
|
|
|
|
|
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let record = self.record_by_id(id)?;
|
|
|
|
|
let packed = self.packed_slice(id, record)?;
|
|
|
|
|
decode_payload(
|
|
|
|
|
packed,
|
|
|
|
|
record.meta.method,
|
|
|
|
|
record.key16,
|
|
|
|
|
record.meta.unpacked_size,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns packed bytes and public metadata.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`RsliError`] when `id` is invalid or the packed range is outside
|
|
|
|
|
/// the archive.
|
|
|
|
|
pub fn load_packed(&self, id: EntryId) -> Result<PackedResource, RsliError> {
|
|
|
|
|
let record = self.record_by_id(id)?;
|
|
|
|
|
let packed = self.packed_slice(id, record)?.to_vec();
|
|
|
|
|
Ok(PackedResource {
|
|
|
|
|
meta: record.meta.clone(),
|
|
|
|
|
packed,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Encodes the document according to the selected profile.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
|
|
|
|
|
match profile {
|
|
|
|
|
WriteProfile::Lossless => self.bytes.to_vec(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RsliDocument {
|
|
|
|
|
fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
|
|
|
|
|
let mut low = 0usize;
|
|
|
|
|
let mut high = self.records.len();
|
|
|
|
|
while low < high {
|
|
|
|
|
let mid = low + (high - low) / 2;
|
|
|
|
|
let original = self.records.get(mid)?.meta.sort_to_original;
|
|
|
|
|
if original < 0 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let original = usize::try_from(original).ok()?;
|
|
|
|
|
let record = self.records.get(original)?;
|
|
|
|
|
match cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw)) {
|
|
|
|
|
std::cmp::Ordering::Less => high = mid,
|
|
|
|
|
std::cmp::Ordering::Greater => low = mid + 1,
|
|
|
|
|
std::cmp::Ordering::Equal => return Some(EntryId(u32::try_from(original).ok()?)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.records.iter().enumerate().find_map(|(idx, record)| {
|
|
|
|
|
if cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw))
|
|
|
|
|
== std::cmp::Ordering::Equal
|
|
|
|
|
{
|
|
|
|
|
Some(EntryId(u32::try_from(idx).ok()?))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record_by_id(&self, id: EntryId) -> Result<&EntryRecord, RsliError> {
|
|
|
|
|
let idx = usize::try_from(id.0).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
self.records
|
|
|
|
|
.get(idx)
|
|
|
|
|
.ok_or_else(|| RsliError::EntryIdOutOfRange {
|
|
|
|
|
id: id.0,
|
|
|
|
|
entry_count: saturating_u32_len(self.records.len()),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn packed_slice<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
id: EntryId,
|
|
|
|
|
record: &EntryRecord,
|
|
|
|
|
) -> Result<&'a [u8], RsliError> {
|
|
|
|
|
let end = record
|
|
|
|
|
.effective_offset
|
|
|
|
|
.checked_add(record.packed_size_available)
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
self.bytes
|
|
|
|
|
.get(record.effective_offset..end)
|
|
|
|
|
.ok_or(RsliError::EntryDataOutOfBounds {
|
|
|
|
|
id: id.0,
|
|
|
|
|
offset: u64::try_from(record.effective_offset).unwrap_or(u64::MAX),
|
|
|
|
|
size: record.packed_size_declared,
|
|
|
|
|
file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
struct ParseOptions {
|
|
|
|
|
allow_ao_trailer: bool,
|
|
|
|
|
allow_deflate_eof_plus_one: bool,
|
|
|
|
|
allow_invalid_presorted_fallback: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
struct ParsedRsli {
|
|
|
|
|
header: RsliHeader,
|
|
|
|
|
ao_trailer: Option<AoTrailer>,
|
|
|
|
|
records: Vec<EntryRecord>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
struct EntryRecord {
|
|
|
|
|
meta: EntryMeta,
|
|
|
|
|
key16: u16,
|
|
|
|
|
packed_size_declared: u32,
|
|
|
|
|
packed_size_available: usize,
|
|
|
|
|
effective_offset: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
|
|
|
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
|
|
|
|
|
if bytes.len() < 32 {
|
|
|
|
|
return Err(RsliError::EntryTableOutOfBounds {
|
|
|
|
|
table_offset: 32,
|
|
|
|
|
table_len: 0,
|
|
|
|
|
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut header_raw = [0u8; 32];
|
|
|
|
|
header_raw.copy_from_slice(&bytes[0..32]);
|
|
|
|
|
|
|
|
|
|
let mut magic = [0u8; 2];
|
|
|
|
|
magic.copy_from_slice(&bytes[0..2]);
|
|
|
|
|
if &magic != b"NL" {
|
|
|
|
|
return Err(RsliError::InvalidMagic { got: magic });
|
|
|
|
|
}
|
|
|
|
|
let reserved = bytes[2];
|
|
|
|
|
if reserved != 0 {
|
|
|
|
|
return Err(RsliError::InvalidReserved { got: reserved });
|
|
|
|
|
}
|
|
|
|
|
let version = bytes[3];
|
|
|
|
|
if version != 0x01 {
|
|
|
|
|
return Err(RsliError::UnsupportedVersion { got: version });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let entry_count_signed = i16::from_le_bytes([bytes[4], bytes[5]]);
|
|
|
|
|
if entry_count_signed < 0 {
|
|
|
|
|
return Err(RsliError::InvalidEntryCount {
|
|
|
|
|
got: entry_count_signed,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let count = usize::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
|
|
|
|
|
return Err(RsliError::TooManyEntries { got: count });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
|
|
|
|
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
|
|
|
|
let header = RsliHeader {
|
|
|
|
|
raw: header_raw,
|
|
|
|
|
version,
|
|
|
|
|
entry_count: u16::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
presorted_flag,
|
|
|
|
|
xor_seed,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let table_len = count.checked_mul(32).ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
let table_end = 32usize
|
|
|
|
|
.checked_add(table_len)
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
if table_end > bytes.len() {
|
|
|
|
|
return Err(RsliError::EntryTableOutOfBounds {
|
|
|
|
|
table_offset: 32,
|
|
|
|
|
table_len: u64::try_from(table_len).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let table_plain = xor_stream(&bytes[32..table_end], (xor_seed & 0xFFFF) as u16);
|
|
|
|
|
if table_plain.len() != table_len {
|
|
|
|
|
return Err(RsliError::CorruptEntryTable(
|
|
|
|
|
"entry table decrypt length mismatch",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (overlay, trailer_raw) = parse_ao_trailer(bytes, options.allow_ao_trailer)?;
|
|
|
|
|
|
|
|
|
|
let mut records = Vec::with_capacity(count);
|
|
|
|
|
for idx in 0..count {
|
|
|
|
|
let row = &table_plain[idx * 32..(idx + 1) * 32];
|
|
|
|
|
let mut name_raw = [0u8; 12];
|
|
|
|
|
name_raw.copy_from_slice(&row[0..12]);
|
|
|
|
|
|
|
|
|
|
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
|
|
|
|
let mut sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
|
|
|
|
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
|
|
|
|
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
|
|
|
|
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
|
|
|
|
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
|
|
|
|
|
let method = parse_method(method_raw);
|
|
|
|
|
|
|
|
|
|
let effective_offset_u64 = u64::from(data_offset_raw)
|
|
|
|
|
.checked_add(u64::from(overlay))
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
let effective_offset =
|
|
|
|
|
usize::try_from(effective_offset_u64).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
let mut packed_size_available =
|
|
|
|
|
usize::try_from(packed_size_declared).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
let end = effective_offset_u64
|
|
|
|
|
.checked_add(u64::from(packed_size_declared))
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
let file_len = u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
|
|
|
|
|
if end > file_len {
|
|
|
|
|
if method_raw == 0x100 && end == file_len + 1 {
|
|
|
|
|
if options.allow_deflate_eof_plus_one
|
|
|
|
|
&& is_registered_deflate_eof_plus_one_quirk(&name_raw)
|
|
|
|
|
{
|
|
|
|
|
packed_size_available = packed_size_available
|
|
|
|
|
.checked_sub(1)
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
} else {
|
|
|
|
|
return Err(RsliError::DeflateEofPlusOneQuirkRejected {
|
|
|
|
|
id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return Err(RsliError::PackedSizePastEof {
|
|
|
|
|
id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
offset: effective_offset_u64,
|
|
|
|
|
packed_size: packed_size_declared,
|
|
|
|
|
file_len,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let available_end = effective_offset
|
|
|
|
|
.checked_add(packed_size_available)
|
|
|
|
|
.ok_or(RsliError::IntegerOverflow)?;
|
|
|
|
|
if available_end > bytes.len() {
|
|
|
|
|
return Err(RsliError::EntryDataOutOfBounds {
|
|
|
|
|
id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
offset: effective_offset_u64,
|
|
|
|
|
size: packed_size_declared,
|
|
|
|
|
file_len,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if presorted_flag != 0xABBA {
|
|
|
|
|
sort_to_original = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
records.push(EntryRecord {
|
|
|
|
|
meta: EntryMeta {
|
|
|
|
|
name: decode_name(c_name_bytes(&name_raw)),
|
|
|
|
|
name_raw,
|
|
|
|
|
flags: i32::from(flags_signed),
|
|
|
|
|
method,
|
|
|
|
|
data_offset: effective_offset_u64,
|
|
|
|
|
packed_size: packed_size_declared,
|
|
|
|
|
unpacked_size,
|
|
|
|
|
sort_to_original,
|
|
|
|
|
data_offset_raw,
|
|
|
|
|
},
|
|
|
|
|
key16: sort_to_original.cast_unsigned(),
|
|
|
|
|
packed_size_declared,
|
|
|
|
|
packed_size_available,
|
|
|
|
|
effective_offset,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if presorted_flag == 0xABBA {
|
|
|
|
|
if validate_permutation(&records).is_err() {
|
|
|
|
|
if !options.allow_invalid_presorted_fallback {
|
|
|
|
|
validate_permutation(&records)?;
|
|
|
|
|
}
|
|
|
|
|
rebuild_sorted_mapping(&mut records)?;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
rebuild_sorted_mapping(&mut records)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(ParsedRsli {
|
|
|
|
|
header,
|
|
|
|
|
ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
|
|
|
|
|
records,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError> {
|
|
|
|
|
let mut sorted: Vec<usize> = (0..records.len()).collect();
|
|
|
|
|
sorted.sort_by(|a, b| {
|
|
|
|
|
cmp_c_string(
|
|
|
|
|
c_name_bytes(&records[*a].meta.name_raw),
|
|
|
|
|
c_name_bytes(&records[*b].meta.name_raw),
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
for (idx, record) in records.iter_mut().enumerate() {
|
|
|
|
|
record.meta.sort_to_original =
|
|
|
|
|
i16::try_from(sorted[idx]).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
record.key16 = record.meta.sort_to_original.cast_unsigned();
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
|
2026-06-22 16:58:59 +04:00
|
|
|
if bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
2026-06-22 13:12:27 +04:00
|
|
|
return Ok((0, None));
|
|
|
|
|
}
|
|
|
|
|
let mut raw = [0u8; 6];
|
|
|
|
|
raw.copy_from_slice(&bytes[bytes.len() - 6..]);
|
|
|
|
|
let overlay = u32::from_le_bytes([raw[2], raw[3], raw[4], raw[5]]);
|
|
|
|
|
if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)? {
|
|
|
|
|
return Err(RsliError::MediaOverlayOutOfBounds {
|
|
|
|
|
overlay,
|
|
|
|
|
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-22 16:58:59 +04:00
|
|
|
if !allow {
|
|
|
|
|
return Err(RsliError::AoTrailerQuirkRejected { overlay });
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
Ok((overlay, Some(raw)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
|
|
|
|
|
let mut seen = vec![false; records.len()];
|
|
|
|
|
for record in records {
|
|
|
|
|
let idx = i32::from(record.meta.sort_to_original);
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
return Err(RsliError::CorruptEntryTable(
|
|
|
|
|
"sort_to_original is not a valid permutation index",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let idx = usize::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
if idx >= records.len() || seen[idx] {
|
|
|
|
|
return Err(RsliError::CorruptEntryTable(
|
|
|
|
|
"sort_to_original is not a permutation",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
seen[idx] = true;
|
|
|
|
|
}
|
|
|
|
|
if seen.iter().any(|value| !*value) {
|
|
|
|
|
return Err(RsliError::CorruptEntryTable(
|
|
|
|
|
"sort_to_original is not a permutation",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_method(raw: u32) -> RsliMethod {
|
|
|
|
|
match raw {
|
|
|
|
|
0x000 => RsliMethod::Stored,
|
|
|
|
|
0x020 => RsliMethod::XorOnly,
|
|
|
|
|
0x040 => RsliMethod::Lzss,
|
|
|
|
|
0x060 => RsliMethod::XorLzss,
|
|
|
|
|
0x080 => RsliMethod::AdaptiveLzss,
|
|
|
|
|
0x0A0 => RsliMethod::XorAdaptiveLzss,
|
|
|
|
|
0x100 => RsliMethod::RawDeflate,
|
|
|
|
|
other => RsliMethod::Unknown(other),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_registered_deflate_eof_plus_one_quirk(name_raw: &[u8; 12]) -> bool {
|
|
|
|
|
c_name_bytes(name_raw)
|
|
|
|
|
.iter()
|
|
|
|
|
.map(u8::to_ascii_uppercase)
|
|
|
|
|
.eq(b"INTERF8.TEX".iter().copied())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_name(name: &[u8]) -> String {
|
|
|
|
|
name.iter().map(|byte| char::from(*byte)).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
|
|
|
|
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
|
|
|
|
&raw[..len]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn cmp_c_string(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
|
|
|
|
|
let min_len = a.len().min(b.len());
|
|
|
|
|
for idx in 0..min_len {
|
|
|
|
|
if a[idx] != b[idx] {
|
|
|
|
|
return a[idx].cmp(&b[idx]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
a.len().cmp(&b.len())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_payload(
|
|
|
|
|
packed: &[u8],
|
|
|
|
|
method: RsliMethod,
|
|
|
|
|
key16: u16,
|
|
|
|
|
unpacked_size: u32,
|
|
|
|
|
) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
let out = match method {
|
|
|
|
|
RsliMethod::Stored => {
|
|
|
|
|
if packed.len() < expected {
|
|
|
|
|
return Err(RsliError::OutputSizeMismatch {
|
|
|
|
|
expected: unpacked_size,
|
|
|
|
|
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
packed[..expected].to_vec()
|
|
|
|
|
}
|
|
|
|
|
RsliMethod::XorOnly => {
|
|
|
|
|
if packed.len() < expected {
|
|
|
|
|
return Err(RsliError::OutputSizeMismatch {
|
|
|
|
|
expected: unpacked_size,
|
|
|
|
|
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
xor_stream(&packed[..expected], key16)
|
|
|
|
|
}
|
|
|
|
|
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
|
|
|
|
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
|
|
|
|
|
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
|
|
|
|
|
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
|
|
|
|
|
RsliMethod::RawDeflate => decode_deflate(packed)?,
|
|
|
|
|
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
|
|
|
|
|
};
|
|
|
|
|
if out.len() != expected {
|
|
|
|
|
return Err(RsliError::OutputSizeMismatch {
|
|
|
|
|
expected: unpacked_size,
|
|
|
|
|
got: u32::try_from(out.len()).unwrap_or(u32::MAX),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
struct XorState {
|
|
|
|
|
lo: u8,
|
|
|
|
|
hi: u8,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl XorState {
|
|
|
|
|
fn new(key16: u16) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
lo: u8::try_from(key16 & 0xFF).unwrap_or(u8::MAX),
|
|
|
|
|
hi: u8::try_from((key16 >> 8) & 0xFF).unwrap_or(u8::MAX),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
|
|
|
|
|
self.lo = self.hi ^ self.lo.wrapping_shl(1);
|
|
|
|
|
let decrypted = encrypted ^ self.lo;
|
|
|
|
|
self.hi = self.lo ^ (self.hi >> 1);
|
|
|
|
|
decrypted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
|
|
|
|
|
let mut state = XorState::new(key16);
|
|
|
|
|
data.iter().map(|byte| state.decrypt_byte(*byte)).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn lzss_decompress_simple(
|
|
|
|
|
data: &[u8],
|
|
|
|
|
expected_size: usize,
|
|
|
|
|
xor_key: Option<u16>,
|
|
|
|
|
) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let mut ring = [0x20u8; 0x1000];
|
|
|
|
|
let mut ring_pos = 0xFEEusize;
|
|
|
|
|
let mut out = Vec::with_capacity(expected_size);
|
|
|
|
|
let mut in_pos = 0usize;
|
|
|
|
|
let mut control = 0u8;
|
|
|
|
|
let mut bits_left = 0u8;
|
|
|
|
|
let mut xor_state = xor_key.map(XorState::new);
|
|
|
|
|
|
|
|
|
|
while out.len() < expected_size {
|
|
|
|
|
if bits_left == 0 {
|
|
|
|
|
control = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
|
|
|
|
|
RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
|
|
|
|
|
)?;
|
|
|
|
|
in_pos = in_pos.saturating_add(1);
|
|
|
|
|
bits_left = 8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (control & 1) != 0 {
|
|
|
|
|
let byte = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
|
|
|
|
|
RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
|
|
|
|
|
)?;
|
|
|
|
|
in_pos = in_pos.saturating_add(1);
|
|
|
|
|
out.push(byte);
|
|
|
|
|
ring[ring_pos] = byte;
|
|
|
|
|
ring_pos = (ring_pos + 1) & 0x0FFF;
|
|
|
|
|
} else {
|
|
|
|
|
let low = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
|
|
|
|
|
RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
|
|
|
|
|
)?;
|
|
|
|
|
let high = read_packed_byte(data, in_pos.saturating_add(1), &mut xor_state).ok_or(
|
|
|
|
|
RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
|
|
|
|
|
)?;
|
|
|
|
|
in_pos = in_pos.saturating_add(2);
|
|
|
|
|
let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
|
|
|
|
|
let length = usize::from((high & 0x0F) + 3);
|
|
|
|
|
for step in 0..length {
|
|
|
|
|
let byte = ring[(offset + step) & 0x0FFF];
|
|
|
|
|
out.push(byte);
|
|
|
|
|
ring[ring_pos] = byte;
|
|
|
|
|
ring_pos = (ring_pos + 1) & 0x0FFF;
|
|
|
|
|
if out.len() >= expected_size {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
control >>= 1;
|
|
|
|
|
bits_left -= 1;
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Option<u8> {
|
|
|
|
|
let encrypted = data.get(pos).copied()?;
|
|
|
|
|
Some(if let Some(state) = state {
|
|
|
|
|
state.decrypt_byte(encrypted)
|
|
|
|
|
} else {
|
|
|
|
|
encrypted
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let mut decoder = flate2::read::DeflateDecoder::new(packed);
|
|
|
|
|
decoder
|
|
|
|
|
.read_to_end(&mut out)
|
|
|
|
|
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LZH_N: usize = 4096;
|
|
|
|
|
const LZH_F: usize = 60;
|
|
|
|
|
const LZH_THRESHOLD: usize = 2;
|
|
|
|
|
const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
|
|
|
|
|
const LZH_T: usize = LZH_N_CHAR * 2 - 1;
|
|
|
|
|
const LZH_R: usize = LZH_T - 1;
|
|
|
|
|
const LZH_MAX_FREQ: u16 = 0x8000;
|
|
|
|
|
|
|
|
|
|
fn lzss_huffman_decompress(
|
|
|
|
|
data: &[u8],
|
|
|
|
|
expected_size: usize,
|
|
|
|
|
xor_key: Option<u16>,
|
|
|
|
|
) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let mut decoder = LzhDecoder::new(data, xor_key);
|
|
|
|
|
decoder.decode(expected_size)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LzhDecoder<'a> {
|
|
|
|
|
bit_reader: BitReader<'a>,
|
|
|
|
|
text: [u8; LZH_N],
|
|
|
|
|
freq: [u16; LZH_T + 1],
|
|
|
|
|
parent: [usize; LZH_T + LZH_N_CHAR],
|
|
|
|
|
son: [usize; LZH_T],
|
|
|
|
|
d_code: [u8; 256],
|
|
|
|
|
d_len: [u8; 256],
|
|
|
|
|
ring_pos: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> LzhDecoder<'a> {
|
|
|
|
|
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
|
|
|
|
let mut decoder = Self {
|
|
|
|
|
bit_reader: BitReader::new(data, xor_key),
|
|
|
|
|
text: [0x20u8; LZH_N],
|
|
|
|
|
freq: [0u16; LZH_T + 1],
|
|
|
|
|
parent: [0usize; LZH_T + LZH_N_CHAR],
|
|
|
|
|
son: [0usize; LZH_T],
|
|
|
|
|
d_code: [0u8; 256],
|
|
|
|
|
d_len: [0u8; 256],
|
|
|
|
|
ring_pos: LZH_N - LZH_F,
|
|
|
|
|
};
|
|
|
|
|
decoder.init_tables();
|
|
|
|
|
decoder.start_huff();
|
|
|
|
|
decoder
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>, RsliError> {
|
|
|
|
|
let mut out = Vec::with_capacity(expected_size);
|
|
|
|
|
while out.len() < expected_size {
|
|
|
|
|
let c = self.decode_char()?;
|
|
|
|
|
if c < 256 {
|
|
|
|
|
let byte = u8::try_from(c).map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
out.push(byte);
|
|
|
|
|
self.text[self.ring_pos] = byte;
|
|
|
|
|
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
|
|
|
|
} else {
|
|
|
|
|
let mut offset = self.decode_position()?;
|
|
|
|
|
offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
|
|
|
|
|
let mut length = c.saturating_sub(253);
|
|
|
|
|
while length > 0 && out.len() < expected_size {
|
|
|
|
|
let byte = self.text[offset];
|
|
|
|
|
out.push(byte);
|
|
|
|
|
self.text[self.ring_pos] = byte;
|
|
|
|
|
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
|
|
|
|
offset = (offset + 1) & (LZH_N - 1);
|
|
|
|
|
length -= 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn init_tables(&mut self) {
|
|
|
|
|
let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
|
|
|
|
|
let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
|
|
|
|
|
let mut group_index = 0u8;
|
|
|
|
|
let mut idx = 0usize;
|
|
|
|
|
let mut run = 32usize;
|
|
|
|
|
for count in d_code_group_counts {
|
|
|
|
|
for _ in 0..count {
|
|
|
|
|
for _ in 0..run {
|
|
|
|
|
self.d_code[idx] = group_index;
|
|
|
|
|
idx += 1;
|
|
|
|
|
}
|
|
|
|
|
group_index = group_index.wrapping_add(1);
|
|
|
|
|
}
|
|
|
|
|
run >>= 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut len = 3u8;
|
|
|
|
|
idx = 0;
|
|
|
|
|
for count in d_len_group_counts {
|
|
|
|
|
for _ in 0..count {
|
|
|
|
|
self.d_len[idx] = len;
|
|
|
|
|
idx += 1;
|
|
|
|
|
}
|
|
|
|
|
len = len.saturating_add(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn start_huff(&mut self) {
|
|
|
|
|
for i in 0..LZH_N_CHAR {
|
|
|
|
|
self.freq[i] = 1;
|
|
|
|
|
self.son[i] = i + LZH_T;
|
|
|
|
|
self.parent[i + LZH_T] = i;
|
|
|
|
|
}
|
|
|
|
|
let mut i = 0usize;
|
|
|
|
|
let mut j = LZH_N_CHAR;
|
|
|
|
|
while j <= LZH_R {
|
|
|
|
|
self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
|
|
|
|
|
self.son[j] = i;
|
|
|
|
|
self.parent[i] = j;
|
|
|
|
|
self.parent[i + 1] = j;
|
|
|
|
|
i += 2;
|
|
|
|
|
j += 1;
|
|
|
|
|
}
|
|
|
|
|
self.freq[LZH_T] = u16::MAX;
|
|
|
|
|
self.parent[LZH_R] = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_char(&mut self) -> Result<usize, RsliError> {
|
|
|
|
|
let mut node = self.son[LZH_R];
|
|
|
|
|
while node < LZH_T {
|
|
|
|
|
let bit = usize::from(self.bit_reader.read_bit()?);
|
|
|
|
|
let branch = node
|
|
|
|
|
.checked_add(bit)
|
|
|
|
|
.ok_or(RsliError::DecompressionFailed("lzss-huffman tree overflow"))?;
|
|
|
|
|
node = *self.son.get(branch).ok_or(RsliError::DecompressionFailed(
|
|
|
|
|
"lzss-huffman tree out of bounds",
|
|
|
|
|
))?;
|
|
|
|
|
}
|
|
|
|
|
let c = node - LZH_T;
|
|
|
|
|
self.update(c);
|
|
|
|
|
Ok(c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_position(&mut self) -> Result<usize, RsliError> {
|
|
|
|
|
let i = usize::try_from(self.bit_reader.read_bits(8)?)
|
|
|
|
|
.map_err(|_| RsliError::IntegerOverflow)?;
|
|
|
|
|
let mut c = usize::from(self.d_code[i]) << 6;
|
|
|
|
|
let mut j = usize::from(self.d_len[i]).saturating_sub(2);
|
|
|
|
|
while j > 0 {
|
|
|
|
|
j -= 1;
|
|
|
|
|
c |= usize::from(self.bit_reader.read_bit()?) << j;
|
|
|
|
|
}
|
|
|
|
|
Ok(c | (i & 0x3F))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update(&mut self, c: usize) {
|
|
|
|
|
if self.freq[LZH_R] == LZH_MAX_FREQ {
|
|
|
|
|
self.reconstruct();
|
|
|
|
|
}
|
|
|
|
|
let mut current = self.parent[c + LZH_T];
|
|
|
|
|
loop {
|
|
|
|
|
self.freq[current] = self.freq[current].saturating_add(1);
|
|
|
|
|
let freq = self.freq[current];
|
|
|
|
|
if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
|
|
|
|
|
let mut swap_idx = current + 1;
|
|
|
|
|
while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
|
|
|
|
|
swap_idx += 1;
|
|
|
|
|
}
|
|
|
|
|
self.freq.swap(current, swap_idx);
|
|
|
|
|
let left = self.son[current];
|
|
|
|
|
let right = self.son[swap_idx];
|
|
|
|
|
self.son[current] = right;
|
|
|
|
|
self.son[swap_idx] = left;
|
|
|
|
|
self.parent[left] = swap_idx;
|
|
|
|
|
if left < LZH_T {
|
|
|
|
|
self.parent[left + 1] = swap_idx;
|
|
|
|
|
}
|
|
|
|
|
self.parent[right] = current;
|
|
|
|
|
if right < LZH_T {
|
|
|
|
|
self.parent[right + 1] = current;
|
|
|
|
|
}
|
|
|
|
|
current = swap_idx;
|
|
|
|
|
}
|
|
|
|
|
current = self.parent[current];
|
|
|
|
|
if current == 0 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reconstruct(&mut self) {
|
|
|
|
|
let mut j = 0usize;
|
|
|
|
|
for i in 0..LZH_T {
|
|
|
|
|
if self.son[i] >= LZH_T {
|
|
|
|
|
self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
|
|
|
|
|
self.son[j] = self.son[i];
|
|
|
|
|
j += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut i = 0usize;
|
|
|
|
|
let mut current = LZH_N_CHAR;
|
|
|
|
|
while current < LZH_T {
|
|
|
|
|
let sum = self.freq[i].saturating_add(self.freq[i + 1]);
|
|
|
|
|
self.freq[current] = sum;
|
|
|
|
|
let mut insert_at = current;
|
|
|
|
|
while insert_at > 0 && sum < self.freq[insert_at - 1] {
|
|
|
|
|
insert_at -= 1;
|
|
|
|
|
}
|
|
|
|
|
for move_idx in (insert_at..current).rev() {
|
|
|
|
|
self.freq[move_idx + 1] = self.freq[move_idx];
|
|
|
|
|
self.son[move_idx + 1] = self.son[move_idx];
|
|
|
|
|
}
|
|
|
|
|
self.freq[insert_at] = sum;
|
|
|
|
|
self.son[insert_at] = i;
|
|
|
|
|
i += 2;
|
|
|
|
|
current += 1;
|
|
|
|
|
}
|
|
|
|
|
for idx in 0..LZH_T {
|
|
|
|
|
let node = self.son[idx];
|
|
|
|
|
self.parent[node] = idx;
|
|
|
|
|
if node < LZH_T {
|
|
|
|
|
self.parent[node + 1] = idx;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.freq[LZH_T] = u16::MAX;
|
|
|
|
|
self.parent[LZH_R] = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct BitReader<'a> {
|
|
|
|
|
data: &'a [u8],
|
|
|
|
|
byte_pos: usize,
|
|
|
|
|
bit_mask: u8,
|
|
|
|
|
current_byte: u8,
|
|
|
|
|
xor_state: Option<XorState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> BitReader<'a> {
|
|
|
|
|
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
data,
|
|
|
|
|
byte_pos: 0,
|
|
|
|
|
bit_mask: 0x80,
|
|
|
|
|
current_byte: 0,
|
|
|
|
|
xor_state: xor_key.map(XorState::new),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_bit(&mut self) -> Result<u8, RsliError> {
|
|
|
|
|
if self.bit_mask == 0x80 {
|
|
|
|
|
let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
|
|
|
|
|
return Err(RsliError::DecompressionFailed(
|
|
|
|
|
"lzss-huffman: unexpected EOF",
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
if let Some(state) = &mut self.xor_state {
|
|
|
|
|
byte = state.decrypt_byte(byte);
|
|
|
|
|
}
|
|
|
|
|
self.current_byte = byte;
|
|
|
|
|
}
|
|
|
|
|
let bit = u8::from((self.current_byte & self.bit_mask) != 0);
|
|
|
|
|
self.bit_mask >>= 1;
|
|
|
|
|
if self.bit_mask == 0 {
|
|
|
|
|
self.bit_mask = 0x80;
|
|
|
|
|
self.byte_pos = self.byte_pos.saturating_add(1);
|
|
|
|
|
}
|
|
|
|
|
Ok(bit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_bits(&mut self, bits: usize) -> Result<u32, RsliError> {
|
|
|
|
|
let mut value = 0u32;
|
|
|
|
|
for _ in 0..bits {
|
|
|
|
|
value = (value << 1) | u32::from(self.read_bit()?);
|
|
|
|
|
}
|
|
|
|
|
Ok(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn saturating_u32_len(len: usize) -> u32 {
|
|
|
|
|
u32::try_from(len).unwrap_or(u32::MAX)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_minimal_empty_library() {
|
|
|
|
|
let bytes = synthetic_rsli(&[], false, 0x1234, None);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("minimal RsLi");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.entry_count(), 0);
|
|
|
|
|
assert_eq!(doc.header().raw[0..4], *b"NL\0\x01");
|
|
|
|
|
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_invalid_header_fields() {
|
|
|
|
|
let valid = synthetic_rsli(&[], false, 0, None);
|
|
|
|
|
|
|
|
|
|
let mut invalid_magic = valid.clone();
|
|
|
|
|
invalid_magic[0] = b'X';
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(invalid_magic), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::InvalidMagic { .. })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut invalid_reserved = valid.clone();
|
|
|
|
|
invalid_reserved[2] = 1;
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(invalid_reserved), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::InvalidReserved { got: 1 })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut invalid_version = valid.clone();
|
|
|
|
|
invalid_version[3] = 2;
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(invalid_version), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::UnsupportedVersion { got: 2 })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut invalid_count = valid;
|
|
|
|
|
invalid_count[4..6].copy_from_slice(&(-1i16).to_le_bytes());
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(invalid_count), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::InvalidEntryCount { got: -1 })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_entry_table_bounds() {
|
|
|
|
|
let mut bytes = synthetic_rsli(&[], false, 0, None);
|
|
|
|
|
bytes[4..6].copy_from_slice(&1i16.to_le_bytes());
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(bytes), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::EntryTableOutOfBounds { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn table_xor_transform_uses_known_vector() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
xor_stream(&[0x00, 0x01, 0x02, 0x03], 0x1234),
|
|
|
|
|
[0x7A, 0x86, 0xB2, 0x8C]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn table_xor_transform_is_symmetric() {
|
|
|
|
|
let plain = b"entry table bytes".to_vec();
|
|
|
|
|
let encrypted = xor_stream(&plain, 0x3456);
|
|
|
|
|
|
|
|
|
|
assert_ne!(encrypted, plain);
|
|
|
|
|
assert_eq!(xor_stream(&encrypted, 0x3456), plain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn table_xor_state_spans_entries() {
|
|
|
|
|
let rows = two_plain_rows_for_transform_test();
|
|
|
|
|
let whole_stream = xor_stream(&rows.concat(), 0x2468);
|
|
|
|
|
let row_reset = rows
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|row| xor_stream(row, 0x2468))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
assert_ne!(whole_stream, row_reset);
|
|
|
|
|
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"a"),
|
|
|
|
|
SyntheticEntry::stored(b"B", 1, b"b"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x2468,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("continuous table stream");
|
|
|
|
|
assert_eq!(doc.entry_count(), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn presorted_mapping_uses_valid_permutation() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::stored(b"B", 1, b"bee"),
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"aye"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x4321,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("valid presorted map");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
|
|
|
|
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
|
|
|
|
assert_eq!(doc.load(EntryId(1)).expect("A payload"), b"aye");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn compatible_profile_rebuilds_invalid_presorted_mapping() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::stored(b"B", 0, b"bee"),
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"aye"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x0102,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(bytes.clone()), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::CorruptEntryTable(_))
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
|
|
|
|
|
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
|
|
|
|
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:58:59 +04:00
|
|
|
#[test]
|
|
|
|
|
fn explicit_profile_controls_invalid_presorted_fallback() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::stored(b"B", 0, b"bee"),
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"aye"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x0102,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let profile = RsliCompatibilityProfile {
|
|
|
|
|
allow_invalid_presorted_fallback: false,
|
|
|
|
|
..RsliCompatibilityProfile::retail()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode_with_profile(
|
|
|
|
|
arc(bytes.clone()),
|
|
|
|
|
RsliReadProfile::compatible_with(profile)
|
|
|
|
|
),
|
|
|
|
|
Err(RsliError::CorruptEntryTable(_))
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let profile = RsliCompatibilityProfile {
|
|
|
|
|
allow_invalid_presorted_fallback: true,
|
|
|
|
|
..RsliCompatibilityProfile::none()
|
|
|
|
|
};
|
|
|
|
|
let doc = decode_with_profile(arc(bytes), RsliReadProfile::compatible_with(profile))
|
|
|
|
|
.expect("presorted fallback only");
|
|
|
|
|
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
|
|
|
|
fn stored_method_uses_exact_size() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::stored(b"A", 0, b"abc")],
|
|
|
|
|
true,
|
|
|
|
|
0x1111,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored entry");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("stored payload"), b"abc");
|
|
|
|
|
assert_eq!(doc.entry(EntryId(0)).expect("stored meta").packed_size, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn xor_only_method_uses_entry_key() {
|
|
|
|
|
let plain = b"secret".to_vec();
|
|
|
|
|
let packed = xor_stream(&plain, 1);
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed),
|
|
|
|
|
SyntheticEntry::stored(b"B", 0, b"plain"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x2222,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor entry");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("xor payload"), plain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn lzss_method_decodes_literals_references_and_wrap() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::with_payload(
|
|
|
|
|
b"LIT",
|
|
|
|
|
0x040,
|
|
|
|
|
0,
|
|
|
|
|
b"ABC",
|
|
|
|
|
vec![0b0000_0111, b'A', b'B', b'C'],
|
|
|
|
|
),
|
|
|
|
|
SyntheticEntry::with_payload(
|
|
|
|
|
b"WRAP",
|
|
|
|
|
0x040,
|
|
|
|
|
1,
|
|
|
|
|
b" ",
|
|
|
|
|
vec![0b0000_0000, 0xFF, 0xF1],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x1212,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("lzss archive");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("literal lzss"), b"ABC");
|
|
|
|
|
assert_eq!(doc.load(EntryId(1)).expect("wrapped reference"), b" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn xor_lzss_method_uses_entry_key() {
|
|
|
|
|
let plain_lzss = vec![0b0000_0111, b'X', b'Y', b'Z'];
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::with_payload(b"X", 0x060, 1, b"XYZ", xor_stream(&plain_lzss, 1)),
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"filler"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x3434,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor lzss archive");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("xor lzss"), b"XYZ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn adaptive_lzss_method_decodes_synthetic_vector() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
b"A",
|
|
|
|
|
0x080,
|
|
|
|
|
0,
|
|
|
|
|
b"t",
|
|
|
|
|
vec![0x00],
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("adaptive lzss archive");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("adaptive lzss"), b"t");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn xor_adaptive_lzss_method_decodes_synthetic_vector() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::with_payload(b"X", 0x0A0, 1, b"t", vec![0x02]),
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"filler"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x5656,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor adaptive lzss archive");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("xor adaptive lzss"), b"t");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn raw_deflate_method_expects_raw_stream_not_zlib_wrapper() {
|
|
|
|
|
let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w'];
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
b"RAW",
|
|
|
|
|
0x100,
|
|
|
|
|
0,
|
|
|
|
|
b"raw",
|
|
|
|
|
raw_deflate,
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("raw deflate archive");
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("raw deflate"), b"raw");
|
|
|
|
|
|
|
|
|
|
let zlib_wrapped = vec![
|
|
|
|
|
0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w', 0x02, 0x92, 0x01, 0x4B,
|
|
|
|
|
];
|
|
|
|
|
let wrapped = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
b"ZLIB",
|
|
|
|
|
0x100,
|
|
|
|
|
0,
|
|
|
|
|
b"raw",
|
|
|
|
|
zlib_wrapped,
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(wrapped), ReadProfile::Strict).expect("zlib wrapped archive");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
doc.load(EntryId(0)),
|
|
|
|
|
Err(RsliError::DecompressionFailed("deflate"))
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn named_deflate_eof_plus_one_quirk_accepts_only_approved_entry() {
|
|
|
|
|
let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w'];
|
|
|
|
|
let approved = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_declared_packed_size(
|
|
|
|
|
b"INTERF8.TEX",
|
|
|
|
|
0x100,
|
|
|
|
|
0,
|
|
|
|
|
b"raw",
|
|
|
|
|
raw_deflate.clone(),
|
|
|
|
|
u32::try_from(raw_deflate.len() + 1).expect("declared size"),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(approved.clone()), ReadProfile::Strict),
|
|
|
|
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
|
|
|
|
));
|
2026-06-22 16:58:59 +04:00
|
|
|
assert!(matches!(
|
|
|
|
|
decode_with_profile(
|
|
|
|
|
arc(approved.clone()),
|
|
|
|
|
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
|
|
|
|
|
allow_deflate_eof_plus_one: false,
|
|
|
|
|
..RsliCompatibilityProfile::retail()
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
|
|
|
|
));
|
2026-06-22 13:12:27 +04:00
|
|
|
let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
|
|
|
|
|
|
|
|
|
|
let unknown = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_declared_packed_size(
|
|
|
|
|
b"OTHER.TEX",
|
|
|
|
|
0x100,
|
|
|
|
|
0,
|
|
|
|
|
b"raw",
|
|
|
|
|
raw_deflate.clone(),
|
|
|
|
|
u32::try_from(raw_deflate.len() + 1).expect("declared size"),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(unknown), ReadProfile::Compatible),
|
|
|
|
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let plus_two = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_declared_packed_size(
|
|
|
|
|
b"INTERF8.TEX",
|
|
|
|
|
0x100,
|
|
|
|
|
0,
|
|
|
|
|
b"raw",
|
|
|
|
|
raw_deflate.clone(),
|
|
|
|
|
u32::try_from(raw_deflate.len() + 2).expect("declared size"),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(plus_two), ReadProfile::Compatible),
|
|
|
|
|
Err(RsliError::PackedSizePastEof { id: 0, .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_method_is_rejected_on_load() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
b"A",
|
|
|
|
|
0x1E0,
|
|
|
|
|
0,
|
|
|
|
|
b"abc",
|
|
|
|
|
b"abc".to_vec(),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("unknown method archive");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
doc.load(EntryId(0)),
|
|
|
|
|
Err(RsliError::UnsupportedMethod { raw: 0x1E0 })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decoded_size_mismatch_is_rejected() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
b"A",
|
|
|
|
|
0x000,
|
|
|
|
|
0,
|
|
|
|
|
b"abc",
|
|
|
|
|
b"ab".to_vec(),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
0,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("mismatched entry archive");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
doc.load(EntryId(0)),
|
|
|
|
|
Err(RsliError::OutputSizeMismatch {
|
|
|
|
|
expected: 3,
|
|
|
|
|
got: 2
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ao_overlay_adjusts_effective_offsets() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::stored(b"A", 0, b"media")],
|
|
|
|
|
true,
|
|
|
|
|
0x3333,
|
|
|
|
|
Some(4),
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-22 16:58:59 +04:00
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible).expect("AO overlay");
|
2026-06-22 13:12:27 +04:00
|
|
|
let meta = doc.entry(EntryId(0)).expect("AO meta");
|
|
|
|
|
assert_eq!(meta.data_offset, 64);
|
|
|
|
|
assert_eq!(meta.data_offset_raw, 60);
|
|
|
|
|
assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
|
2026-06-22 16:58:59 +04:00
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode_with_profile(
|
|
|
|
|
arc(bytes),
|
|
|
|
|
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
|
|
|
|
|
allow_ao_trailer: false,
|
|
|
|
|
..RsliCompatibilityProfile::retail()
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
|
|
|
|
|
));
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_ao_overlay_is_rejected() {
|
|
|
|
|
let mut bytes = synthetic_rsli(&[], false, 0, None);
|
|
|
|
|
bytes.extend_from_slice(b"AO");
|
|
|
|
|
bytes.extend_from_slice(&1000u32.to_le_bytes());
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode(arc(bytes), ReadProfile::Compatible),
|
|
|
|
|
Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:58:59 +04:00
|
|
|
#[test]
|
|
|
|
|
fn strict_profile_distinguishes_valid_ao_quirk_from_malformed_ao() {
|
|
|
|
|
let valid = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::stored(b"A", 0, b"media")],
|
|
|
|
|
true,
|
|
|
|
|
0x3333,
|
|
|
|
|
Some(4),
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode_with_profile(arc(valid), RsliReadProfile::strict()),
|
|
|
|
|
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut malformed = synthetic_rsli(&[], false, 0, None);
|
|
|
|
|
malformed.extend_from_slice(b"AO");
|
|
|
|
|
malformed.extend_from_slice(&1000u32.to_le_bytes());
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
decode_with_profile(arc(malformed), RsliReadProfile::strict()),
|
|
|
|
|
Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
|
|
|
|
fn unknown_header_bytes_are_lossless() {
|
|
|
|
|
let mut bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::stored(b"A", 0, b"abc")],
|
|
|
|
|
true,
|
|
|
|
|
0x4444,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
bytes[6] = 0xA5;
|
|
|
|
|
bytes[24] = 0x5A;
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("unknown header bytes");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.header().raw[6], 0xA5);
|
|
|
|
|
assert_eq!(doc.header().raw[24], 0x5A);
|
|
|
|
|
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn no_op_lossless_roundtrip_preserves_bytes() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[
|
|
|
|
|
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
|
|
|
|
SyntheticEntry::stored(b"B", 1, b"beta"),
|
|
|
|
|
],
|
|
|
|
|
true,
|
|
|
|
|
0x5555,
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("roundtrip archive");
|
|
|
|
|
|
|
|
|
|
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn generated_supported_methods_decode_expected_bytes() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()),
|
|
|
|
|
(
|
|
|
|
|
0x020,
|
|
|
|
|
b"XOR".as_slice(),
|
|
|
|
|
b"ok".as_slice(),
|
|
|
|
|
xor_stream(b"ok", 0),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
0x040,
|
|
|
|
|
b"LZS".as_slice(),
|
|
|
|
|
b"ok".as_slice(),
|
|
|
|
|
vec![0b0000_0011, b'o', b'k'],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
0x060,
|
|
|
|
|
b"XLZ".as_slice(),
|
|
|
|
|
b"ok".as_slice(),
|
|
|
|
|
xor_stream(&[0b0000_0011, b'o', b'k'], 0),
|
|
|
|
|
),
|
|
|
|
|
(0x080, b"ADP".as_slice(), b"t".as_slice(), vec![0x00]),
|
|
|
|
|
(
|
|
|
|
|
0x0A0,
|
|
|
|
|
b"XAD".as_slice(),
|
|
|
|
|
b"t".as_slice(),
|
|
|
|
|
xor_stream(&[0x00], 0),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
0x100,
|
|
|
|
|
b"DEF".as_slice(),
|
|
|
|
|
b"ok".as_slice(),
|
|
|
|
|
vec![0x01, 0x02, 0x00, 0xFD, 0xFF, b'o', b'k'],
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (idx, (method, name, expected, packed)) in cases.iter().enumerate() {
|
|
|
|
|
let bytes = synthetic_rsli(
|
|
|
|
|
&[SyntheticEntry::with_payload(
|
|
|
|
|
name,
|
|
|
|
|
*method,
|
|
|
|
|
0,
|
|
|
|
|
expected,
|
|
|
|
|
packed.clone(),
|
|
|
|
|
)],
|
|
|
|
|
true,
|
|
|
|
|
u16::try_from(idx).expect("case index"),
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("generated method archive");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
doc.load(EntryId(0)).expect("generated method payload"),
|
|
|
|
|
*expected
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn arbitrary_small_inputs_do_not_panic() {
|
|
|
|
|
for len in 0..128usize {
|
|
|
|
|
let mut bytes = vec![0u8; len];
|
|
|
|
|
if len >= 4 {
|
|
|
|
|
bytes[0..4].copy_from_slice(b"NL\0\x01");
|
|
|
|
|
}
|
|
|
|
|
if len >= 6 {
|
|
|
|
|
bytes[4..6].copy_from_slice(&((len % 8) as i16).to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
if len >= 24 {
|
|
|
|
|
bytes[20..24].copy_from_slice(&0x1357u32.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let strict =
|
|
|
|
|
std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Strict));
|
|
|
|
|
let compatible =
|
|
|
|
|
std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Compatible));
|
|
|
|
|
assert!(strict.is_ok());
|
|
|
|
|
assert!(compatible.is_ok());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn licensed_corpora_rsli_roundtrip_gates() {
|
|
|
|
|
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
|
|
|
|
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
|
|
|
|
|
|
|
|
|
assert!(part1.entries > 0);
|
|
|
|
|
assert!(part2.entries > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn licensed_part1_rsli_method_distribution_baseline() {
|
|
|
|
|
let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
stats.methods,
|
|
|
|
|
RsliMethodCounts {
|
|
|
|
|
stored: 0,
|
|
|
|
|
xor_only: 0,
|
|
|
|
|
lzss: 2,
|
|
|
|
|
xor_lzss: 0,
|
|
|
|
|
adaptive_lzss: 0,
|
|
|
|
|
xor_adaptive_lzss: 0,
|
|
|
|
|
raw_deflate: 24,
|
|
|
|
|
unknown: 0,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn licensed_part2_rsli_method_distribution_baseline() {
|
|
|
|
|
let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
stats.methods,
|
|
|
|
|
RsliMethodCounts {
|
|
|
|
|
stored: 0,
|
|
|
|
|
xor_only: 0,
|
|
|
|
|
lzss: 2,
|
|
|
|
|
xor_lzss: 0,
|
|
|
|
|
adaptive_lzss: 0,
|
|
|
|
|
xor_adaptive_lzss: 0,
|
|
|
|
|
raw_deflate: 24,
|
|
|
|
|
unknown: 0,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
|
|
|
|
|
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
|
|
|
|
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
part1.eof_plus_one_entries,
|
|
|
|
|
vec!["sprites.lib:INTERF8.TEX".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
part2.eof_plus_one_entries,
|
|
|
|
|
vec!["sprites.lib:INTERF8.TEX".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_strict_profile_only_rejects_approved_quirk("IS");
|
|
|
|
|
assert_strict_profile_only_rejects_approved_quirk("IS2");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
struct RsliMethodCounts {
|
|
|
|
|
stored: usize,
|
|
|
|
|
xor_only: usize,
|
|
|
|
|
lzss: usize,
|
|
|
|
|
xor_lzss: usize,
|
|
|
|
|
adaptive_lzss: usize,
|
|
|
|
|
xor_adaptive_lzss: usize,
|
|
|
|
|
raw_deflate: usize,
|
|
|
|
|
unknown: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RsliMethodCounts {
|
|
|
|
|
fn add(&mut self, method: RsliMethod) {
|
|
|
|
|
match method {
|
|
|
|
|
RsliMethod::Stored => self.stored += 1,
|
|
|
|
|
RsliMethod::XorOnly => self.xor_only += 1,
|
|
|
|
|
RsliMethod::Lzss => self.lzss += 1,
|
|
|
|
|
RsliMethod::XorLzss => self.xor_lzss += 1,
|
|
|
|
|
RsliMethod::AdaptiveLzss => self.adaptive_lzss += 1,
|
|
|
|
|
RsliMethod::XorAdaptiveLzss => self.xor_adaptive_lzss += 1,
|
|
|
|
|
RsliMethod::RawDeflate => self.raw_deflate += 1,
|
|
|
|
|
RsliMethod::Unknown(_) => self.unknown += 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
struct CorpusGateResult {
|
|
|
|
|
entries: usize,
|
|
|
|
|
methods: RsliMethodCounts,
|
|
|
|
|
eof_plus_one_entries: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn corpus_gate(name: &str, expected_files: usize) -> Result<CorpusGateResult, String> {
|
|
|
|
|
let files = corpus_files(name)?;
|
|
|
|
|
if files.len() != expected_files {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"{name}: expected {expected_files} RsLi files, got {}",
|
|
|
|
|
files.len()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut entries = 0usize;
|
|
|
|
|
let mut methods = RsliMethodCounts::default();
|
|
|
|
|
let mut eof_plus_one_entries = Vec::new();
|
|
|
|
|
for path in &files {
|
|
|
|
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
|
|
|
|
|
.map_err(|err| format!("{}: {err}", path.display()))?;
|
|
|
|
|
entries = entries
|
|
|
|
|
.checked_add(doc.entry_count())
|
|
|
|
|
.ok_or_else(|| "entry count overflow".to_string())?;
|
|
|
|
|
for (idx, entry) in doc.entries().iter().enumerate() {
|
|
|
|
|
methods.add(entry.method);
|
|
|
|
|
if entry.method == RsliMethod::RawDeflate
|
|
|
|
|
&& entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1
|
|
|
|
|
{
|
|
|
|
|
eof_plus_one_entries.push(format!(
|
|
|
|
|
"{}:{}",
|
|
|
|
|
path.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.unwrap_or("<unknown>"),
|
|
|
|
|
entry.name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let id = EntryId(u32::try_from(idx).map_err(|_| "entry id overflow")?);
|
|
|
|
|
let found = doc
|
|
|
|
|
.find(&entry.name)
|
|
|
|
|
.ok_or_else(|| format!("lookup failed: {}", path.display()))?;
|
|
|
|
|
if found != id {
|
|
|
|
|
return Err(format!("lookup mismatch: {}", path.display()));
|
|
|
|
|
}
|
|
|
|
|
let unpacked = doc
|
|
|
|
|
.load(id)
|
|
|
|
|
.map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?;
|
|
|
|
|
if unpacked.len()
|
|
|
|
|
!= usize::try_from(entry.unpacked_size).map_err(|_| "size overflow")?
|
|
|
|
|
{
|
|
|
|
|
return Err(format!("unpacked size mismatch: {}", path.display()));
|
|
|
|
|
}
|
|
|
|
|
let packed = doc
|
|
|
|
|
.load_packed(id)
|
|
|
|
|
.map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?;
|
|
|
|
|
if packed.packed.is_empty() && entry.packed_size != 0 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"packed payload unexpectedly empty: {}",
|
|
|
|
|
path.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if doc.encode(WriteProfile::Lossless) != bytes {
|
|
|
|
|
return Err(format!("lossless roundtrip mismatch: {}", path.display()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(CorpusGateResult {
|
|
|
|
|
entries,
|
|
|
|
|
methods,
|
|
|
|
|
eof_plus_one_entries,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> {
|
|
|
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.join("../..")
|
|
|
|
|
.join("testdata")
|
|
|
|
|
.join(name);
|
|
|
|
|
if !root.is_dir() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"licensed corpus root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let mut files = Vec::new();
|
|
|
|
|
collect_rsli_files(&root, &mut files).map_err(|err| err.to_string())?;
|
|
|
|
|
files.sort();
|
|
|
|
|
Ok(files)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn assert_strict_profile_only_rejects_approved_quirk(name: &str) {
|
|
|
|
|
for path in corpus_files(name).expect("licensed RsLi files") {
|
|
|
|
|
let bytes = fs::read(&path).expect("licensed RsLi bytes");
|
|
|
|
|
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
|
|
|
|
|
.expect("compatible licensed RsLi");
|
|
|
|
|
let mut eof_plus_one_names = Vec::new();
|
|
|
|
|
for entry in doc.entries() {
|
|
|
|
|
if entry.method == RsliMethod::RawDeflate
|
|
|
|
|
&& entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1
|
|
|
|
|
{
|
|
|
|
|
eof_plus_one_names.push(entry.name.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let strict = decode(arc(bytes), ReadProfile::Strict);
|
|
|
|
|
if eof_plus_one_names.is_empty() {
|
|
|
|
|
assert!(
|
|
|
|
|
strict.is_ok(),
|
|
|
|
|
"strict profile should accept {}",
|
|
|
|
|
path.display()
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
assert_eq!(eof_plus_one_names, vec!["INTERF8.TEX".to_string()]);
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(
|
|
|
|
|
strict,
|
|
|
|
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { .. })
|
|
|
|
|
),
|
|
|
|
|
"strict profile should only reject the approved EOF+1 quirk in {}",
|
|
|
|
|
path.display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_rsli_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
|
|
|
|
|
for entry in fs::read_dir(root)? {
|
|
|
|
|
let path = entry?.path();
|
|
|
|
|
if path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.is_some_and(|name| name.starts_with('.'))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if path.is_dir() {
|
|
|
|
|
collect_rsli_files(&path, out)?;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if path.is_file() {
|
|
|
|
|
let bytes = fs::read(&path)?;
|
|
|
|
|
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
|
|
|
|
out.push(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
|
|
|
|
|
Arc::from(bytes.into_boxed_slice())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
struct SyntheticEntry {
|
|
|
|
|
name: Vec<u8>,
|
|
|
|
|
method_raw: u32,
|
|
|
|
|
sort_to_original: i16,
|
|
|
|
|
unpacked_size: u32,
|
|
|
|
|
declared_packed_size: u32,
|
|
|
|
|
packed: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SyntheticEntry {
|
|
|
|
|
fn stored(name: &[u8], sort_to_original: i16, payload: &[u8]) -> Self {
|
|
|
|
|
Self::with_payload(name, 0x000, sort_to_original, payload, payload.to_vec())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_payload(
|
|
|
|
|
name: &[u8],
|
|
|
|
|
method_raw: u32,
|
|
|
|
|
sort_to_original: i16,
|
|
|
|
|
unpacked: &[u8],
|
|
|
|
|
packed: Vec<u8>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
let declared_packed_size = u32::try_from(packed.len()).expect("synthetic packed size");
|
|
|
|
|
Self::with_declared_packed_size(
|
|
|
|
|
name,
|
|
|
|
|
method_raw,
|
|
|
|
|
sort_to_original,
|
|
|
|
|
unpacked,
|
|
|
|
|
packed,
|
|
|
|
|
declared_packed_size,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_declared_packed_size(
|
|
|
|
|
name: &[u8],
|
|
|
|
|
method_raw: u32,
|
|
|
|
|
sort_to_original: i16,
|
|
|
|
|
unpacked: &[u8],
|
|
|
|
|
packed: Vec<u8>,
|
|
|
|
|
declared_packed_size: u32,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
name: name.to_vec(),
|
|
|
|
|
method_raw,
|
|
|
|
|
sort_to_original,
|
|
|
|
|
unpacked_size: u32::try_from(unpacked.len()).expect("synthetic unpacked size"),
|
|
|
|
|
declared_packed_size,
|
|
|
|
|
packed,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn synthetic_rsli(
|
|
|
|
|
entries: &[SyntheticEntry],
|
|
|
|
|
presorted: bool,
|
|
|
|
|
xor_seed: u16,
|
|
|
|
|
overlay: Option<u32>,
|
|
|
|
|
) -> Vec<u8> {
|
|
|
|
|
let count = i16::try_from(entries.len()).expect("synthetic entry count");
|
|
|
|
|
let table_len = entries
|
|
|
|
|
.len()
|
|
|
|
|
.checked_mul(32)
|
|
|
|
|
.expect("synthetic table length");
|
|
|
|
|
let payload_offset = 32usize
|
|
|
|
|
.checked_add(table_len)
|
|
|
|
|
.expect("synthetic payload offset");
|
|
|
|
|
let overlay = overlay.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
let mut header = [0u8; 32];
|
|
|
|
|
header[0..4].copy_from_slice(b"NL\0\x01");
|
|
|
|
|
header[4..6].copy_from_slice(&count.to_le_bytes());
|
|
|
|
|
if presorted {
|
|
|
|
|
header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
header[20..24].copy_from_slice(&u32::from(xor_seed).to_le_bytes());
|
|
|
|
|
|
|
|
|
|
let mut table_plain = Vec::with_capacity(table_len);
|
|
|
|
|
let mut cursor = payload_offset;
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let mut row = [0u8; 32];
|
|
|
|
|
let name_len = entry.name.len().min(12);
|
|
|
|
|
row[0..name_len].copy_from_slice(&entry.name[..name_len]);
|
|
|
|
|
row[16..18].copy_from_slice(
|
|
|
|
|
&i16::try_from(entry.method_raw)
|
|
|
|
|
.expect("synthetic method fits")
|
|
|
|
|
.to_le_bytes(),
|
|
|
|
|
);
|
|
|
|
|
row[18..20].copy_from_slice(&entry.sort_to_original.to_le_bytes());
|
|
|
|
|
row[20..24].copy_from_slice(&entry.unpacked_size.to_le_bytes());
|
|
|
|
|
let raw_offset = u32::try_from(cursor)
|
|
|
|
|
.expect("synthetic offset")
|
|
|
|
|
.checked_sub(overlay)
|
|
|
|
|
.expect("synthetic overlay precedes payload");
|
|
|
|
|
row[24..28].copy_from_slice(&raw_offset.to_le_bytes());
|
|
|
|
|
row[28..32].copy_from_slice(&entry.declared_packed_size.to_le_bytes());
|
|
|
|
|
table_plain.extend_from_slice(&row);
|
|
|
|
|
cursor = cursor
|
|
|
|
|
.checked_add(entry.packed.len())
|
|
|
|
|
.expect("synthetic payload cursor");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut bytes = Vec::with_capacity(cursor + 6);
|
|
|
|
|
bytes.extend_from_slice(&header);
|
|
|
|
|
bytes.extend_from_slice(&xor_stream(&table_plain, xor_seed));
|
|
|
|
|
for entry in entries {
|
|
|
|
|
bytes.extend_from_slice(&entry.packed);
|
|
|
|
|
}
|
|
|
|
|
if overlay != 0 {
|
|
|
|
|
bytes.extend_from_slice(b"AO");
|
|
|
|
|
bytes.extend_from_slice(&overlay.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
|
|
|
|
|
let mut a = [0u8; 32];
|
|
|
|
|
let mut b = [0u8; 32];
|
|
|
|
|
a[0] = b'A';
|
|
|
|
|
b[0] = b'B';
|
|
|
|
|
a[18..20].copy_from_slice(&0i16.to_le_bytes());
|
|
|
|
|
b[18..20].copy_from_slice(&1i16.to_le_bytes());
|
|
|
|
|
vec![a, b]
|
|
|
|
|
}
|
|
|
|
|
}
|