Compare commits

16 Commits

Author SHA1 Message Date
4c4f542fc2 Merge branch 'master' into renovate/actions-setup-python-6.x
All checks were successful
Test / Lint (pull_request) Successful in 1m2s
Test / Test (pull_request) Successful in 59s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m4s
Test / Lint (push) Successful in 1m4s
Test / Test (push) Successful in 58s
RenovateBot / renovate (push) Successful in 22s
2026-02-12 04:52:10 +04:00
4c9d772b03 chore(deps): update actions/setup-python action to v6
All checks were successful
Test / Lint (push) Successful in 51s
Test / Lint (pull_request) Successful in 53s
Test / Test (push) Successful in 52s
Test / Test (pull_request) Successful in 51s
2026-02-12 00:01:33 +00:00
097a915f35 fix(deps): update all digest updates
All checks were successful
Test / Lint (pull_request) Successful in 1m3s
Test / Test (pull_request) Successful in 50s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m10s
Test / Lint (push) Successful in 1m0s
Test / Test (push) Successful in 58s
2026-02-12 00:01:28 +00:00
c691de0dd0 fix: обновить срок действия авторских прав в документации и улучшить параметры rsync для развертывания
All checks were successful
Test / Lint (pull_request) Successful in 56s
Test / Test (pull_request) Successful in 59s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 26s
Test / Lint (push) Successful in 57s
Test / Test (push) Successful in 52s
RenovateBot / renovate (push) Successful in 1m32s
2026-02-11 23:16:15 +00:00
92818ce0c4 fix: обновить путь развертывания в конфигурации rsync для корректной работы
Some checks failed
Test / Lint (pull_request) Successful in 1m8s
Test / Test (pull_request) Successful in 49s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 25s
Test / Lint (push) Successful in 54s
Test / Test (push) Successful in 55s
2026-02-11 23:08:37 +00:00
6676cfdd8d feat: обновить параметры SSH для развертывания документации с использованием rsync
Some checks failed
Test / Lint (pull_request) Successful in 58s
Test / Test (pull_request) Successful in 53s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 25s
Test / Lint (push) Successful in 52s
Test / Test (push) Successful in 51s
2026-02-11 23:02:03 +00:00
8b639ee6c9 feat: добавить установку rsync и openssh-client для развертывания документации
Some checks failed
Test / Lint (pull_request) Successful in 52s
Test / Test (pull_request) Successful in 1m13s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 26s
Test / Lint (push) Successful in 1m0s
Test / Test (push) Successful in 53s
2026-02-11 22:49:30 +00:00
a58dea5499 feat: добавить рабочий процесс для развертывания документации MkDocs при пуше в ветку master
Some checks failed
Test / Lint (pull_request) Successful in 47s
Test / Test (pull_request) Successful in 49s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 1m29s
Test / Lint (push) Successful in 53s
Test / Test (push) Successful in 52s
2026-02-11 22:41:22 +00:00
615891d550 feat: обновить заголовки разделов в документации по FXID и NRes для улучшения структуры
All checks were successful
Test / Lint (pull_request) Successful in 46s
Test / Test (pull_request) Successful in 48s
Test / Lint (push) Successful in 48s
Test / Test (push) Successful in 49s
2026-02-11 22:10:43 +00:00
481ff1c06d Implement feature X to enhance user experience and fix bug Y in module Z 2026-02-11 22:06:56 +00:00
7702d800a0 feat: улучшить документацию по материалам и текстурам, добавить детали о сборке и парсинге 2026-02-11 22:04:43 +00:00
3c06e768d6 feat: добавить поддержку атомарной замены файлов для Windows и тесты на максимальную длину имени 2026-02-11 22:00:46 +00:00
70ed6480c2 Refactor materials and Texm documentation for clarity and completeness
- Updated the structure and content of the materials and Texm documentation to provide a comprehensive overview of the material subsystem in the engine.
- Enhanced sections on identifiers, architecture, material layout, and runtime storage.
- Improved explanations of material attributes, animation modes, and parsing behavior.
- Added detailed specifications for toolchain interactions, including lossless write rules and validation recommendations.
- Included pseudocode examples for parsing MAT0 and Texm formats to aid in understanding.
2026-02-11 21:50:33 +00:00
662b292b5b feat: обновить методы обработки данных и улучшить обработку ошибок в библиотеке 2026-02-11 21:43:40 +00:00
3410b54793 feat: добавить тесты для проверки структурных инвариантов и корректности сортировки в RsLi 2026-02-11 21:21:32 +00:00
041b1a6cb3 Добавлены спецификации для сетевой подсистемы, системы звука, загрузки ландшафта, интерфейса пользователя и пайплайна выполнения. Обновлен файл навигации mkdocs.yml для включения новых документов. 2026-02-11 21:12:05 +00:00
30 changed files with 3665 additions and 1524 deletions

View File

@@ -0,0 +1,48 @@
name: Docs Deploy
on:
push:
branches:
- master
jobs:
deploy-docs:
name: Build and Deploy MkDocs
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install docs dependencies
run: pip install -r requirements.txt
- name: Build MkDocs site
run: mkdocs build
- name: Install rsync
run: |
sudo apt-get update
sudo apt-get install -y rsync openssh-client
- name: Prepare SSH key
env:
SSH_KEY_B64: ${{ secrets.ROOT_CI_KEY_B64 }}
run: |
umask 077
mkdir -p ~/.ssh
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_root_ci
chmod 600 ~/.ssh/id_root_ci
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.FPARKAN_DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.FPARKAN_DEPLOY_PORT }}
run: |
rsync -rlz --delete \
-e "ssh -p ${DEPLOY_PORT} -i ~/.ssh/id_root_ci -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
site/ "gitea-runner@${DEPLOY_HOST}:./"

View File

@@ -31,6 +31,7 @@ impl AsRef<[u8]> for ResourceData<'_> {
/// Output sink used by `read_into`/`load_into` APIs. /// Output sink used by `read_into`/`load_into` APIs.
pub trait OutputBuffer { pub trait OutputBuffer {
/// Writes the full payload to the sink, replacing any previous content.
fn write_exact(&mut self, data: &[u8]) -> io::Result<()>; fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
} }

View File

@@ -5,3 +5,6 @@ edition = "2021"
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }

View File

@@ -100,4 +100,11 @@ impl fmt::Display for Error {
} }
} }
impl std::error::Error for Error {} impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}

View File

@@ -651,18 +651,43 @@ fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
file.flush()?; file.flush()?;
drop(file); drop(file);
match fs::rename(&tmp_path, path) { if let Err(err) = replace_file_atomically(&tmp_path, path) {
Ok(()) => Ok(()), let _ = fs::remove_file(&tmp_path);
Err(rename_err) => { return Err(Error::Io(err));
if path.exists() { }
fs::remove_file(path)?;
fs::rename(&tmp_path, path)?; Ok(())
Ok(()) }
} else {
let _ = fs::remove_file(&tmp_path); #[cfg(not(windows))]
Err(Error::Io(rename_err)) fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
} fs::rename(src, dst)
} }
#[cfg(windows)]
fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
use std::iter;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
// Replace destination in one OS call, avoiding remove+rename gaps on Windows.
let ok = unsafe {
MoveFileExW(
src_wide.as_ptr(),
dst_wide.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
} }
} }

View File

@@ -81,6 +81,19 @@ fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(arr) u32::from_le_bytes(arr)
} }
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
let slice = bytes
.get(offset..offset + 4)
.expect("i32 read out of bounds in test");
let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test");
i32::from_le_bytes(arr)
}
fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> {
let nul = raw.iter().position(|value| *value == 0)?;
Some(&raw[..nul])
}
fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> { fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
let mut out = vec![0u8; 16]; let mut out = vec![0u8; 16];
let mut offsets = Vec::with_capacity(entries.len()); let mut offsets = Vec::with_capacity(entries.len());
@@ -133,6 +146,154 @@ fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
out out
} }
#[test]
fn nres_docs_structural_invariants_all_files() {
let files = nres_test_files();
if files.is_empty() {
eprintln!(
"skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres"
);
return;
}
for path in files {
let bytes = fs::read(&path).unwrap_or_else(|err| {
panic!("failed to read {}: {err}", path.display());
});
assert!(
bytes.len() >= 16,
"NRes header too short in {}",
path.display()
);
assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display());
assert_eq!(
read_u32_le(&bytes, 4),
0x100,
"bad version in {}",
path.display()
);
assert_eq!(
usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"),
bytes.len(),
"header.total_size mismatch in {}",
path.display()
);
let entry_count_i32 = read_i32_le(&bytes, 8);
assert!(
entry_count_i32 >= 0,
"negative entry_count={} in {}",
entry_count_i32,
path.display()
);
let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow");
let directory_len = entry_count.checked_mul(64).expect("directory_len overflow");
let directory_offset = bytes
.len()
.checked_sub(directory_len)
.unwrap_or_else(|| panic!("directory underflow in {}", path.display()));
assert!(
directory_offset >= 16,
"directory offset before data area in {}",
path.display()
);
assert_eq!(
directory_offset + directory_len,
bytes.len(),
"directory not at file end in {}",
path.display()
);
let mut sort_indices = Vec::with_capacity(entry_count);
let mut entries = Vec::with_capacity(entry_count);
for index in 0..entry_count {
let base = directory_offset + index * 64;
let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
let data_offset =
usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
let sort_index =
usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow");
let mut name_raw = [0u8; 36];
name_raw.copy_from_slice(
bytes
.get(base + 20..base + 56)
.expect("name field out of bounds in test"),
);
let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| {
panic!(
"name field without NUL terminator in {} entry #{index}",
path.display()
)
});
assert!(
name_bytes.len() <= 35,
"name longer than 35 bytes in {} entry #{index}",
path.display()
);
sort_indices.push(sort_index);
entries.push((name_bytes.to_vec(), data_offset, size));
}
let mut expected_sort: Vec<usize> = (0..entry_count).collect();
expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0));
assert_eq!(
sort_indices,
expected_sort,
"sort_index table mismatch in {}",
path.display()
);
let mut data_regions: Vec<(usize, usize)> =
entries.iter().map(|(_, off, size)| (*off, *size)).collect();
data_regions.sort_by_key(|(off, _)| *off);
for (idx, (data_offset, size)) in data_regions.iter().enumerate() {
assert_eq!(
data_offset % 8,
0,
"data offset is not 8-byte aligned in {} (region #{idx})",
path.display()
);
assert!(
*data_offset >= 16,
"data offset before header end in {} (region #{idx})",
path.display()
);
assert!(
data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset,
"data region overlaps directory in {} (region #{idx})",
path.display()
);
}
for pair in data_regions.windows(2) {
let (start, size) = pair[0];
let (next_start, _) = pair[1];
let end = start
.checked_add(size)
.unwrap_or_else(|| panic!("size overflow in {}", path.display()));
assert!(
end <= next_start,
"overlapping data regions in {}: [{start}, {end}) and next at {next_start}",
path.display()
);
for (offset, value) in bytes[end..next_start].iter().enumerate() {
assert_eq!(
*value,
0,
"non-zero alignment padding in {} at offset {}",
path.display(),
end + offset
);
}
}
}
}
#[test] #[test]
fn nres_read_and_roundtrip_all_files() { fn nres_read_and_roundtrip_all_files() {
let files = nres_test_files(); let files = nres_test_files();
@@ -609,6 +770,41 @@ fn nres_synthetic_read_find_and_edit() {
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
#[test]
fn nres_max_name_length_roundtrip() {
let max_name = "12345678901234567890123456789012345";
assert_eq!(max_name.len(), 35);
let src = build_nres_bytes(&[SyntheticEntry {
kind: 9,
attr1: 1,
attr2: 2,
attr3: 3,
name: max_name,
data: b"payload",
}]);
let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default())
.expect("open synthetic nres failed");
assert_eq!(archive.entry_count(), 1);
assert_eq!(archive.find(max_name), Some(EntryId(0)));
assert_eq!(
archive.find(&max_name.to_ascii_lowercase()),
Some(EntryId(0))
);
let entry = archive.get(EntryId(0)).expect("missing entry 0");
assert_eq!(entry.meta.name, max_name);
assert_eq!(
archive
.read(EntryId(0))
.expect("read payload failed")
.as_slice(),
b"payload"
);
}
#[test] #[test]
fn nres_find_falls_back_when_sort_index_is_out_of_range() { fn nres_find_falls_back_when_sort_index_is_out_of_range() {
let mut bytes = build_nres_bytes(&[ let mut bytes = build_nres_bytes(&[

View File

@@ -1,19 +1,14 @@
use crate::error::Error; use crate::error::Error;
use crate::Result; use crate::Result;
use flate2::read::{DeflateDecoder, ZlibDecoder}; use flate2::read::DeflateDecoder;
use std::io::Read; use std::io::Read;
/// Decode Deflate or Zlib compressed data /// Decode raw Deflate (RFC 1951) payload.
pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> { pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut decoder = DeflateDecoder::new(packed); let mut decoder = DeflateDecoder::new(packed);
if decoder.read_to_end(&mut out).is_ok() { decoder
return Ok(out); .read_to_end(&mut out)
}
out.clear();
let mut zlib = ZlibDecoder::new(packed);
zlib.read_to_end(&mut out)
.map_err(|_| Error::DecompressionFailed("deflate"))?; .map_err(|_| Error::DecompressionFailed("deflate"))?;
Ok(out) Ok(out)
} }

View File

@@ -52,14 +52,14 @@ impl<'a> LzhDecoder<'a> {
let mut out = Vec::with_capacity(expected_size); let mut out = Vec::with_capacity(expected_size);
while out.len() < expected_size { while out.len() < expected_size {
let c = self.decode_char(); let c = self.decode_char()?;
if c < 256 { if c < 256 {
let byte = c as u8; let byte = c as u8;
out.push(byte); out.push(byte);
self.text[self.ring_pos] = byte; self.text[self.ring_pos] = byte;
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
} else { } else {
let mut offset = self.decode_position(); let mut offset = self.decode_position()?;
offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1); offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
let mut length = c.saturating_sub(253); let mut length = c.saturating_sub(253);
@@ -131,29 +131,29 @@ impl<'a> LzhDecoder<'a> {
self.parent[LZH_R] = 0; self.parent[LZH_R] = 0;
} }
fn decode_char(&mut self) -> usize { fn decode_char(&mut self) -> Result<usize> {
let mut node = self.son[LZH_R]; let mut node = self.son[LZH_R];
while node < LZH_T { while node < LZH_T {
let bit = usize::from(self.bit_reader.read_bit_or_zero()); let bit = usize::from(self.bit_reader.read_bit()?);
node = self.son[node + bit]; node = self.son[node + bit];
} }
let c = node - LZH_T; let c = node - LZH_T;
self.update(c); self.update(c);
c Ok(c)
} }
fn decode_position(&mut self) -> usize { fn decode_position(&mut self) -> Result<usize> {
let i = self.bit_reader.read_bits_or_zero(8) as usize; let i = self.bit_reader.read_bits(8)? as usize;
let mut c = usize::from(self.d_code[i]) << 6; let mut c = usize::from(self.d_code[i]) << 6;
let mut j = usize::from(self.d_len[i]).saturating_sub(2); let mut j = usize::from(self.d_len[i]).saturating_sub(2);
while j > 0 { while j > 0 {
j -= 1; j -= 1;
c |= usize::from(self.bit_reader.read_bit_or_zero()) << j; c |= usize::from(self.bit_reader.read_bit()?) << j;
} }
c | (i & 0x3F) Ok(c | (i & 0x3F))
} }
fn update(&mut self, c: usize) { fn update(&mut self, c: usize) {
@@ -264,10 +264,10 @@ impl<'a> BitReader<'a> {
} }
} }
fn read_bit_or_zero(&mut self) -> u8 { fn read_bit(&mut self) -> Result<u8> {
if self.bit_mask == 0x80 { if self.bit_mask == 0x80 {
let Some(mut byte) = self.data.get(self.byte_pos).copied() else { let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
return 0; return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF"));
}; };
if let Some(state) = &mut self.xor_state { if let Some(state) = &mut self.xor_state {
byte = state.decrypt_byte(byte); byte = state.decrypt_byte(byte);
@@ -285,14 +285,14 @@ impl<'a> BitReader<'a> {
self.bit_mask = 0x80; self.bit_mask = 0x80;
self.byte_pos = self.byte_pos.saturating_add(1); self.byte_pos = self.byte_pos.saturating_add(1);
} }
bit Ok(bit)
} }
fn read_bits_or_zero(&mut self, bits: usize) -> u32 { fn read_bits(&mut self, bits: usize) -> Result<u32> {
let mut value = 0u32; let mut value = 0u32;
for _ in 0..bits { for _ in 0..bits {
value = (value << 1) | u32::from(self.read_bit_or_zero()); value = (value << 1) | u32::from(self.read_bit()?);
} }
value Ok(value)
} }
} }

View File

@@ -130,4 +130,11 @@ impl fmt::Display for Error {
} }
} }
impl std::error::Error for Error {} impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}

View File

@@ -191,7 +191,7 @@ impl Library {
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> { pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
let entry = self.entry_by_id(id)?; let entry = self.entry_by_id(id)?;
let packed = self.packed_slice(entry)?; let packed = self.packed_slice(id, entry)?;
decode_payload( decode_payload(
packed, packed,
entry.meta.method, entry.meta.method,
@@ -208,7 +208,7 @@ impl Library {
pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> { pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
let entry = self.entry_by_id(id)?; let entry = self.entry_by_id(id)?;
let packed = self.packed_slice(entry)?.to_vec(); let packed = self.packed_slice(id, entry)?.to_vec();
Ok(PackedResource { Ok(PackedResource {
meta: entry.meta.clone(), meta: entry.meta.clone(),
packed, packed,
@@ -231,7 +231,7 @@ impl Library {
pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> { pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> {
let entry = self.entry_by_id(id)?; let entry = self.entry_by_id(id)?;
if entry.meta.method == PackMethod::None { if entry.meta.method == PackMethod::None {
let packed = self.packed_slice(entry)?; let packed = self.packed_slice(id, entry)?;
let size = let size =
usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?; usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
if packed.len() < size { if packed.len() < size {
@@ -255,7 +255,7 @@ impl Library {
}) })
} }
fn packed_slice<'a>(&'a self, entry: &EntryRecord) -> Result<&'a [u8]> { fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> {
let start = entry.effective_offset; let start = entry.effective_offset;
let end = start let end = start
.checked_add(entry.packed_size_available) .checked_add(entry.packed_size_available)
@@ -263,7 +263,7 @@ impl Library {
self.bytes self.bytes
.get(start..end) .get(start..end)
.ok_or(Error::EntryDataOutOfBounds { .ok_or(Error::EntryDataOutOfBounds {
id: 0, id: id.0,
offset: u64::try_from(start).unwrap_or(u64::MAX), offset: u64::try_from(start).unwrap_or(u64::MAX),
size: entry.packed_size_declared, size: entry.packed_size_declared,
file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX), file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),

View File

@@ -149,13 +149,31 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
if presorted_flag == 0xABBA { if presorted_flag == 0xABBA {
let mut seen = vec![false; count];
for entry in &entries { for entry in &entries {
let idx = i32::from(entry.sort_to_original); let idx = i32::from(entry.sort_to_original);
if idx < 0 || usize::try_from(idx).map_err(|_| Error::IntegerOverflow)? >= count { if idx < 0 {
return Err(Error::CorruptEntryTable( return Err(Error::CorruptEntryTable(
"sort_to_original is not a valid permutation index", "sort_to_original is not a valid permutation index",
)); ));
} }
let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
if idx >= count {
return Err(Error::CorruptEntryTable(
"sort_to_original is not a valid permutation index",
));
}
if seen[idx] {
return Err(Error::CorruptEntryTable(
"sort_to_original is not a permutation",
));
}
seen[idx] = true;
}
if seen.iter().any(|value| !*value) {
return Err(Error::CorruptEntryTable(
"sort_to_original is not a permutation",
));
} }
} else { } else {
let mut sorted: Vec<usize> = (0..count).collect(); let mut sorted: Vec<usize> = (0..count).collect();

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T};
use crate::compress::xor::xor_stream; use crate::compress::xor::xor_stream;
use flate2::write::DeflateEncoder; use flate2::write::DeflateEncoder;
use flate2::write::ZlibEncoder;
use flate2::Compression; use flate2::Compression;
use std::any::Any; use std::any::Any;
use std::fs; use std::fs;
@@ -103,6 +104,12 @@ fn deflate_raw(data: &[u8]) -> Vec<u8> {
encoder.finish().expect("deflate encoder finish failed") encoder.finish().expect("deflate encoder finish failed")
} }
fn deflate_zlib(data: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).expect("zlib encoder write failed");
encoder.finish().expect("zlib encoder finish failed")
}
fn lzss_pack_literals(data: &[u8]) -> Vec<u8> { fn lzss_pack_literals(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new(); let mut out = Vec::new();
for chunk in data.chunks(8) { for chunk in data.chunks(8) {
@@ -444,6 +451,14 @@ fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) ->
output output
} }
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
let slice = bytes
.get(offset..offset + 4)
.expect("u32 read out of bounds in test");
let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
u32::from_le_bytes(arr)
}
#[test] #[test]
fn rsli_read_unpack_and_repack_all_files() { fn rsli_read_unpack_and_repack_all_files() {
let files = rsli_test_files(); let files = rsli_test_files();
@@ -581,6 +596,126 @@ fn rsli_read_unpack_and_repack_all_files() {
} }
} }
#[test]
fn rsli_docs_structural_invariants_all_files() {
let files = rsli_test_files();
if files.is_empty() {
eprintln!(
"skipping rsli_docs_structural_invariants_all_files: no RsLi archives in testdata/rsli"
);
return;
}
let mut deflate_eof_plus_one_quirks = Vec::new();
for path in files {
let bytes = fs::read(&path).unwrap_or_else(|err| {
panic!("failed to read {}: {err}", path.display());
});
assert!(
bytes.len() >= 32,
"RsLi header too short in {}",
path.display()
);
assert_eq!(&bytes[0..2], b"NL", "bad magic in {}", path.display());
assert_eq!(
bytes[2],
0,
"reserved header byte must be zero in {}",
path.display()
);
assert_eq!(bytes[3], 1, "bad version in {}", path.display());
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
assert!(
entry_count >= 0,
"negative entry_count={} in {}",
entry_count,
path.display()
);
let count = usize::try_from(entry_count).expect("entry_count overflow");
let table_size = count.checked_mul(32).expect("table_size overflow");
let table_end = 32usize.checked_add(table_size).expect("table_end overflow");
assert!(
table_end <= bytes.len(),
"table out of bounds in {}",
path.display()
);
let seed = read_u32_le(&bytes, 20);
let table_plain = xor_stream(&bytes[32..table_end], (seed & 0xFFFF) as u16);
assert_eq!(
table_plain.len(),
table_size,
"decrypted table size mismatch in {}",
path.display()
);
let mut overlay = 0u32;
if bytes.len() >= 6 && &bytes[bytes.len() - 6..bytes.len() - 4] == b"AO" {
overlay = read_u32_le(&bytes, bytes.len() - 4);
assert!(
usize::try_from(overlay).expect("overlay overflow") <= bytes.len(),
"overlay beyond EOF in {}",
path.display()
);
}
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
let mut sort_values = Vec::with_capacity(count);
for index in 0..count {
let base = index * 32;
let row = &table_plain[base..base + 32];
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
let data_offset = u64::from(read_u32_le(row, 24));
let packed_size = u64::from(read_u32_le(row, 28));
let method = (flags_signed as u16 as u32) & 0x1E0;
let effective_offset = data_offset + u64::from(overlay);
let end = effective_offset + packed_size;
let file_len = u64::try_from(bytes.len()).expect("file size overflow");
if end > file_len {
assert!(
method == 0x100 && end == file_len + 1,
"packed range out of bounds in {} entry #{index}: method=0x{method:03X}, range=[{effective_offset}, {end}), file={file_len}",
path.display()
);
deflate_eof_plus_one_quirks.push((path.display().to_string(), index));
}
sort_values.push(sort_to_original);
}
if presorted_flag == 0xABBA {
let mut sorted = sort_values;
sorted.sort_unstable();
let expected: Vec<i16> = (0..count)
.map(|idx| i16::try_from(idx).expect("too many entries for i16"))
.collect();
assert_eq!(
sorted,
expected,
"sort_to_original is not a permutation in {}",
path.display()
);
}
}
if !deflate_eof_plus_one_quirks.is_empty() {
assert!(
deflate_eof_plus_one_quirks
.iter()
.all(|(file, idx)| file.ends_with("sprites.lib") && *idx == 23),
"unexpected deflate EOF+1 quirks: {:?}",
deflate_eof_plus_one_quirks
);
}
}
#[test] #[test]
fn rsli_synthetic_all_methods_roundtrip() { fn rsli_synthetic_all_methods_roundtrip() {
let entries = vec![ let entries = vec![
@@ -667,6 +802,316 @@ fn rsli_synthetic_all_methods_roundtrip() {
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
#[test]
fn rsli_empty_archive_roundtrip() {
let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default());
let path = write_temp_file("rsli-empty", &bytes);
let library = Library::open_path(&path).expect("open empty rsli failed");
assert_eq!(library.entry_count(), 0);
assert_eq!(library.find("ANYTHING"), None);
let rebuilt = library
.rebuild_from_parsed_metadata()
.expect("rebuild empty rsli failed");
assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch");
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_max_name_length_without_nul_roundtrip() {
let max_name = "NAME12345678";
assert_eq!(max_name.len(), 12);
let bytes = build_rsli_bytes(
&[SyntheticRsliEntry {
name: max_name.to_string(),
method_raw: 0x000,
plain: b"payload".to_vec(),
declared_packed_size: None,
}],
&RsliBuildOptions::default(),
);
let path = write_temp_file("rsli-max-name", &bytes);
let library = Library::open_path(&path).expect("open max-name rsli failed");
assert_eq!(library.entry_count(), 1);
assert_eq!(library.find(max_name), Some(EntryId(0)));
assert_eq!(
library.find(&max_name.to_ascii_lowercase()),
Some(EntryId(0))
);
assert_eq!(
library.entries[0]
.name_raw
.iter()
.position(|byte| *byte == 0),
None,
"name_raw must occupy full 12 bytes without NUL"
);
let entry = library.get(EntryId(0)).expect("missing entry");
assert_eq!(entry.meta.name, max_name);
assert_eq!(
library.load(EntryId(0)).expect("load failed"),
b"payload",
"payload mismatch"
);
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_lzss_large_payload_over_4k_roundtrip() {
let plain: Vec<u8> = (0..10_000u32).map(|v| (v % 251) as u8).collect();
let entries = vec![
SyntheticRsliEntry {
name: "LZSS4K".to_string(),
method_raw: 0x040,
plain: plain.clone(),
declared_packed_size: None,
},
SyntheticRsliEntry {
name: "XLZS4K".to_string(),
method_raw: 0x060,
plain: plain.clone(),
declared_packed_size: None,
},
];
let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
let path = write_temp_file("rsli-lzss-4k", &bytes);
let library = Library::open_path(&path).expect("open large-lzss rsli failed");
assert_eq!(library.entry_count(), entries.len());
for entry in &entries {
let id = library
.find(&entry.name)
.unwrap_or_else(|| panic!("find failed for {}", entry.name));
let loaded = library
.load(id)
.unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
assert_eq!(loaded, plain, "payload mismatch for {}", entry.name);
}
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() {
let entries = vec![
SyntheticRsliEntry {
name: "AAA".to_string(),
method_raw: 0x000,
plain: b"a".to_vec(),
declared_packed_size: None,
},
SyntheticRsliEntry {
name: "BBB".to_string(),
method_raw: 0x000,
plain: b"b".to_vec(),
declared_packed_size: None,
},
SyntheticRsliEntry {
name: "CCC".to_string(),
method_raw: 0x000,
plain: b"c".to_vec(),
declared_packed_size: None,
},
];
let bytes = build_rsli_bytes(
&entries,
&RsliBuildOptions {
presorted: true,
..RsliBuildOptions::default()
},
);
let path = write_temp_file("rsli-find-fallback", &bytes);
let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
library.entries[1].sort_to_original = -1;
assert_eq!(library.find("AAA"), Some(EntryId(0)));
assert_eq!(library.find("bbb"), Some(EntryId(1)));
assert_eq!(library.find("CcC"), Some(EntryId(2)));
assert_eq!(library.find("missing"), None);
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_deflate_method_rejects_zlib_wrapped_stream() {
let plain = b"payload".to_vec();
let zlib_payload = deflate_zlib(&plain);
let entries = vec![SyntheticRsliEntry {
name: "ZLIB".to_string(),
method_raw: 0x100,
plain,
declared_packed_size: Some(
u32::try_from(zlib_payload.len()).expect("zlib payload size overflow"),
),
}];
let mut bytes = build_rsli_bytes(
&entries,
&RsliBuildOptions {
presorted: true,
..RsliBuildOptions::default()
},
);
let table_end = 32 + entries.len() * 32;
let data_offset = table_end;
let data_end = data_offset + zlib_payload.len();
if bytes.len() < data_end {
bytes.resize(data_end, 0);
}
bytes[data_offset..data_end].copy_from_slice(&zlib_payload);
let path = write_temp_file("rsli-zlib-reject", &bytes);
let library = Library::open_path(&path).expect("open zlib-wrapped rsli failed");
match library.load(EntryId(0)) {
Err(Error::DecompressionFailed(reason)) => {
assert_eq!(reason, "deflate");
}
other => panic!("expected deflate decompression error, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_lzss_huffman_reports_unexpected_eof() {
let entries = vec![SyntheticRsliEntry {
name: "TRUNC".to_string(),
method_raw: 0x080,
plain: b"this payload is long enough".to_vec(),
declared_packed_size: None,
}];
let mut bytes = build_rsli_bytes(
&entries,
&RsliBuildOptions {
presorted: true,
..RsliBuildOptions::default()
},
);
let seed = read_u32_le(&bytes, 20);
let mut table_plain = xor_stream(&bytes[32..64], (seed & 0xFFFF) as u16);
let original_packed_size = u32::from_le_bytes([
table_plain[28],
table_plain[29],
table_plain[30],
table_plain[31],
]);
assert!(
original_packed_size > 4,
"packed payload too small for truncation"
);
let truncated_size = original_packed_size - 3;
table_plain[28..32].copy_from_slice(&truncated_size.to_le_bytes());
let encrypted_table = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
bytes[32..64].copy_from_slice(&encrypted_table);
let path = write_temp_file("rsli-lzh-truncated", &bytes);
let library = Library::open_path(&path).expect("open truncated lzh rsli failed");
match library.load(EntryId(0)) {
Err(Error::DecompressionFailed(reason)) => {
assert_eq!(reason, "lzss-huffman: unexpected EOF");
}
other => panic!("expected lzss-huffman EOF error, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_presorted_flag_requires_permutation() {
let entries = vec![
SyntheticRsliEntry {
name: "AAA".to_string(),
method_raw: 0x000,
plain: b"a".to_vec(),
declared_packed_size: None,
},
SyntheticRsliEntry {
name: "BBB".to_string(),
method_raw: 0x000,
plain: b"b".to_vec(),
declared_packed_size: None,
},
];
let mut bytes = build_rsli_bytes(
&entries,
&RsliBuildOptions {
presorted: true,
..RsliBuildOptions::default()
},
);
let seed = read_u32_le(&bytes, 20);
let mut table_plain = xor_stream(&bytes[32..32 + entries.len() * 32], (seed & 0xFFFF) as u16);
// Corrupt sort_to_original: duplicate index 0, so the table is not a permutation.
table_plain[18..20].copy_from_slice(&0i16.to_le_bytes());
table_plain[50..52].copy_from_slice(&0i16.to_le_bytes());
let table_encrypted = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
bytes[32..32 + table_encrypted.len()].copy_from_slice(&table_encrypted);
let path = write_temp_file("rsli-bad-presorted-perm", &bytes);
match Library::open_path(&path) {
Err(Error::CorruptEntryTable(message)) => {
assert!(
message.contains("permutation"),
"unexpected error message: {message}"
);
}
other => panic!("expected CorruptEntryTable for invalid permutation, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
#[test]
fn rsli_load_reports_correct_entry_id_on_range_failure() {
let entries = vec![
SyntheticRsliEntry {
name: "ONE".to_string(),
method_raw: 0x000,
plain: b"one".to_vec(),
declared_packed_size: None,
},
SyntheticRsliEntry {
name: "TWO".to_string(),
method_raw: 0x000,
plain: b"two".to_vec(),
declared_packed_size: None,
},
];
let bytes = build_rsli_bytes(
&entries,
&RsliBuildOptions {
presorted: true,
..RsliBuildOptions::default()
},
);
let path = write_temp_file("rsli-entry-id-error", &bytes);
let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
library.entries[1].packed_size_available = usize::MAX;
match library.load(EntryId(1)) {
Err(Error::IntegerOverflow) => {}
other => panic!("expected IntegerOverflow, got {other:?}"),
}
library.entries[1].packed_size_available = library.bytes.len();
match library.load(EntryId(1)) {
Err(Error::EntryDataOutOfBounds { id, .. }) => assert_eq!(id, 1),
other => panic!("expected EntryDataOutOfBounds with id=1, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
#[test] #[test]
fn rsli_xorlzss_huffman_on_the_fly_roundtrip() { fn rsli_xorlzss_huffman_on_the_fly_roundtrip() {
let plain: Vec<u8> = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect(); let plain: Vec<u8> = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect();

5
docs/specs/ai.md Normal file
View File

@@ -0,0 +1,5 @@
# AI system
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.

5
docs/specs/arealmap.md Normal file
View File

@@ -0,0 +1,5 @@
# ArealMap
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.

5
docs/specs/behavior.md Normal file
View File

@@ -0,0 +1,5 @@
# Behavior system
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.

5
docs/specs/control.md Normal file
View File

@@ -0,0 +1,5 @@
# Control system
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.

834
docs/specs/fxid.md Normal file
View File

@@ -0,0 +1,834 @@
# FXID
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
- 1:1 загрузки и исполнения в совместимом runtime;
- построения валидатора payload;
- создания lossless-конвертера (`binary -> IR -> binary`);
- создания редактора с безопасным редактированием полей.
Связанный контейнер: [NRes / RsLi](nres.md).
---
## 1. Источники и статус восстановления
Спецификация восстановлена по:
- `tmp/disassembler1/Effect.dll.c`;
- `tmp/disassembler2/Effect.dll.asm`;
- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
- проверке реальных архивов `testdata/nres`.
Ключевые функции:
- parser FXID: `Effect.dll!sub_10007650`;
- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
- alpha/time: `sub_10005C60`;
- exports: `CreateFxManager`, `InitializeSettings`.
Проверка по данным:
- `923/923` FXID payload валидны в `testdata/nres`.
---
## 2. Контейнер и runtime API
### 2.1. NRes entry
FXID хранится как NRes-entry:
- `type_id = 0x44495846` (`"FXID"`).
Наблюдение по датасету (923 эффекта):
- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
### 2.2. Export API `Effect.dll`
Экспортируются:
- `CreateFxManager(int a1, int a2, int owner)`;
- `InitializeSettings()`.
`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
### 2.3. Интерфейс менеджера
Рабочая vtable (`off_1001E478`):
| Смещение | Функция | Назначение |
|---|---|---|
| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
| +0x14 | `sub_10004590` | Создать runtime instance |
| +0x18 | `sub_10004780` | Удалить instance |
| +0x1C | `sub_100047B0` | Установить time/interp mode |
| +0x20 | `sub_100047D0` | Установить scale |
| +0x24 | `sub_10004830` | Установить позицию |
| +0x28 | `sub_10004930` | Установить matrix transform |
| +0x2C | `sub_10004B00` | Restart/retime |
| +0x38 | `sub_10004BA0` | Duration modifier |
| +0x3C | `sub_10004BD0` | Start/Enable |
| +0x40 | `sub_10004C10` | Stop/Disable |
| +0x44 | `sub_10004C50` | Bind emitter/context |
| +0x48 | `sub_10004D50` | Сброс frame flags |
`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
---
## 3. Бинарный формат FXID payload
Все значения little-endian.
### 3.1. Header (60 байт, `0x3C`)
```c
struct FxHeader60 {
uint32_t cmd_count; // 0x00
uint32_t time_mode; // 0x04
float duration_sec; // 0x08
float phase_jitter; // 0x0C
uint32_t flags; // 0x10
uint32_t settings_id; // 0x14
float rand_shift_x; // 0x18
float rand_shift_y; // 0x1C
float rand_shift_z; // 0x20
float pivot_x; // 0x24
float pivot_y; // 0x28
float pivot_z; // 0x2C
float scale_x; // 0x30
float scale_y; // 0x34
float scale_z; // 0x38
};
```
Командный поток начинается строго с `offset = 0x3C`.
### 3.2. Header-поля (подтвержденная семантика)
- `cmd_count`: число команд (engine итерирует ровно столько шагов).
- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
- `phase_jitter`: используется при `flags & 0x1`.
- `flags`: runtime-gating/alpha/visibility (см. ниже).
- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
- `rand_shift_*`: используется при `flags & 0x8`.
- `pivot_*`: используется в ветках `sub_10007D10`.
- `scale_*`: копируется в runtime scale и влияет на матрицы.
### 3.3. `flags` (битовая карта)
| Бит | Маска | Наблюдаемое поведение |
|---|---:|---|
| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
| 4 | `0x0010` | Visibility/occlusion ветки |
| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
| 6 | `0x0040` | Инверсия начального active-state |
| 7 | `0x0080` | Day/night filter (ветка A) |
| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
| 9 | `0x0200` | Alpha *= normalized lifetime |
| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
| 12 | `0x1000` | Установка manager-state bit `0x10` |
Нерасшифрованные биты должны сохраняться 1:1.
### 3.4. `time_mode` (`0..17`)
Обозначения (`sub_10005C60`):
- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
- `tn = (now_ms - t0) / (t1 - t0)`;
- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
Режимы:
- `0`: constant (`instance.alpha_const`, поле `v4+40`);
- `1`: `tn`;
- `2`: `fract(tn)`;
- `3`: `1 - tn`;
- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
- `6`: `param33.x / param17.vecA.x`;
- `7`: `param33.y / param17.vecA.y`;
- `8`: `param33.z / param17.vecA.z`;
- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
- `10`: `param36.x / param17.vecB.x`;
- `11`: `param36.y / param17.vecB.y`;
- `12`: `param36.z / param17.vecB.z`;
- `13`: `1 - external_resource_value`;
- `14`: `1 - queue_param(49)`;
- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
Post-обработка после mode:
- если `flags & 0x200`: `alpha *= tn`;
- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
---
## 4. Командный поток
### 4.1. Общий формат команды
Каждая команда:
- `uint32 cmd_word`;
- далее body фиксированного размера по opcode.
`cmd_word`:
- `opcode = cmd_word & 0xFF`;
- `enabled = (cmd_word >> 8) & 1`;
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
Выравнивания между командами нет.
### 4.2. Размеры
| Opcode | Размер записи |
|---:|---:|
| 1 | 224 |
| 2 | 148 |
| 3 | 200 |
| 4 | 204 |
| 5 | 112 |
| 6 | 4 |
| 7 | 208 |
| 8 | 248 |
| 9 | 208 |
| 10 | 208 |
### 4.3. Opcode -> runtime-класс (vtable)
| Opcode | `new(size)` | vtable |
|---:|---:|---|
| 1 | `0xF0` | `off_1001E78C` |
| 2 | `0xA0` | `off_1001F048` |
| 3 | `0xFC` | `off_1001E770` |
| 4 | `0x104` | `off_1001E754` |
| 5 | `0x54` | `off_1001E360` |
| 6 | `0x1C` | `off_1001E738` |
| 7 | `0x48` | `off_1001E228` |
| 8 | `0xAC` | `off_1001E71C` |
| 9 | `0x100` | `off_1001E700` |
| 10 | `0x48` | `off_1001E24C` |
### 4.4. Общий вызовной контракт команды
После создания команды (`sub_10007650`):
1. `cmd->enabled = cmd_word.bit8`.
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
3. команда добавляется в список инстанса.
В runtime cycle:
- `vfunc +8`: update/compute (bool);
- `vfunc +12`: emission/render callback;
- `vfunc +20`: toggle active;
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
---
## 5. Загрузка FXID (engine-accurate)
`sub_10007650`:
```c
void FxLoad(FxInstance* fx, uint8_t* payload) {
FxHeader60* h = (FxHeader60*)payload;
fx->raw_header = h;
fx->mode = h->time_mode;
fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
fx->scale = {h->scale_x, h->scale_y, h->scale_z};
fx->active_default = ((h->flags & 0x40) == 0);
uint8_t* ptr = payload + 0x3C;
for (uint32_t i = 0; i < h->cmd_count; ++i) {
uint32_t w = *(uint32_t*)ptr;
uint8_t op = (uint8_t)(w & 0xFF);
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
if (cmd) {
cmd->enabled = (w >> 8) & 1;
if (h->flags & 0x400) fx->manager_flags |= 0x0100;
if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
cmd->Init(fx->queue, fx);
fx->commands.push_back(cmd);
}
ptr += size_by_opcode(op); // без bounds checks в оригинале
}
}
```
Критичные edge-case оригинала:
- bounds checks отсутствуют;
- при unknown opcode `ptr` не двигается (`advance = 0`);
- при `new == null` команда пропускается, но `ptr` двигается.
Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
- `default:+0`.
---
## 6. Runtime lifecycle
- `sub_10007470`: ctor instance.
- `sub_10003D30(case 28)`: per-frame update manager.
- `sub_10006170`: gate + alpha/time + command updates.
- `sub_10008120` / `sub_10007D10`: update/render branches.
- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
Event-codes `sub_10003D30`:
- `4`: bootstrap/time init;
- `20`: range-removal + index repair;
- `23`: set manager bit0;
- `24`: clear manager bit0;
- `28`: main tick.
---
## 7. Общий тип `ResourceRef64`
Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
```c
struct ResourceRef64 {
char archive[32]; // null-terminated ASCII, case-insensitive compare
char name[32]; // null-terminated ASCII
};
```
Поведение loader'а:
- оба имени обязаны быть непустыми;
- кэширование по `(_strcmpi archive, _strcmpi name)`;
- загрузка/резолв через manager resource API.
Наблюдение по данным:
- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
- для остальных: обычно `material.lib` + material name.
---
## 8. Полная карта body по opcode (field-level)
Смещения указаны от начала команды (включая `cmd_word`).
### 8.1. Opcode 1 (`off_1001E78C`, size=224)
Основные методы:
- init: `sub_1000F4B0`;
- update: `sub_1000F6E0`;
- emit: `nullsub_2`;
- toggle: `sub_1000F490`.
```c
struct FxCmd01 {
uint32_t word; // +0
uint32_t mode; // +4 (enum, см. ниже)
float t_start; // +8
float t_end; // +12
float p0_min[3]; // +16..24
float p0_max[3]; // +28..36
float p1_min[3]; // +40..48
float p1_max[3]; // +52..60
float q0_min[4]; // +64..76
float q0_max[4]; // +80..92
float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
float scalar_min; // +112
float scalar_max; // +116
float scalar_rand_amp; // +120
float color_rgb[3]; // +124..132 (вызов manager+16)
float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
char opt_name[32]; // +192..223 (редко, напр. "light_w")
};
```
Замечания по полям op1:
- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
- `1 -> create_kind=1, flags=0x80000000`;
- `2/5 -> create_kind=1, flags=0x00000000`;
- `3 -> create_kind=3, flags=0x00000000`;
- `4 -> create_kind=4, flags=0x00000000`;
- `6 -> create_kind=1, flags=0xA0000000`;
- `7 -> create_kind=1, flags=0x20000000`.
### 8.2. Opcode 2 (`off_1001F048`, size=148)
Основные методы:
- init: `sub_10012D10`;
- update: `sub_10012EB0`;
- emit: `nullsub_2`;
- toggle: `sub_10013170`.
```c
struct FxCmd02 {
uint32_t word; // +0
uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
float t_start; // +8
float t_end; // +12
float a_min[3]; // +16..24
float a_max[3]; // +28..36
float b_min[3]; // +40..48
float b_max[3]; // +52..60
float c0_base; // +64
float c1_base; // +68
float c2_base; // +72
float c2_max; // +76
uint32_t param_910; // +80 (передаётся в manager cmd=910)
ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
};
```
`mode` -> внутренний map в `sub_100065A0`:
- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
### 8.3. Opcode 3 (`off_1001E770`, size=200)
Методы:
- init: `sub_100103B0`;
- update: `sub_100105F0`;
- emit: `sub_100106C0`.
```c
struct FxCmd03 {
uint32_t word; // +0
uint32_t mode; // +4
float alpha_source; // +8 (>=0: norm time, <0: global time)
float alpha_pow_a; // +12
float alpha_pow_b; // +16
float out_min; // +20
float out_max; // +24
float out_pow; // +28
float active_t0; // +32
float active_t1; // +36
float v0_min[3]; // +40..48
float v0_max[3]; // +52..60
float pow0[3]; // +64..72
float v1_min[3]; // +76..84
float v1_max[3]; // +88..96
float v2_min[3]; // +100..108
float v2_max[3]; // +112..120
float pow1[3]; // +124..132
ResourceRef64 ref; // +136..199
};
```
### 8.4. Opcode 4 (`off_1001E754`, size=204)
Layout как opcode 3 + последний коэффициент:
```c
struct FxCmd04 {
FxCmd03 base; // +0..199
float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
};
```
`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
### 8.5. Opcode 5 (`off_1001E360`, size=112)
Методы:
- init: `sub_100028A0`;
- update: `sub_10002A20`;
- emit: `sub_10002BE0`;
- context update: `sub_10003070`.
```c
struct FxCmd05 {
uint32_t word; // +0
uint32_t mode; // +4 (в данных обычно 1)
uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
float active_t0; // +16
uint32_t max_segments; // +20
float active_t1_min; // +24
float active_t1_max; // +28
float step_norm; // +32
float segment_len; // +36
float alpha_source; // +40 (>=0 norm, <0 random)
float alpha_pow; // +44
ResourceRef64 ref; // +48..111
};
```
### 8.6. Opcode 6 (`off_1001E738`, size=4)
Только `cmd_word`:
```c
struct FxCmd06 {
uint32_t word; // +0
};
```
`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
### 8.7. Opcode 7 (`off_1001E228`, size=208)
Методы:
- init: `sub_10001720`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002780`.
```c
struct FxCmd07 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_min; // +8
float eval_max; // +12
float eval_pow; // +16
float active_t0; // +20
float active_t1; // +24
float phase_span; // +28
float phase_rate; // +32
uint32_t count_a; // +36
uint32_t count_b; // +40
float set0_min[3]; // +44..52
float set0_max[3]; // +56..64
float set0_rand[3]; // +68..76
float set0_pow[3]; // +80..88
float set1_min[3]; // +92..100
float set1_max[3]; // +104..112
float set1_rand[3]; // +116..124
float set1_pow[3]; // +128..136
float gravity_or_drag_k; // +140
ResourceRef64 ref; // +144..207
};
```
### 8.8. Opcode 8 (`off_1001E71C`, size=248)
Методы:
- init: `sub_10011230`;
- update: `sub_100115C0`;
- emit: `sub_10012030`.
```c
struct FxCmd08 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_t0; // +8
float eval_t1; // +12
float gate_t0; // +16
float gate_t1; // +20
float period_min; // +24
float period_max; // +28
float phase_pow; // +32
uint32_t slots; // +36
float set0_min[3]; // +40..48
float set0_max[3]; // +52..60
float set0_rand[3]; // +64..72
float set1_min[3]; // +76..84
float set1_max[3]; // +88..96
float set1_rand[3]; // +100..108
float set2_rand[3]; // +112..120
float set2_pow[3]; // +124..132
float rmax_set0[3]; // +136..144 (bound/radius calc)
float rmax_set1[3]; // +148..156 (bound/radius calc)
float rmax_set2[3]; // +160..168 (bound/radius calc)
float render_pow[3]; // +172..180
ResourceRef64 ref; // +184..247
};
```
### 8.9. Opcode 9 (`off_1001E700`, size=208)
Layout как opcode 3 с двумя final-полями:
```c
struct FxCmd09 {
FxCmd03 base; // +0..199
uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
};
```
Методы:
- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
### 8.10. Opcode 10 (`off_1001E24C`, size=208)
Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
- init: `sub_10001A40`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002830`.
Наблюдение по данным:
- `mode` (`+4`) встречается как `16` или `32`.
---
## 9. Runtime-специфика по opcode (важные отличия)
### 9.1. Opcode 1
- создаёт handle через manager (`vfunc +48`);
- задаёт флаги handle (`vfunc +52`);
- в update пушит:
- позиционный вектор 1 (`vfunc +32`),
- позиционный вектор 2 (`vfunc +36`),
- 4-компонентный параметр (`vfunc +12`),
- scalar+rgb (`vfunc +16`).
### 9.2. Opcode 2
- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
- использует manager-команду id `910`.
### 9.3. Opcode 3/4/9
- общий core-emitter в `sub_100106C0`;
- opcode 4 добавляет нормализацию по `raw+200`;
- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
### 9.4. Opcode 5
- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
- context-matrix приходит через `vfunc +24` (`sub_10003070`).
### 9.5. Opcode 7/10
- общий update/render (`sub_10001230`, `sub_10001300`);
- разные внутренние element-форматы:
- opcode 7: `204` байта/элемент (`sub_100092D0`),
- opcode 10: `492` байта/элемент (`sub_1000BB40`).
### 9.6. Opcode 8
- самый тяжёлый спавнер, хранит ring/slot-структуры;
- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
---
## 10. Спецификация инструментов
### 10.1. Reader (strict)
Алгоритм:
1. `len(payload) >= 60`;
2. читаем `cmd_count`;
3. `ptr = 0x3C`;
4. цикл `cmd_count`:
- `ptr + 4 <= len`;
- `opcode in 1..10`;
- `ptr + size(opcode) <= len`;
- `ptr += size(opcode)`;
5. strict-tail: `ptr == len(payload)`.
### 10.2. Reader (engine-compatible)
Legacy-режим (опасный, только при необходимости byte-совместимости):
- без bounds-check;
- tolerant к unknown opcode как в оригинале.
### 10.3. Writer (canonical)
1. записать `FxHeader60`;
2. `cmd_count = commands.len()`;
3. команды сериализуются как `cmd_word + fixed-body`;
4. размер payload: `0x3C + sum(size(op_i))`;
5. без хвостовых байт.
### 10.4. Editor (lossless)
Правила:
- все поля little-endian;
- не менять fixed size команды;
- не добавлять padding;
- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
- для частично-известных полей поддерживать режим `opaque`.
### 10.5. IR/JSON (рекомендуемая форма)
```json
{
"header": {
"time_mode": 1,
"duration_sec": 2.5,
"phase_jitter": 0.2,
"flags": 22,
"settings_id": 785,
"rand_shift": [0.0, 0.0, 0.0],
"pivot": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"commands": [
{
"opcode": 8,
"word_raw": 264,
"enabled": 1,
"fields": {
"mode": 1065353216,
"eval_t0": 0.0,
"eval_t1": 1.0,
"resource": {"archive": "material.lib", "name": "fire_smoke"}
},
"opaque_extra_hex": "..."
}
]
}
```
---
## 11. Проверка на реальных данных
`testdata/nres`:
- FXID payload: `923`;
- валидация parser'а: `923/923 valid`.
Распределение opcode:
- `1: 618`
- `2: 517`
- `3: 1545`
- `4: 202`
- `5: 31`
- `6: 0` (в датасете не встречен, но поддержан)
- `7: 1161`
- `8: 237`
- `9: 266`
- `10: 160`
Подтверждённые `ResourceRef64` оффсеты:
- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`.
Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
- `material.lib` / `light_w`.
---
## 12. Практический чек-лист 1:1
Для runtime-порта:
- реализовать `FxHeader60` и parser `sub_10007650`;
- реализовать opcode-классы с методами как в vtable;
- учитывать start/stop/restart контракт manager API;
- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
- воспроизвести event loop `sub_10003D30(case 28)`.
Для toolchain:
- strict validator по разделу 10.1;
- canonical writer по разделу 10.3;
- field-aware editor + opaque fallback для неизвестных зон.
---
## 13. Что считать «полной» совместимостью
Практический критерий завершения:
1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
---
## 14. Что осталось до «абсолютных 100%»
Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
Что нужно собрать, чтобы закрыть это полностью:
- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
- контрольные прогоны при фиксированном `dt` и seed;
- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).

View File

@@ -0,0 +1,874 @@
# Materials, WEAR, MAT0 и Texm
Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
- реализации runtime 1:1;
- создания инструментов чтения/валидации;
- создания инструментов конвертации и редактирования с lossless round-trip.
Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
---
## 1. Идентификаторы и сущности
| Сущность | ID (LE uint32) | ASCII | Где используется |
|---|---:|---|---|
| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
---
## 2. Архитектура подсистемы
### 2.1 Экспортируемые точки входа (World3D)
- `LoadMatManager`
- `SetPalettesLib`
- `SetTexturesLib`
- `SetMaterialLib`
- `SetLightMapLib`
- `SetGameTime`
- `UnloadAllTextures`
`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
- `Textures.lib`
- `Material.lib`
- `LightMap.lib`
- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
### 2.3 Ключевые runtime-хранилища
1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
2. Кэш текстурных объектов.
3. Кэш lightmap-объектов.
4. Банк загруженных палитр.
5. Глобальный пул определений материалов (`MAT0`).
---
## 3. Layout `MatManager` (0x470)
Объект содержит 70 таблиц wear/lightmaps (не 140).
```c
// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
// [0] vtable
// [1] callback iface
// [2] callback data
// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
// [73..142] wearCounts[70]
// [143] tableCount
// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
// [214..283] lightmapCounts[70]
```
### 3.1 Vtable методов (`off_100209E4`)
| Индекс | Функция | Назначение |
|---:|---|---|
| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
### 3.2 Кодирование material-handle
`uint32 handle = (tableIndex << 16) | wearIndex`.
- `HIWORD(handle)` -> индекс таблицы `0..69`
- `LOWORD(handle)` -> индекс материала в wear-таблице
---
## 4. Глобальные кэши и их ёмкость
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
### 4.1 Кэш текстур (`dword_1014E910`...)
- Размер слота: `5 DWORD` (20 байт)
- Ёмкость: `777`
```c
struct TextureSlot {
int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
void* textureObject; // +4
int32_t refCount; // +8
uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
uint32_t loadFlags; // +16 флаги загрузки
};
```
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
### 4.2 Кэш lightmaps (`dword_10029C98`...)
- Тот же layout `5 DWORD`
- Ёмкость: `100`
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
### 4.3 Пул материалов (`dword_100669F0`...)
- Шаг: `92 DWORD` (`368` байт)
- Ёмкость: `700`
Фиксированные поля на шаг `i*92`:
| DWORD offset | Byte offset | Поле |
|---:|---:|---|
| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
| 1 | 4 | `refCount` |
| 2 | 8 | `phaseCount` |
| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
| 4 | 16 | `animBlockCount` (`< 20`) |
| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
| 85 | 340 | metaA (`dword_10066B44`) |
| 86 | 344 | metaB (`dword_10066B48`) |
| 87 | 348 | metaC (`dword_10066B4C`) |
| 88 | 352 | metaD (`dword_10066B50`) |
| 89 | 356 | flagA (`dword_10066B54`) |
| 90 | 360 | nibbleMode (`dword_10066B58`) |
| 91 | 364 | flagB (`dword_10066B5C`) |
### 4.4 Банк палитр
- `dword_1013DA58[]`
- Загружается до `286` элементов (26 букв * 11 вариантов)
---
## 5. Загрузка палитр (`sub_10002B40`)
### 5.1 Генерация имён
Движок перебирает:
- буквы `'A'..'Z'`
- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
И формирует имя:
- `<Letter><Suffix>.PAL`
- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
### 5.2 Индекс палитры
`paletteIndex = letterIndex * 11 + variantIndex`
- `letterIndex = 0..25`
- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
### 5.3 Поведение
- Если запись не найдена: `paletteSlots[idx] = 0`
- Если найдена: payload отдаётся в рендер (`render->method+60`)
---
## 6. Формат `MAT0` (`Material.lib`)
### 6.1 Атрибуты NRes entry
`sub_10004310` использует:
- `entry.type` = `MAT0`
- `entry.attr1` (bitfield runtime-флагов)
- `entry.attr2` (версия/вариант заголовка payload)
- `entry.attr3` не используется в runtime-парсере
Маппинг `attr1`:
- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
- bit6 (`0x40`) -> `flagB=1`
### 6.2 Payload layout
```c
struct Mat0Payload {
uint16_t phaseCount;
uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
// Если attr2 >= 2:
uint8_t metaA8;
uint8_t metaB8;
// Если attr2 >= 3:
uint32_t metaC32;
// Если attr2 >= 4:
uint32_t metaD32;
PhaseRecordByte34 phases[phaseCount];
AnimBlockRaw anim[animBlockCount];
};
```
Если `attr2 < 2`, runtime-значения по умолчанию:
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f` (`0x3F800000`)
- `metaD = 0`
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
Сырые 34 байта:
```c
struct PhaseRecordByte34 {
uint8_t p[18]; // параметры
char textureName[16];// если textureName[0]==0, текстуры нет
};
```
Преобразование в runtime-структуру (точный порядок):
| Из `p[i]` | В offset runtime | Преобразование |
|---:|---:|---|
| `p[0]` | `+16` | `p[0] / 255.0f` |
| `p[1]` | `+20` | `p[1] / 255.0f` |
| `p[2]` | `+24` | `p[2] / 255.0f` |
| `p[3]` | `+28` | `p[3] * 0.01f` |
| `p[4]` | `+0` | `p[4] / 255.0f` |
| `p[5]` | `+4` | `p[5] / 255.0f` |
| `p[6]` | `+8` | `p[6] / 255.0f` |
| `p[7]` | `+12` | `p[7] / 255.0f` |
| `p[8]` | `+32` | `p[8] / 255.0f` |
| `p[9]` | `+36` | `p[9] / 255.0f` |
| `p[10]` | `+40` | `p[10] / 255.0f` |
| `p[11]` | `+44` | `p[11] / 255.0f` |
| `p[12]` | `+48` | `p[12] / 255.0f` |
| `p[13]` | `+52` | `p[13] / 255.0f` |
| `p[14]` | `+56` | `p[14] / 255.0f` |
| `p[15]` | `+60` | `p[15] / 255.0f` |
| `p[16]` | `+64` | `uint32 = p[16]` |
| `p[17]` | `+72` | `int32 = p[17]` |
Текстура:
- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
- иначе `runtime[+68] = LoadTexture(textureName, flags)`
### 6.4 Runtime-запись фазы (76 байт)
```c
struct MaterialPhase76 {
float f0; // +0
float f1; // +4
float f2; // +8
float f3; // +12
float f4; // +16
float f5; // +20
float f6; // +24
float f7; // +28
float f8; // +32
float f9; // +36
float f10; // +40
float f11; // +44
float f12; // +48
float f13; // +52
float f14; // +56
float f15; // +60
uint32_t u16; // +64
int32_t texSlot; // +68 (индекс в texture cache, либо -1)
int32_t i18; // +72
};
```
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
Каждый блок в payload:
```c
struct AnimBlockRaw {
uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
uint16_t keyCount;
struct KeyRaw {
uint16_t k0;
uint16_t k1;
uint16_t k2;
} keys[keyCount];
};
```
Runtime-представление блока = 16 байт:
```c
struct AnimBlockRuntime {
uint32_t mode; // headerRaw & 7
uint32_t interpMask;// headerRaw >> 3
int32_t keyCount;
void* keysPtr; // массив keyCount * 8
};
```
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
`k2` в `sub_100031F0/sub_10003680` не используется.
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
### 6.6 Поиск и fallback
При `LoadMaterial(name)`:
- сначала точный поиск в `Material.lib`;
- при промахе лог: `"Material %s not found."`;
- fallback на `DEFAULT`;
- если и `DEFAULT` не найден, берётся индекс `0`.
---
## 7. Выбор текущей material-фазы
### 7.1 Интерполяция (`sub_10003030`)
Интерполируются только следующие поля (по `interpMask`):
- bit `0x02`: `+4,+8,+12`
- bit `0x01`: `+20,+24,+28`
- bit `0x04`: `+36,+40,+44`
- bit `0x08`: `+52,+56,+60`
- bit `0x10`: `+32`
Не интерполируются и копируются из «текущей» фазы:
- `+0,+16,+48,+64,+68,+72`
### 7.2 Выбор по времени (`sub_100031F0`)
Вход:
- `handle` (`tableIndex|wearIndex`)
- `animBlockIndex`
- глобальное время `SetGameTime()` (`dword_10032A38`)
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
Режимы `mode = headerRaw & 7`:
- `0`: loop
- `1`: ping-pong
- `2`: one-shot clamp
- `3`: random (`rand() % cycleLength`)
Важные детали 1:1:
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
Перед вычислением времени применяется runtime-нормализация:
- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
### 7.4 Сброс времени записи
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
---
## 8. Формат `WEAR` (текст)
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
### 8.1 Грамматика
```text
<wearCount:int>\n
<legacyId:int> <materialName>\n // повторить wearCount раз
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
[LIGHTMAPS\n
<lightmapCount:int>\n
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
```
- `<legacyId>` читается, но как ключ не используется.
- Идентификатором реально является имя (`materialName` / `lightmapName`).
### 8.2 Парсеры
1. `sub_10003B10`: файл/ресурсный режим.
2. `sub_10003F80`: парсер из строкового буфера.
Различие важно для совместимости:
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
### 8.3 Поведение и ошибки
- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
### 8.4 Ограничения runtime
- Таблиц в `MatManager`: максимум 70 (физический layout).
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
Инструментам нужно явно валидировать `tableCount < 70`.
---
## 9. Загрузка texture/lightmap по имени
Общие функции:
- `sub_10004B10` — texture (`Textures.lib`)
- `sub_10004CB0` — lightmap (`LightMap.lib`)
### 9.1 Валидация имени
Алгоритм требует наличие `'.'` в позиции `0..16`.
Иначе:
- `"Bad texture name."`
- возврат `-1`
### 9.2 Palette index из суффикса
После точки разбирается:
- `L = toupper(name[dot+1])`
- `D = name[dot+2]` (опционально)
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
Если `idx < 0`, палитра не подставляется (`0`).
Верхняя граница `idx` в runtime не проверяется.
Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
### 9.3 Кэширование
- Дедупликация по `resIndex`.
- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
- При освобождении материала `refCount` texture/lightmap уменьшается.
- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
---
## 10. Формат `Texm`
### 10.1 Заголовок 32 байта
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm' = 0x6D786554
uint32_t width;
uint32_t height;
uint32_t mipCount;
uint32_t flags4;
uint32_t flags5;
uint32_t unk6;
uint32_t format;
};
```
### 10.2 Поддерживаемые `format`
Подтверждённые в данных:
- `0` (палитровый 8-bit)
- `565`
- `4444`
- `888`
- `8888`
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
- `556`
- `88`
### 10.3 Layout payload
1. `TexmHeader32`
2. если `format == 0`: palette table `256 * 4 = 1024` байта
3. mip-chain пикселей
4. опциональный `Page` chunk
Расчёт:
```c
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
### 10.4 `Page` chunk
```c
struct PageChunk {
uint32_t magic; // 'Page'
uint32_t rectCount;
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
} rects[rectCount];
};
```
Runtime конвертирует `Rect16` в:
- пиксельные прямоугольники;
- UV-границы с учётом возможного `mipSkip`.
Формулы (`s = mipSkip`):
- `x0 = x << s`, `x1 = (x + w) << s`
- `y0 = y << s`, `y1 = (y + h) << s`
- `u0 = x / (width << s)`, `du = w / (width << s)`
- `v0 = y / (height << s)`, `dv = h / (height << s)`
Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
### 10.5 Loader-поведение (`sub_1000FB30`)
- Читает header в внутренние поля (`+56..+84`) напрямую:
- `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
- `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
- Считает `sizeCore`, находит tail.
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
### 10.6 Политика `mipSkip` (`sub_1000F580`)
`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
- если `mipCount <= 1` -> `0`
- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
- если `flags5Mask == 0x10000000` -> `1`
- если `flags5Mask == 0x20000000`:
- `1`, если `width >= 256` или `height >= 256`
- иначе `0`
- если `flags5Mask == 0x40000000`:
- если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
- если `width == 128` или `height == 128`: `1`
- иначе `0`
- иначе `0`
Применение в loader:
- `mipCount -= mipSkip`
- `width >>= mipSkip`, `height >>= mipSkip`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
---
## 11. Флаги профиля/рендера (Ngi32)
Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
Подтверждённые значения:
- `Disable MultiTexturing`
- `DisableMipmap`
- `Force 16-bit textures`
- `UseFirstCard`
- `DisableD3DCalls`
- `DisableDSound`
- `ForceCpu`
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
---
## 12. Спецификация для toolchain (read/edit/write)
### 12.1 Каноническая модель данных
1. `MAT0`:
- хранить исходные `attr1/attr2/attr3`;
- хранить сырой payload + декодированную структуру;
- при записи сохранять порядок/размеры секций точно.
2. `WEAR`:
- хранить строки wear/lightmaps как текст;
- сохранять порядок строк;
- допускать отсутствие блока `LIGHTMAPS`.
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
3. `Texm`:
- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
- хранить palette (если есть), mip data, `Page`.
### 12.2 Правила lossless записи
- Не менять значения `flags4/flags5/unk6` без явной причины.
- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
- Для `MAT0`:
- `animBlockCount < 20`.
- `phaseCount` и фактический размер секции должны совпадать.
- textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
- Для `Texm`:
- `magic == 'Texm'`.
- `mipCount > 0`, `width>0`, `height>0`.
- tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
### 12.3 Рекомендованные валидации редактора
- `WEAR`:
- `wearCount > 0`.
- число строк wear соответствует `wearCount`.
- если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
- `MAT0`:
- не выходить за payload при распаковке.
- все ссылки фаз/keys проверять на диапазоны.
- `Texm`:
- `sizeCore <= payload_size`.
- проверка `Page` как `8 + rectCount*8`.
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
---
## 13. Проверка на реальных данных (`tmp/gamedata`)
### 13.1 `Material.lib`
- `905` entries, все `type=MAT0`
- `attr2 = 6` у всех
- `attr3 = 0` у всех
- `phaseCount` до `29`
- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
### 13.2 `Textures.lib`
- `393` entries, все `type=Texm`
- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
- `flags4`: `32(361), 0(32)`
- `flags5`: `0(312), 0x04000000(81)`
- `Page` chunk присутствует у `65` текстур
### 13.3 `lightmap.lib`
- `25` entries, все `Texm`
- формат: `565`
- `mipCount=1`
- `flags5`: в основном `0`, встречается `0x00800000`
### 13.4 `WEAR`
- `439` entries `type=WEAR`
- `attr1=0, attr2=0, attr3=1`
- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
---
## 14. Opaque-поля и границы знания
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
- `MAT0`:
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
- `metaA/metaB/metaC/metaD``World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
- `Texm`:
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
Это не блокирует реализацию движка/конвертеров 1:1.
---
## 15. Минимальные псевдокоды для реализации
### 15.1 `parse_mat0(payload, attr2)`
```python
def parse_mat0(payload: bytes, attr2: int):
cur = 0
phase_count = u16(payload, cur); cur += 2
anim_count = u16(payload, cur); cur += 2
if anim_count >= 20:
raise ValueError("Too many animations for material")
if attr2 < 2:
metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
else:
metaA = u8(payload, cur); cur += 1
metaB = u8(payload, cur); cur += 1
metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
cur += 4 if attr2 >= 3 else 0
metaD = u32(payload, cur) if attr2 >= 4 else 0
cur += 4 if attr2 >= 4 else 0
phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
cur += 34 * phase_count
anim = []
for _ in range(anim_count):
raw = u32(payload, cur); cur += 4
key_count = u16(payload, cur); cur += 2
keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
cur += 6 * key_count
anim.append((raw, keys))
if cur != len(payload):
raise ValueError("MAT0 tail bytes")
return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
```
### 15.2 `parse_texm(payload)`
```python
def parse_texm(payload: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
if magic != 0x6D786554:
raise ValueError("not Texm")
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
pix = 0
mw, mh = w, h
for _ in range(mips):
pix += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
if core > len(payload):
raise ValueError("truncated")
page = None
if core < len(payload):
if core + 8 > len(payload) or payload[core:core+4] != b"Page":
raise ValueError("tail without Page")
n = u32(payload, core + 4)
need = 8 + n * 8
if core + need != len(payload):
raise ValueError("invalid Page size")
page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
```python
def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
if mip_count <= 1:
return 0
m = flags5 & 0x72000000
if m == 0x02000000:
return 2 if mip_count > 2 else 1
if m == 0x10000000:
return 1
if m == 0x20000000:
return 1 if (width >= 256 or height >= 256) else 0
if m == 0x40000000:
if width > 128 and height > 128:
return 2 if mip_count > 2 else 1
if width == 128 or height == 128:
return 1
return 0
```
### 15.4 `parse_wear_buffer_compatible(text)`
```python
def parse_wear_buffer_compatible(text: str):
lines = text.splitlines()
i = 0
wear_count = int(lines[i].strip()); i += 1
if wear_count <= 0:
raise ValueError("Illegal wear length.")
wear = []
for _ in range(wear_count):
legacy, name = lines[i].split(maxsplit=1)
wear.append((int(legacy), name.strip()))
i += 1
lightmaps = []
tail = lines[i:] if i < len(lines) else []
if tail and tail[0].strip() == "":
# sub_10003F80-совместимый разделитель перед LIGHTMAPS
i += 1
tail = lines[i:]
if tail and tail[0].strip().upper() == "LIGHTMAPS":
i += 1
if i >= len(lines):
raise ValueError("Illegal lightmaps length.")
light_count = int(lines[i].strip()); i += 1
if light_count <= 0:
raise ValueError("Illegal lightmaps length.")
for _ in range(light_count):
legacy, name = lines[i].split(maxsplit=1)
lightmaps.append((int(legacy), name.strip()))
i += 1
return wear, lightmaps
```
### 15.5 `select_phase_time_1to1(...)`
```python
def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
# keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
cycle_len = keys[-1][2]
if cycle_len <= 0:
return 0, 0.0
# unsigned div/mod как в runtime
delta = (game_time - start_time) & 0xFFFFFFFF
q = delta // cycle_len
r = delta % cycle_len
if mode == 1: # ping-pong
if q & 1:
r = cycle_len - r
elif mode == 2: # one-shot
if q > 0:
k = len(keys) - 1
return k, 0.0
elif mode == 3: # random
r = rand32() % cycle_len
start_time = r # side effect как в sub_100031F0
k = find_segment(keys, r) # t_start <= r < t_end
kn = 0 if (k + 1 == len(keys)) else (k + 1)
t0, t1 = keys[k][1], keys[k][2]
alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
return (k, kn), alpha
```

5
docs/specs/missions.md Normal file
View File

@@ -0,0 +1,5 @@
# Missions
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.

105
docs/specs/msh-animation.md Normal file
View File

@@ -0,0 +1,105 @@
# MSH animation
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
---
## 1.13. Ресурсы анимации: Res8 и Res19
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
- **Res19** — `uint16` mappingмассив «frame → keyIndex` (с per-node смещением).
### 1.13.1. Формат Res8 (ключ 24 байта)
```c
struct AnimKey24 {
float posX; // +0x00
float posY; // +0x04
float posZ; // +0x08
float time; // +0x0C
int16_t qx; // +0x10
int16_t qy; // +0x12
int16_t qz; // +0x14
int16_t qw; // +0x16
};
```
Декодирование quaternion-компонент:
```c
q = s16 * (1.0f / 32767.0f)
```
### 1.13.2. Формат Res19
Res19 читается как непрерывный массив `uint16`:
```c
uint16_t map[]; // размер = size(Res19)/2
```
Per-node управление mapping'ом берётся из заголовка узла Res1:
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
1) Вычислить frameиндекс:
```c
frame = (int64)(t - 0.5f); // x87 FISTP-путь
```
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
2) Проверка условий fallback:
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
- `mapStart == 0xFFFF`;
- `map[mapStart + frame] >= fallbackKeyIndex`.
Если любое условие истинно:
```c
keyIndex = fallbackKeyIndex;
```
Иначе:
```c
keyIndex = map[mapStart + frame];
```
3) Сэмплирование:
- `k0 = Res8[keyIndex]`
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
Пути:
- если `t == k0.time` → взять `k0`;
- если `t == k1.time` → взять `k1`;
- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastprocинтерполятор quaternion.
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
1) получить два `(quat, pos)` через `sub_10012880`;
2) выполнить shortestpath коррекцию знака quaternion:
```c
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
```
3) смешать quaternion (fastproc) и построить orientationматрицу;
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
### 1.13.5. Что хранится в `Res19.attr2`
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
Это поле используется как верхняя граница frameиндекса в п.1.13.3.
---

492
docs/specs/msh-core.md Normal file
View File

@@ -0,0 +1,492 @@
# MSH core
Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
Связанный формат контейнера: [NRes / RsLi](nres.md).
---
## 1.1. Общая архитектура
Модель состоит из набора именованных ресурсов внутри одного NResархива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtableметод `+0x18`) через связку `niFind` (vtableметод `+0x0C`, `+0x20`).
Рендер‑модель использует **rigidскининг по узлам** (нет pervertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
## 1.2. Общая структура файла модели
```
┌────────────────────────────────────┐
│ NResзаголовок (16 байт) │
├────────────────────────────────────┤
│ Ресурсы (произвольный порядок): │
│ Res1 — Node table │
│ Res2 — Model header + Slots │
│ Res3 — Vertex positions │
│ Res4 — Packed normals │
│ Res5 — Packed UV0 │
│ Res6 — Index buffer │
│ Res7 — Triangle descriptors │
│ Res8 — Keyframe data │
│ Res10 — String table │
│ Res13 — Batch table │
│ Res19 — Animation mapping │
│ [Res15] — UV1 / доп. поток │
│ [Res16] — Tangent/Bitangent │
│ [Res18] — Vertex color │
│ [Res20] — Доп. таблица │
├────────────────────────────────────┤
│ NResкаталог
└────────────────────────────────────┘
```
Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `1` при отсутствии).
## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
| Шаг | Тип ресурса | Поле структуры | Описание |
|-----|-------------|----------------|-----------------------------------------|
| 1 | 1 | `+0x00` | Node table (Res1) |
| 2 | 2 | `+0x04` | Model header (Res2) |
| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
| 4 | 4 | `+0x10` | Packed normals (Res4) |
| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
| 11 | 6 | `+0x2C` | Index buffer (Res6) |
| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
### Производные поля (вычисляются после загрузки)
| Поле | Формула | Описание |
|---------|-------------------------|------------------------------------------------------------------------------------------------|
| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
| `+0x48` | `= 4` | Stride нормалей: 4 байта |
| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
| `+0x60` | `= 4` | Stride UV0: 4 байта |
| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
| `+0x90` | `= 8` | Stride Res15: 8 байт |
| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
---
### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
- `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
- `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mappingтаблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
Ресурс Res2 содержит:
```
┌───────────────────────────────────┐ Смещение 0
│ Model Header (140 байт = 0x8C) │
├───────────────────────────────────┤ Смещение 140 (0x8C)
│ Slot Table │
│ (slot_count × 68 байт) │
└───────────────────────────────────┘
```
### 1.4.1. Model Header (первые 140 байт)
Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
Итоговая раскладка:
| Диапазон | Размер | Тип | Семантика |
|--------------|--------|-------------|----------------------------------------------------------------------|
| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального boundinghull (`vec3[8]`) |
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная boundingsphere: `center.xyz + radius` |
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
Для рендера и broadphase движок использует как слотbounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
### 1.4.2. Slot Table (массив записей по 68 байт)
Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hexэквивалент (например, 48 (0x30)).
| Смещение | Размер | Тип | Описание |
|-----------|--------|----------|-----------------------------------------------------|
| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
| 8 | 4 | float | `aabbMin.x` |
| 12 | 4 | float | `aabbMin.y` |
| 16 | 4 | float | `aabbMin.z` |
| 20 | 4 | float | `aabbMax.x` |
| 24 | 4 | float | `aabbMax.y` |
| 28 | 4 | float | `aabbMax.z` |
| 32 | 4 | float | `sphereCenter.x` |
| 36 | 4 | float | `sphereCenter.y` |
| 40 | 4 | float | `sphereCenter.z` |
| 44 (0x2C) | 4 | float | `sphereRadius` |
| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
**AABB** — axisaligned bounding box в локальных координатах узла.
**Bounding Sphere** — описанная сфера в локальных координатах узла.
#### 1.4.2.1. Точная семантика `triStart/triCount`
В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
```c
triId >= slot.triStart && triId < slot.triStart + slot.triCount
```
Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
Последние 20 байт записи слота трактуем как 5 последовательных 32битных значений (littleendian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
- `+48 (0x30)`: `unk30` (uint32)
- `+52 (0x34)`: `unk34` (uint32)
- `+56 (0x38)`: `unk38` (uint32)
- `+60 (0x3C)`: `unk3C` (uint32)
- `+64 (0x40)`: `unk40` (uint32)
Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
---
### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
- `node_count` = `size(Res1) / 38`
- `vertex_count` = `size(Res3) / 12`
- `normals_count` = `size(Res4) / 4`
- `uv0_count` = `size(Res5) / 4`
- `index_count` = `size(Res6) / 2`
- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
- `tri_desc_count` = `size(Res7) / 16`
- `batch_count` = `size(Res13) / 20`
- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
- `anim_key_count` = `size(Res8) / 24`
- `anim_map_count` = `size(Res19) / 2`
- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
**Валидация:**
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
**Проверка на реальных данных (435 MSH):**
- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
### Адресация слота
Движок вычисляет индекс слова в таблице:
```
word_index = nodeIndex × 19 + lod × 5 + group + 4
slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
```
Параметры:
- `lod`: 0..2 (три уровня детализации). Значение `1` → подставляется `current_lod` из инстанса.
- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
### Раскладка записи узла (38 байт)
```
┌───────────────────────────────────────────────────────┐
│ Header: 4 × uint16 (8 байт) │
│ hdr0, hdr1, hdr2, hdr3 │
├───────────────────────────────────────────────────────┤
│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
│ LOD 0: group[0..4] │
│ LOD 1: group[0..4] │
│ LOD 2: group[0..4] │
└───────────────────────────────────────────────────────┘
```
| Смещение | Размер | Тип | Описание |
|----------|--------|------------|-----------------------------------------|
| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
Подтверждённые семантики полей `hdr*`:
- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для mapзначений (используется в `sub_10012880`).
`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
---
## 1.6. Ресурс Res3 — Vertex Positions
**Формат:** массив `float3` (IEEE 754 singleprecision).
**Stride:** 12 байт.
```c
struct Position {
float x; // +0
float y; // +4
float z; // +8
};
```
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
---
## 1.7. Ресурс Res4 — Packed Normals
**Формат:** 4 байта на вершину.
**Stride:** 4 байта.
```c
struct PackedNormal {
int8_t nx; // +0
int8_t ny; // +1
int8_t nz; // +2
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
};
```
### Алгоритм декодирования (подтверждено по AniMesh.dll)
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
```
normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
```
**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
**Диапазон входных значений:** 128..+127 → выход ≈ 1.007874..+1.0 → **после клампа** 1.0..+1.0.
**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
**4й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
---
## 1.8. Ресурс Res5 — Packed UV0
**Формат:** 4 байта на вершину (два `int16`).
**Stride:** 4 байта.
```c
struct PackedUV {
int16_t u; // +0
int16_t v; // +2
};
```
### Алгоритм декодирования
```
uv.u = (float)u / 1024.0
uv.v = (float)v / 1024.0
```
**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
**Диапазон входных значений:** 32768..+32767 → выход ≈ 32.0..+31.999.
Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
### Алгоритм кодирования (для экспортёра)
```
packed_u = (int16_t)round(uv.u * 1024.0)
packed_v = (int16_t)round(uv.v * 1024.0)
```
Результат обрезается (clamp) до диапазона `int16` (32768..+32767).
---
## 1.9. Ресурс Res6 — Index Buffer
**Формат:** массив `uint16` (беззнаковые 16битные индексы).
**Stride:** 2 байта.
Максимальное число вершин в одном batch: 65535.
Индексы используются совместно с `baseVertex` из batch table:
```
actual_vertex_index = index_buffer[indexStart + i] + baseVertex
```
---
## 1.10. Ресурс Res7 — Triangle Descriptors
**Формат:** массив записей по 16 байт. Одна запись на треугольник.
| Смещение | Размер | Тип | Описание |
|----------|--------|----------|---------------------------------------------|
| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал triуровня |
| `+0x02` | 2 | `uint16` | `linkTri0` — triref для связанного обхода |
| `+0x04` | 2 | `uint16` | `linkTri1` — triref для связанного обхода |
| `+0x06` | 2 | `uint16` | `linkTri2` — triref для связанного обхода |
| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
```c
sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
```
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
---
## 1.11. Ресурс Res13 — Batch Table
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------------------------------|
| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
| 2 | 2 | uint16 | `materialIndex` — индекс материала |
| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
### Использование при рендере
```
for i in 0 .. indexCount-1:
raw_index = index_buffer[indexStart + i]
vertex_index = raw_index + baseVertex
position = res3[vertex_index]
normal = decode_normal(res4[vertex_index])
uv = decode_uv(res5[vertex_index])
```
**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
---
## 1.12. Ресурс Res10 — String Table
Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
Формат одной записи:
```c
struct Res10Record {
uint32_t len; // число символов без терминирующего '\0'
char text[]; // если len > 0: хранится len+1 байт (включая '\0')
// если len == 0: payload отсутствует
};
```
Переход к следующей записи:
```c
next = cur + 4 + (len ? (len + 1) : 0);
```
`sub_10012530` возвращает:
- `NULL`, если `len == 0`;
- `record + 4`, если `len > 0` (указатель на Cстроку).
Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке nodeфлагов).
---
---
## 1.14. Опциональные vertex streams
### Res15 — Дополнительный vertex stream (stride 8)
- **Stride:** 8 байт на вершину.
- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UVпары), либо иной формат.
- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
### Res16 — Tangent / Bitangent (stride 8, split 2×4)
- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
- Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
- Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
- Если ресурс 16 отсутствует, оба указателя обнуляются.
- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
### Res18 — Vertex Color (stride 4)
- **Stride:** 4 байта на вершину.
- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
- Загружается условно (через проверку `niFindRes` на возврат `1`).
### Res20 — Дополнительная таблица
- Присутствует не всегда.
- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
---

277
docs/specs/msh-notes.md Normal file
View File

@@ -0,0 +1,277 @@
# 3D implementation notes
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
---
## 5.1. Порядок байт
Все значения хранятся в **littleendian** порядке (платформа x86/Win32).
## 5.2. Выравнивание
- **NResресурсы:** данные каждого ресурса внутри NResархива выровнены по границе **8 байт** (0padding).
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
## 5.3. Размеры записей на диске
| Ресурс | Запись | Размер (байт) | Stride |
|--------|-----------|---------------|-------------------------|
| Res1 | Node | 38 | 38 (19×u16) |
| Res2 | Slot | 68 | 68 |
| Res3 | Position | 12 | 12 (3×f32) |
| Res4 | Normal | 4 | 4 (4×s8) |
| Res5 | UV0 | 4 | 4 (2×s16) |
| Res6 | Index | 2 | 2 (u16) |
| Res7 | TriDesc | 16 | 16 |
| Res8 | AnimKey | 24 | 24 |
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
| Res13 | Batch | 20 | 20 |
| Res19 | AnimMap | 2 | 2 (u16) |
| Res15 | VtxStr | 8 | 8 |
| Res16 | VtxStr | 8 | 8 (2×4) |
| Res18 | VtxStr | 4 | 4 |
## 5.4. Вычисление количества элементов
Количество записей вычисляется из размера ресурса:
```
count = resource_data_size / record_stride
```
Например:
- `vertex_count = res3_size / 12`
- `index_count = res6_size / 2`
- `batch_count = res13_size / 20`
- `slot_count = (res2_size - 140) / 68`
- `node_count = res1_size / 38`
- `tri_desc_count = res7_size / 16`
- `anim_key_count = res8_size / 24`
- `anim_map_count = res19_size / 2`
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
## 5.5. Идентификация ресурсов в NRes
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
## 5.6. Минимальный набор для рендера
Для статической модели без анимации достаточно:
| Ресурс | Обязательность |
|--------|------------------------------------------------|
| Res1 | Да |
| Res2 | Да |
| Res3 | Да |
| Res4 | Рекомендуется |
| Res5 | Рекомендуется |
| Res6 | Да |
| Res7 | Для коллизии |
| Res13 | Да |
| Res10 | Желательно (узловые имена/поведенческие ветки) |
| Res8 | Нет (анимация) |
| Res19 | Нет (анимация) |
| Res15 | Нет |
| Res16 | Нет |
| Res18 | Нет |
| Res20 | Нет |
## 5.7. Сводка алгоритмов декодирования
### Позиции (Res3)
```python
def decode_position(data, vertex_index):
offset = vertex_index * 12
x = struct.unpack_from('<f', data, offset)[0]
y = struct.unpack_from('<f', data, offset + 4)[0]
z = struct.unpack_from('<f', data, offset + 8)[0]
return (x, y, z)
```
### Нормали (Res4)
```python
def decode_normal(data, vertex_index):
offset = vertex_index * 4
nx = struct.unpack_from('<b', data, offset)[0] # int8
ny = struct.unpack_from('<b', data, offset + 1)[0]
nz = struct.unpack_from('<b', data, offset + 2)[0]
# nw = data[offset + 3] # не используется
return (
max(-1.0, min(1.0, nx / 127.0)),
max(-1.0, min(1.0, ny / 127.0)),
max(-1.0, min(1.0, nz / 127.0)),
)
```
### UVкоординаты (Res5)
```python
def decode_uv(data, vertex_index):
offset = vertex_index * 4
u = struct.unpack_from('<h', data, offset)[0] # int16
v = struct.unpack_from('<h', data, offset + 2)[0]
return (u / 1024.0, v / 1024.0)
```
### Кодирование нормали (для экспортёра)
```python
def encode_normal(nx, ny, nz):
return (
max(-128, min(127, int(round(nx * 127.0)))),
max(-128, min(127, int(round(ny * 127.0)))),
max(-128, min(127, int(round(nz * 127.0)))),
0 # nw = 0 (безопасное значение)
)
```
### Кодирование UV (для экспортёра)
```python
def encode_uv(u, v):
return (
max(-32768, min(32767, int(round(u * 1024.0)))),
max(-32768, min(32767, int(round(v * 1024.0))))
)
```
### Строки узлов (Res10)
```python
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
out = []
off = 0
for _ in range(node_count):
ln = struct.unpack_from('<I', buf, off)[0]
off += 4
if ln == 0:
out.append(None)
continue
raw = buf[off:off + ln + 1] # len + '\0'
out.append(raw[:-1].decode('ascii', errors='replace'))
off += ln + 1
return out
```
### Ключ анимации (Res8) и mapping (Res19)
```python
def decode_anim_key24(buf: bytes, idx: int):
o = idx * 24
px, py, pz, t = struct.unpack_from('<4f', buf, o)
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
s = 1.0 / 32767.0
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
```
### Эффектный поток (FXID)
```python
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
def parse_fx_payload(raw: bytes):
cmd_count = struct.unpack_from('<I', raw, 0)[0]
ptr = 0x3C
cmds = []
for _ in range(cmd_count):
w = struct.unpack_from('<I', raw, ptr)[0]
op = w & 0xFF
enabled = (w >> 8) & 1
size = FX_CMD_SIZE[op]
cmds.append((op, enabled, ptr, size))
ptr += size
if ptr != len(raw):
raise ValueError('tail bytes after command stream')
return cmds
```
### Texm (header + mips + Page)
```python
def parse_texm(raw: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
assert magic == 0x6D786554 # 'Texm'
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
pix_sum = 0
mw, mh = w, h
for _ in range(mips):
pix_sum += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
page = None
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
n = struct.unpack_from('<I', raw, off + 4)[0]
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
---
# Часть 6. Остаточные семантические вопросы
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
## 6.1. Batch table — смысл `unk4/unk6/unk14`
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
- `unk4` (`+0x04`)
- `unk6` (`+0x06`)
- `unk14` (`+0x0E`)
## 6.2. Node flags и имена групп
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
- Для groupиндекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
## 6.3. Slot tail `unk30..unk40`
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
## 6.4. Effect command payload semantics
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1поведения при импорте внешней геометрии обратно в формат игры.
### A) Неполная «авторская» семантика бинарных таблиц
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
2. `Res7` tri-descriptor: для 16байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
### B) Анимационный path ещё не закрыт как writer
1. Нужен полный writer для `Res8/Res19`:
- точная спецификация байтового формата на запись;
- правила генерации mapping (`Res19`) по узлам/кадрам;
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtimeсущностям пока описаны частично и требуют формализации именно для импорта новых данных.
### C) Материалы, текстуры, эффекты для «полного ассета»
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
### D) Что это означает на практике
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
- геометрия экспортируется из `OBJ`;
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.

File diff suppressed because it is too large Load Diff

5
docs/specs/network.md Normal file
View File

@@ -0,0 +1,5 @@
# Network system
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.

View File

@@ -10,9 +10,9 @@
--- ---
# Часть 1. Формат NRes ## Часть 1. Формат NRes
## 1.1. Общая структура файла ### 1.1. Общая структура файла
``` ```
┌──────────────────────────┐ Смещение 0 ┌──────────────────────────┐ Смещение 0
@@ -28,7 +28,7 @@
└──────────────────────────┘ Смещение = total_size └──────────────────────────┘ Смещение = total_size
``` ```
## 1.2. Заголовок файла (16 байт) ### 1.2. Заголовок файла (16 байт)
| Смещение | Размер | Тип | Значение | Описание | | Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ------------------- | ------------------------------------ | | -------- | ------ | ------- | ------------------- | ------------------------------------ |
@@ -39,7 +39,7 @@
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется. **Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
## 1.3. Положение каталога в файле ### 1.3. Положение каталога в файле
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле: Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
@@ -49,7 +49,7 @@ directory_offset = total_size - entry_count × 64
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом. Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
## 1.4. Запись каталога (64 байта) ### 1.4. Запись каталога (64 байта)
Каждая запись каталога занимает ровно **64 байта** (0x40): Каждая запись каталога занимает ровно **64 байта** (0x40):
@@ -64,23 +64,23 @@ directory_offset = total_size - entry_count × 64
| 56 | 4 | uint32 | Смещение данных от начала файла | | 56 | 4 | uint32 | Смещение данных от начала файла |
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) | | 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
### Поле «Имя файла» (смещение 20, 36 байт) #### Поле «Имя файла» (смещение 20, 36 байт)
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор. - Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов). - При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`). - Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
### Поле «Индекс сортировки» (смещение 60) #### Поле «Индекс сортировки» (смещение 60)
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам. Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии. **Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
### Поле «Смещение данных» (смещение 56) #### Поле «Смещение данных» (смещение 56)
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`. Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
## 1.5. Выравнивание данных ### 1.5. Выравнивание данных
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**: При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
@@ -93,7 +93,7 @@ padding = ((data_size + 7) & ~7) - data_size;
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога. При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
## 1.6. Создание файла (API `niCreateResFile`) ### 1.6. Создание файла (API `niCreateResFile`)
При создании нового файла: При создании нового файла:
@@ -107,7 +107,7 @@ padding = ((data_size + 7) & ~7) - data_size;
3. Индексы сортировки пересчитываются. 3. Индексы сортировки пересчитываются.
4. Каталог записей записывается в конец файла. 4. Каталог записей записывается в конец файла.
## 1.7. Режимы сортировки каталога ### 1.7. Режимы сортировки каталога
Функция `sub_10012560` поддерживает 12 режимов сортировки (011): Функция `sub_10012560` поддерживает 12 режимов сортировки (011):
@@ -126,7 +126,7 @@ padding = ((data_size + 7) & ~7) - data_size;
| 10 | По (атрибут 1, имя) | | 10 | По (атрибут 1, имя) |
| 11 | По (атрибут 2, имя) | | 11 | По (атрибут 2, имя) |
## 1.8. Операция `niOpenResFileEx` — флаги открытия ### 1.8. Операция `niOpenResFileEx` — флаги открытия
Второй параметр — битовые флаги: Второй параметр — битовые флаги:
@@ -137,7 +137,7 @@ padding = ((data_size + 7) & ~7) - data_size;
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) | | 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс | | 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
## 1.9. Виртуальное касание страниц ### 1.9. Виртуальное касание страниц
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ): Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
@@ -149,9 +149,9 @@ for (result = 0x10000; result < size; result += 4096);
--- ---
# Часть 2. Формат RsLi ## Часть 2. Формат RsLi
## 2.1. Общая структура файла ### 2.1. Общая структура файла
``` ```
┌───────────────────────────────┐ Смещение 0 ┌───────────────────────────────┐ Смещение 0
@@ -168,7 +168,7 @@ for (result = 0x10000; result < size; result += 4096);
└───────────────────────────────┘ └───────────────────────────────┘
``` ```
## 2.2. Заголовок файла (32 байта) ### 2.2. Заголовок файла (32 байта)
| Смещение | Размер | Тип | Значение | Описание | | Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ----------------- | --------------------------------------------- | | -------- | ------ | ------- | ----------------- | --------------------------------------------- |
@@ -182,16 +182,16 @@ for (result = 0x10000; result < size; result += 4096);
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) | | 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
| 24 | 8 | — | — | Зарезервировано | | 24 | 8 | — | — | Зарезервировано |
### Флаг предсортировки (смещение 14) #### Флаг предсортировки (смещение 14)
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XORключ для данных). - Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XORключ для данных).
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XORключ (младшие 16 бит). - Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XORключ (младшие 16 бит).
## 2.3. XOR-шифр таблицы записей ### 2.3. XOR-шифр таблицы записей
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка. Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
### Начальное состояние #### Начальное состояние
``` ```
seed = *(uint32*)(header + 20) seed = *(uint32*)(header + 20)
@@ -199,7 +199,7 @@ lo = seed & 0xFF // Младший байт
hi = (seed >> 8) & 0xFF // Второй байт hi = (seed >> 8) & 0xFF // Второй байт
``` ```
### Алгоритм дешифровки (побайтовый) #### Алгоритм дешифровки (побайтовый)
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`: Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
@@ -225,7 +225,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи. Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
## 2.4. Запись таблицы (32 байта, на диске, до дешифровки) ### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
После дешифровки каждая запись имеет следующую структуру: После дешифровки каждая запись имеет следующую структуру:
@@ -239,13 +239,13 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) | | 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) | | 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
### Имена ресурсов #### Имена ресурсов
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`. - Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно. - На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён. - `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
### Поле `sort_to_original[i]` (смещение 18) #### Поле `sort_to_original[i]` (смещение 18)
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск: Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
@@ -254,7 +254,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`). Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
## 2.5. Поле флагов (смещение 16 записи) ### 2.5. Поле флагов (смещение 16 записи)
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты: Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
@@ -263,7 +263,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше) Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
``` ```
### Методы сжатия (биты 85, маска 0x1E0) #### Методы сжатия (биты 85, маска 0x1E0)
| Значение | Hex | Описание | | Значение | Hex | Описание |
| -------- | ----- | --------------------------------------- | | -------- | ----- | --------------------------------------- |
@@ -281,13 +281,13 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
- для 0x60 вернётся 0x40, - для 0x60 вернётся 0x40,
- для 0xA0 вернётся 0x80. - для 0xA0 вернётся 0x80.
### Бит 0x40 (выделение +0x12 и последующее `realloc`) #### Бит 0x40 (выделение +0x12 и последующее `realloc`)
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`. Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически. Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
## 2.6. Размеры данных ### 2.6. Размеры данных
В каждой записи на диске хранятся оба значения: В каждой записи на диске хранятся оба значения:
@@ -300,7 +300,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера. Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
## 2.7. Опциональный трейлер медиа (6 байт) ### 2.7. Опциональный трейлер медиа (6 байт)
При открытии с флагом `a2 & 2`: При открытии с флагом `a2 & 2`:
@@ -313,9 +313,9 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
--- ---
# Часть 3. Алгоритмы сжатия (формат RsLi) ## Часть 3. Алгоритмы сжатия (формат RsLi)
## 3.1. XOR-шифр данных (метод 0x20) ### 3.1. XOR-шифр данных (метод 0x20)
Алгоритм идентичен XORшифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит). Алгоритм идентичен XORшифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
@@ -324,7 +324,7 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
- В ветке **0x20** движок XORит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`). - В ветке **0x20** движок XORит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией. - В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
### Инициализация #### Инициализация
``` ```
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18 key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
@@ -332,7 +332,7 @@ lo = key16 & 0xFF
hi = (key16 >> 8) & 0xFF hi = (key16 >> 8) & 0xFF
``` ```
### Дешифровка (псевдокод) #### Дешифровка (псевдокод)
``` ```
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0) for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
@@ -341,11 +341,11 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
``` ```
## 3.2. LZSS — простой вариант (метод 0x40) ### 3.2. LZSS — простой вариант (метод 0x40)
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером. Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
### Параметры #### Параметры
| Параметр | Значение | | Параметр | Значение |
| ----------------------------- | ------------------ | | ----------------------------- | ------------------ |
@@ -355,7 +355,7 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
| Минимальная длина совпадения | 3 | | Минимальная длина совпадения | 3 |
| Максимальная длина совпадения | 18 (4 бита + 3) | | Максимальная длина совпадения | 18 (4 бита + 3) |
### Алгоритм декомпрессии #### Алгоритм декомпрессии
``` ```
Инициализация: Инициализация:
@@ -400,7 +400,7 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
4. flags_bits_remaining -= 1 4. flags_bits_remaining -= 1
``` ```
### Подробная раскладка пары ссылки (2 байта) #### Подробная раскладка пары ссылки (2 байта)
``` ```
Байт 0 (low): OOOOOOOO (биты [7:0] смещения) Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
@@ -410,11 +410,11 @@ offset = low | ((high & 0xF0) << 4) // Диапазон: 04095
length = (high & 0x0F) + 3 // Диапазон: 318 length = (high & 0x0F) + 3 // Диапазон: 318
``` ```
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80) ### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана. Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
### Параметры #### Параметры
| Параметр | Значение | | Параметр | Значение |
| -------------------------------- | ------------------------------ | | -------------------------------- | ------------------------------ |
@@ -427,7 +427,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
| Начальная длина | 3 (при символе 256) | | Начальная длина | 3 (при символе 256) |
| Максимальная длина | 60 (при символе 313) | | Максимальная длина | 60 (при символе 313) |
### Дерево Хаффмана #### Дерево Хаффмана
Дерево строится как **адаптивное** (dynamic, self-adjusting): Дерево строится как **адаптивное** (dynamic, self-adjusting):
@@ -437,7 +437,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства. - После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается. - При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
### Кодирование позиции #### Кодирование позиции
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций): Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
@@ -455,7 +455,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 } { 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
``` ```
### Алгоритм декомпрессии (высокоуровневый) #### Алгоритм декомпрессии (высокоуровневый)
``` ```
Инициализация: Инициализация:
@@ -489,11 +489,11 @@ length = (high & 0x0F) + 3 // Диапазон: 318
5. Если выходной буфер заполнен → завершить 5. Если выходной буфер заполнен → завершить
``` ```
## 3.4. XOR + LZSS (методы 0x60 и 0xA0) ### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия. Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
### Алгоритм #### Алгоритм
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28). 1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер. 2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
@@ -503,22 +503,22 @@ length = (high & 0x0F) + 3 // Диапазон: 318
- **0x60** — XOR + простой LZSS (раздел 3.2) - **0x60** — XOR + простой LZSS (раздел 3.2)
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3) - **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
### Начальное состояние XOR для данных #### Начальное состояние XOR для данных
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`. При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
## 3.5. Deflate (метод 0x100) ### 3.5. Deflate (метод 0x100)
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой. Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
### Общая структура #### Общая структура
Данные состоят из последовательности блоков. Каждый блок начинается с: Данные состоят из последовательности блоков. Каждый блок начинается с:
- **1 бит** — `is_final`: признак последнего блока - **1 бит** — `is_final`: признак последнего блока
- **2 бита** — `block_type`: тип блока - **2 бита** — `block_type`: тип блока
### Типы блоков #### Типы блоков
| block_type | Описание | Функция | | block_type | Описание | Функция |
| ---------- | --------------------------- | ---------------- | | ---------- | --------------------------- | ---------------- |
@@ -527,7 +527,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
| 2 | Динамические коды Хаффмана | `sub_1001AA30` | | 2 | Динамические коды Хаффмана | `sub_1001AA30` |
| 3 | Зарезервировано (ошибка) | Возвращает код 2 | | 3 | Зарезервировано (ошибка) | Возвращает код 2 |
### Блок типа 0 (stored) #### Блок типа 0 (stored)
1. Отбросить оставшиеся биты до границы байта (выравнивание). 1. Отбросить оставшиеся биты до границы байта (выравнивание).
2. Прочитать 16 бит — `LEN` (длина блока). 2. Прочитать 16 бит — `LEN` (длина блока).
@@ -537,7 +537,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата. Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
### Блок типа 1 (фиксированные коды) #### Блок типа 1 (фиксированные коды)
Стандартные коды Deflate: Стандартные коды Deflate:
@@ -550,7 +550,7 @@ length = (high & 0x0F) + 3 // Диапазон: 318
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты). Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
### Блок типа 2 (динамические коды) #### Блок типа 2 (динамические коды)
1. Прочитать 5 бит → `HLIT` (количество литералов/длин 257). Диапазон: 257286. 1. Прочитать 5 бит → `HLIT` (количество литералов/длин 257). Диапазон: 257286.
2. Прочитать 5 бит → `HDIST` (количество дистанций 1). Диапазон: 130. 2. Прочитать 5 бит → `HDIST` (количество дистанций 1). Диапазон: 130.
@@ -569,21 +569,21 @@ length = (high & 0x0F) + 3 // Диапазон: 318
Хранится в `dword_10037060`. Хранится в `dword_10037060`.
### Валидации #### Валидации
- `HLIT + 257 <= 286` (max 0x11E) - `HLIT + 257 <= 286` (max 0x11E)
- `HDIST + 1 <= 30` (max 0x1E) - `HDIST + 1 <= 30` (max 0x1E)
- При нарушении — возвращается ошибка 1. - При нарушении — возвращается ошибка 1.
## 3.6. Метод 0x00 (без сжатия) ### 3.6. Метод 0x00 (без сжатия)
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог). Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
--- ---
# Часть 4. Внутренние структуры в памяти ## Часть 4. Внутренние структуры в памяти
## 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104) ### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
```c ```c
struct NResArchive { // Размер: 0x68 (104 байта) struct NResArchive { // Размер: 0x68 (104 байта)
@@ -601,7 +601,7 @@ struct NResArchive { // Размер: 0x68 (104 байта)
}; };
``` ```
## 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт) ### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
```c ```c
struct RsLibHeader { // 56 байт (14 DWORD) struct RsLibHeader { // 56 байт (14 DWORD)
@@ -623,7 +623,7 @@ struct RsLibHeader { // 56 байт (14 DWORD)
// Далее следуют entry_count записей по 64 байта каждая // Далее следуют entry_count записей по 64 байта каждая
``` ```
### Внутренняя запись RsLi (64 байта) #### Внутренняя запись RsLi (64 байта)
```c ```c
struct RsLibEntry { // 64 байта (16 DWORD) struct RsLibEntry { // 64 байта (16 DWORD)
@@ -643,9 +643,9 @@ struct RsLibEntry { // 64 байта (16 DWORD)
--- ---
# Часть 5. Экспортируемые API-функции ## Часть 5. Экспортируемые API-функции
## 5.1. NRes API ### 5.1. NRes API
| Функция | Описание | | Функция | Описание |
| ------------------------------ | ------------------------------------------------------------------------- | | ------------------------------ | ------------------------------------------------------------------------- |
@@ -654,7 +654,7 @@ struct RsLibEntry { // 64 байта (16 DWORD)
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти | | `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи | | `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
## 5.2. RsLi API ### 5.2. RsLi API
| Функция | Описание | | Функция | Описание |
| ------------------------------- | -------------------------------------------------------- | | ------------------------------- | -------------------------------------------------------- |
@@ -675,38 +675,38 @@ struct RsLibEntry { // 64 байта (16 DWORD)
--- ---
# Часть 6. Контрольные заметки для реализации ## Часть 6. Контрольные заметки для реализации
## 6.1. Кодировки и регистр ### 6.1. Кодировки и регистр
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`). - **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк). - **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
## 6.2. Порядок байт ### 6.2. Порядок байт
Все значения хранятся в **little-endian** порядке (платформа x86/Win32). Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
## 6.3. Выравнивание ### 6.3. Выравнивание
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами). - **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд). - **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
## 6.4. Размер записей на диске ### 6.4. Размер записей на диске
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла. - **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка). - **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
## 6.5. Кэширование и memory mapping ### 6.5. Кэширование и memory mapping
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`). Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
## 6.6. Размер seed XOR ### 6.6. Размер seed XOR
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`). - **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи. - **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта. - **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
## 6.7. Эмпирическая проверка на данных игры ### 6.7. Эмпирическая проверка на данных игры
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2). - Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно. - Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.

View File

@@ -0,0 +1,123 @@
# Runtime pipeline
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
---
## 1.15. Алгоритм рендера модели (реконструкция)
```
Вход: model, instanceTransform, cameraFrustum
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
2. Для каждого node (nodeIndex = 0 .. nodeCount1):
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
если slotIndex == 0xFFFF → пропустить узел
c. slot = slotTable[slotIndex]
d. // Frustum culling:
transformedAABB = transform(slot.aabb, nodeTransform)
если transformedAABB вне cameraFrustum → пропустить
// Альтернативно по сфере:
transformedCenter = nodeTransform × slot.sphereCenter
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
если сфера вне frustum → пропустить
e. Для i = 0 .. slot.batchCount 1:
batch = batchTable[slot.batchStart + i]
// Фильтрация по batchFlags (если нужна)
// Установить материал:
setMaterial(batch.materialIndex)
// Установить transform:
setWorldMatrix(nodeTransform)
// Нарисовать:
DrawIndexedPrimitive(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount,
primitiveType = TRIANGLE_LIST
)
```
---
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
```
Вход: model, nodeIndex, lod, group, filterMask, callback
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
если slotIndex == 0xFFFF → выход
2. slot = slotTable[slotIndex]
triDescIndex = slot.triStart
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount 1]:
batch = batchTable[batchIndex]
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
Для t = 0 .. triCount 1:
triDesc = triDescTable[triDescIndex]
// Фильтрация:
если (triDesc.triFlags & filterMask) → пропустить
// Получить индексы вершин:
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
// Получить позиции:
p0 = positions[idx0]
p1 = positions[idx1]
p2 = positions[idx2]
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
triDescIndex += 1
```
---
---
## 3.1. Архитектурный обзор
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
### Экспорты Effect.dll
| Функция | Описание |
|----------------------|--------------------------------------------------------|
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
| `InitializeSettings` | Инициализировать настройки эффектов |
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
### Телеметрия из Terrain.dll
Terrain.dll содержит отладочную статистику рендера:
```
"Rendered meshes : %d"
"Rendered primitives : %d"
"Rendered faces : %d"
"Rendered particles/batches : %d/%d"
```
Из этого следует:
- Частицы рендерятся **батчами** (группами).
- Статистика частиц отделена от статистики мешей.
- Частицы интегрированы в общий 3Dрендерпайплайн.

5
docs/specs/sound.md Normal file
View File

@@ -0,0 +1,5 @@
# Sound system
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.

View File

@@ -0,0 +1,32 @@
# Terrain + map loading
Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
---
## 4.1. Обзор
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
- Рендер частиц (`"Rendered particles/batches"`).
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
- Микротекстуры (`"Unable to find microtexture mapping"`).
## 4.2. Текстуры ландшафта
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
- Валидация размера текстуры (`"Unsupported texture size"`).
- Создание D3Dтекстуры (`"Unable to create texture"`).
Ландшафт использует **микротекстуры** (microtexture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
## 4.3. Защита от пустых примитивов
Terrain.dll содержит проверки:
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).

5
docs/specs/ui.md Normal file
View File

@@ -0,0 +1,5 @@
# UI system
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.

View File

@@ -10,7 +10,7 @@ repo_name: valentineus/fparkan
repo_url: https://github.com/valentineus/fparkan repo_url: https://github.com/valentineus/fparkan
# Copyright # Copyright
copyright: Copyright &copy; 2023 &mdash; 2024 Valentin Popov copyright: Copyright &copy; 2023 &mdash; 2026 Valentin Popov
# Configuration # Configuration
theme: theme:
@@ -23,8 +23,23 @@ theme:
nav: nav:
- Home: index.md - Home: index.md
- Specs: - Specs:
- 3D implementation notes: specs/msh-notes.md
- AI system: specs/ai.md
- ArealMap: specs/arealmap.md
- Behavior system: specs/behavior.md
- Control system: specs/control.md
- FXID: specs/fxid.md
- Materials + Texm: specs/materials-texm.md
- Missions: specs/missions.md
- MSH animation: specs/msh-animation.md
- MSH core: specs/msh-core.md
- Network system: specs/network.md
- NRes / RsLi: specs/nres.md - NRes / RsLi: specs/nres.md
- Форматы 3Dресурсов: specs/msh.md - Runtime pipeline: specs/runtime-pipeline.md
- Sound system: specs/sound.md
- Terrain + map loading: specs/terrain-map-loading.md
- UI system: specs/ui.md
- Форматы 3Dресурсов (обзор): specs/msh.md
# Additional configuration # Additional configuration
extra: extra: