fix(ffi,core): harden FFI ownership and cache indexing

- bump version to 0.3.1
- return owned C strings for archive entry paths and add matching free function
- intern schema and cache string pointers in owning arenas
- clarify nested schema ownership docs and generated header comments
- propagate BA2 DX10 writer add errors through the FFI
- normalize not-found out_len and last_error behavior
- load cbindgen config explicitly and commit generated bethkit.h
- guard SliceCursor offset arithmetic against overflow
- remove stale PluginCache signature index entries on cross-type overrides
- move executable integration tests into crate-level test suites

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Wuerfelhusten 2026-05-04 13:35:30 +02:00
parent bdf2f99f4e
commit 3955dd9e44
17 changed files with 2435 additions and 2835 deletions

View file

@ -80,9 +80,20 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# Unit tests only — integration tests require real plugin/archive files.
- name: Run unit tests
run: cargo test --workspace --lib
# Unit tests and integration tests (live tests self-skip when game files
# are absent, so --all-targets is safe on CI).
- name: Run tests
run: cargo test --workspace --all-targets
# Doc tests run only on Linux to keep the matrix lean.
- name: Run doc tests
if: matrix.os == 'ubuntu-latest'
run: cargo test --doc --workspace
# Verify that all public items compile with rustdoc (Linux only).
- name: Check documentation
if: matrix.os == 'ubuntu-latest'
run: cargo doc --workspace --no-deps
- name: Build release DLL
run: cargo build --release -p bethkit-ffi --features generate-header

3
.gitignore vendored
View file

@ -10,9 +10,6 @@ tests/testdata/*.ba2
# Rust build artefacts
/target/
# cbindgen-generated C header (regenerated by build.rs)
crates/bethkit-ffi/bethkit.h
# Planning notes — local only
planning/

View file

@ -4,9 +4,5 @@ pragma_once = false
autogen_warning = "/* Auto-generated by cbindgen — do not edit */"
tab_width = 4
[export]
prefix = "bethkit"
[fn]
prefix = "bethkit_"
rename_args = "snake_case"

View file

@ -55,9 +55,12 @@ pub struct CacheEntry {
pub struct PluginCache {
entries: Vec<CacheEntry>,
load_order: LoadOrder,
/// Maps each indexed [`GlobalFormId`] to `(entry_index, raw_form_id)`.
/// Maps each indexed [`GlobalFormId`] to `(entry_index, raw_form_id, signature)`.
/// Later entries overwrite earlier ones — winning-override semantics.
by_global_id: HashMap<GlobalFormId, (usize, FormId)>,
/// The stored signature enables correct removal of stale entries from
/// `by_signature` when a later plugin overrides a record with a different
/// record type.
by_global_id: HashMap<GlobalFormId, (usize, FormId, Signature)>,
/// Maps each 4-byte record signature to the list of [`GlobalFormId`]s
/// for that record type in the winning-override set. Built incrementally
/// in [`Self::add`].
@ -153,20 +156,18 @@ impl PluginCache {
// Update the winning-override index. Later entries overwrite earlier.
for (gfid, raw_fid, sig) in triples {
// Remove stale signature index entry for the previous winner.
if let Some(&(_, prev_fid)) = self.by_global_id.get(&gfid) {
if let Some(sig_list) = self.by_signature.get_mut(&sig) {
// NOTE: This is O(k) where k = records of that type.
// For large plugins this may be slow, but it only fires
// when the same GlobalFormId is overridden by a later
// plugin, which is the minority case.
let _ = prev_fid; // suppress unused warning
// Remove the stale entry from the OLD signature's list if this
// GlobalFormId was already indexed. Using the stored old signature
// (not the new sig) ensures we clean the correct bucket even when
// the override changes the record type (e.g. NPC_ -> WEAP).
if let Some(&(_, _, old_sig)) = self.by_global_id.get(&gfid) {
if let Some(sig_list) = self.by_signature.get_mut(&old_sig) {
sig_list.retain(|g| g != &gfid);
}
}
self.by_signature.entry(sig).or_default().push(gfid.clone());
self.by_global_id.insert(gfid, (entry_index, raw_fid));
self.by_global_id.insert(gfid, (entry_index, raw_fid, sig));
}
// Invalidate the EditorID cache so it is rebuilt on next access.
@ -181,7 +182,7 @@ impl PluginCache {
///
/// * `gfid` - The game-unique FormID to look up.
pub fn resolve_record(&self, gfid: &GlobalFormId) -> Option<&Record> {
let &(entry_idx, raw_fid) = self.by_global_id.get(gfid)?;
let &(entry_idx, raw_fid, _) = self.by_global_id.get(gfid)?;
self.entries[entry_idx].plugin.find_record(raw_fid)
}
@ -196,7 +197,7 @@ impl PluginCache {
///
/// * `edid` - The EditorID to find (case-sensitive).
pub fn find_by_editor_id(&self, edid: &str) -> Option<(&GlobalFormId, &Record)> {
let by_global_id: &HashMap<GlobalFormId, (usize, FormId)> = &self.by_global_id;
let by_global_id: &HashMap<GlobalFormId, (usize, FormId, Signature)> = &self.by_global_id;
let entries: &[CacheEntry] = &self.entries;
let index: &HashMap<String, GlobalFormId> = self.by_editor_id.get_or_init(|| {
@ -206,7 +207,7 @@ impl PluginCache {
// records that are in the winning set.
let mut winning_by_entry: HashMap<usize, HashMap<FormId, GlobalFormId>> =
HashMap::default();
for (gfid, &(entry_idx, raw_fid)) in by_global_id {
for (gfid, &(entry_idx, raw_fid, _)) in by_global_id {
winning_by_entry
.entry(entry_idx)
.or_default()
@ -464,4 +465,30 @@ mod tests {
assert!(cache.find_by_editor_id("SecondNpc").is_some());
Ok(())
}
/// Verifies that overriding a record with a different signature correctly
/// removes the stale entry from the old type's records_of_type list.
#[test]
fn override_with_different_signature_clears_old_type(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
// given: mod_a.esp owns an NPC_ record.
let plugin_a = plugin_with_one_record("mod_a.esp", 0x00_000001, b"NPC_", None);
// mod_b.esp overrides the same GlobalFormId but as a WEAP record.
let plugin_b = plugin_with_master_and_record("mod_a.esp", 0x00_000001, b"WEAP");
// when
let mut cache = PluginCache::new();
cache.add("mod_a.esp", plugin_a)?;
cache.add("mod_b.esp", plugin_b)?;
// then: the NPC_ bucket must be empty; the WEAP bucket has the winner.
let npcs: Vec<_> = cache.records_of_type(Signature(*b"NPC_")).collect();
let weapons: Vec<_> = cache.records_of_type(Signature(*b"WEAP")).collect();
assert!(
npcs.is_empty(),
"stale NPC_ entry must be removed on type-change override"
);
assert_eq!(weapons.len(), 1, "WEAP override must be indexed");
Ok(())
}
}

2026
crates/bethkit-ffi/bethkit.h Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,13 +19,28 @@ fn generate_header() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
// cbindgen.toml lives at the workspace root, two directories above the crate
// (crates/bethkit-ffi → crates/ → workspace root).
let config_path = std::path::PathBuf::from(&crate_dir)
.parent()
.expect("crate dir has a parent (crates/)")
.parent()
.expect("crates/ dir has a parent (workspace root)")
.join("cbindgen.toml");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed={}", config_path.display());
let config = cbindgen::Config::from_file(&config_path)
.expect("Unable to load cbindgen.toml from workspace root");
// Write into target/ first to avoid Windows file-lock issues when
// editors have the source-tree copy open.
let tmp = std::path::PathBuf::from(&out_dir).join("bethkit.h");
cbindgen::Builder::new()
.with_crate(&crate_dir)
.with_language(cbindgen::Language::C)
.with_include_guard("BETHKIT_H")
.with_config(config)
.generate()
.expect("Unable to generate bethkit.h")
.write_to_file(&tmp);

View file

@ -170,25 +170,43 @@ pub extern "C" fn bethkit_archive_entry_get(
}
}
/// Returns a pointer to the NUL-terminated virtual path of `entry` (e.g.
/// `"textures\\actors\\character\\male\\malehead.dds"`).
/// Returns a newly-allocated NUL-terminated copy of the virtual path of
/// `entry` (e.g. `"textures\\actors\\character\\male\\malehead.dds"`).
///
/// The returned pointer is borrowed from `entry`'s owning archive and is
/// valid until that archive is freed.
/// The caller takes ownership of the returned string and must free it with
/// [`bethkit_archive_entry_path_free`].
///
/// Returns null and sets the last error if `entry` is null or the path
/// contains a NUL byte (which would make it unrepresentable as a C string).
///
/// # Errors
///
/// Returns null and sets the last error if `entry` is null.
/// Returns null and sets the last error if `entry` is null or path-to-CString
/// conversion fails.
#[no_mangle]
pub extern "C" fn bethkit_archive_entry_path(entry: *const BethkitArchiveEntry) -> *const c_char {
null_check!(entry, "bethkit_archive_entry_path", std::ptr::null());
pub extern "C" fn bethkit_archive_entry_path(entry: *const BethkitArchiveEntry) -> *mut c_char {
null_check!(entry, "bethkit_archive_entry_path", std::ptr::null_mut());
// SAFETY: entry is non-null and points into the entries Vec of the archive.
// The String data inside ArchiveEntry is stable (not moved) because the
// archive is behind a Box.
let path = &unsafe { &*entry }.0.path;
// NOTE: ArchiveEntry::path does not contain interior NUL bytes in practice;
// if it did the file would not have been extractable by the game either.
path.as_ptr().cast::<c_char>()
match std::ffi::CString::new(path.as_bytes()) {
Ok(cs) => cs.into_raw(),
Err(_) => {
set_last_error("bethkit_archive_entry_path: path contains an interior NUL byte");
std::ptr::null_mut()
}
}
}
/// Frees a string previously returned by [`bethkit_archive_entry_path`].
///
/// Passing a null pointer is a no-op.
#[no_mangle]
pub extern "C" fn bethkit_archive_entry_path_free(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
// SAFETY: ptr was produced by CString::into_raw inside bethkit_archive_entry_path.
drop(unsafe { std::ffi::CString::from_raw(ptr) });
}
/// Returns the uncompressed file size in bytes for `entry`.
@ -210,18 +228,21 @@ pub extern "C" fn bethkit_archive_entry_uncompressed_size(
/// pointer to the buffer. The caller takes ownership of this buffer and must
/// free it with [`bethkit_bytes_free`] passing the same `out_len` value.
///
/// Returns null on failure.
/// When the virtual path is not found in the archive, returns null and writes
/// `0` into `*out_len` without updating the last error (not-found is not an
/// error; check the return value).
///
/// # Arguments
///
/// * `archive` — Archive to extract from. Borrows.
/// * `path` — NUL-terminated virtual path of the file to extract. Borrows.
/// * `out_len` — Written with the number of bytes on success.
/// * `out_len` — Written with the byte count on success, or `0` on failure.
///
/// # Errors
///
/// Returns null and sets the last error if `archive`, `path`, or `out_len` is
/// null, the path is not found, or extraction fails.
/// Returns null, writes `0` into `*out_len`, and sets the last error if
/// `archive`, `path`, or `out_len` is null, `path` contains invalid UTF-8,
/// or extraction (decompression/I/O) fails.
#[no_mangle]
pub extern "C" fn bethkit_archive_extract(
archive: *const BethkitArchive,
@ -241,15 +262,15 @@ pub extern "C" fn bethkit_archive_extract(
None => return std::ptr::null_mut(),
};
// SAFETY: out_len is non-null (checked above); zero it before any early-return
// so the caller always reads a defined value.
unsafe { *out_len = 0 };
// SAFETY: archive is non-null.
let arc = unsafe { &*archive };
let result = match arc.0.extract(path_str) {
None => {
set_last_error(format!(
"bethkit_archive_extract: path not found: {path_str}"
));
return std::ptr::null_mut();
}
// Not found is not an error — return null without touching last_error.
None => return std::ptr::null_mut(),
Some(r) => r,
};
@ -605,12 +626,15 @@ pub extern "C" fn bethkit_ba2_dx10_writer_free(w: *mut BethkitBa2Dx10Writer) {
/// Adds a file to a BA2 DX10 writer.
///
/// `path` is the virtual archive path (e.g. `"textures\\mymod\\foo.dds"`).
/// `data` / `len` are the raw DDS file bytes to pack.
///
/// Returns 0 on success or -1 on error.
///
/// # Errors
///
/// Returns -1 and sets the last error if any pointer is null or the writer
/// has already been consumed.
/// Returns -1 and sets the last error if any pointer is null, the writer has
/// already been consumed, or `data` does not contain a valid DX10/DDS image.
#[no_mangle]
pub extern "C" fn bethkit_ba2_dx10_writer_add(
w: *mut BethkitBa2Dx10Writer,
@ -637,7 +661,7 @@ pub extern "C" fn bethkit_ba2_dx10_writer_add(
Some(inner) => {
// SAFETY: data is non-null and valid for len bytes by caller contract.
let bytes = unsafe { std::slice::from_raw_parts(data, len) }.to_vec();
let _ = inner.add(path_str, bytes);
ffi_try!(inner.add(path_str, bytes).map_err(FfiError::Bsa), -1);
0
}
}

View file

@ -28,7 +28,12 @@ use crate::{cstr_to_str, null_check, set_last_error};
///
/// Created by [`bethkit_plugin_cache_new`]. Must be freed with
/// [`bethkit_plugin_cache_free`].
pub struct BethkitPluginCache(PluginCache);
pub struct BethkitPluginCache {
inner: PluginCache,
/// Interned NUL-terminated plugin name strings for stable `plugin_name`
/// pointers inside [`BethkitGlobalFormId`] values returned by this cache.
name_cstrings: Vec<std::ffi::CString>,
}
/// Creates a new, empty plugin cache.
///
@ -36,7 +41,10 @@ pub struct BethkitPluginCache(PluginCache);
/// [`bethkit_plugin_cache_free`].
#[no_mangle]
pub extern "C" fn bethkit_plugin_cache_new() -> *mut BethkitPluginCache {
Box::into_raw(Box::new(BethkitPluginCache(PluginCache::new())))
Box::into_raw(Box::new(BethkitPluginCache {
inner: PluginCache::new(),
name_cstrings: Vec::new(),
}))
}
/// Frees a plugin cache handle. Passing a null pointer is a no-op.
@ -90,7 +98,7 @@ pub extern "C" fn bethkit_plugin_cache_add(
let inner_plugin = boxed_plugin.inner;
// SAFETY: cache is non-null.
if let Err(e) = unsafe { &mut *cache }.0.add(name_str, inner_plugin) {
if let Err(e) = unsafe { &mut *cache }.inner.add(name_str, inner_plugin) {
set_last_error(format!("bethkit_plugin_cache_add: {e}"));
return -1;
}
@ -104,7 +112,7 @@ pub extern "C" fn bethkit_plugin_cache_add(
pub extern "C" fn bethkit_plugin_cache_len(cache: *const BethkitPluginCache) -> usize {
null_check!(cache, "bethkit_plugin_cache_len", 0);
// SAFETY: cache is non-null.
unsafe { &*cache }.0.len()
unsafe { &*cache }.inner.len()
}
/// Returns the total number of records across all plugins in the cache.
@ -114,7 +122,7 @@ pub extern "C" fn bethkit_plugin_cache_len(cache: *const BethkitPluginCache) ->
pub extern "C" fn bethkit_plugin_cache_record_count(cache: *const BethkitPluginCache) -> usize {
null_check!(cache, "bethkit_plugin_cache_record_count", 0);
// SAFETY: cache is non-null.
unsafe { &*cache }.0.record_count()
unsafe { &*cache }.inner.record_count()
}
/// Resolves a global FormID (plugin name + object ID) to the winning record.
@ -156,7 +164,7 @@ pub extern "C" fn bethkit_plugin_cache_resolve(
};
// SAFETY: cache is non-null.
match unsafe { &*cache }.0.resolve_record(&gfid) {
match unsafe { &*cache }.inner.resolve_record(&gfid) {
Some(r) => r as *const _ as *const BethkitRecord,
None => std::ptr::null(),
}
@ -165,24 +173,26 @@ pub extern "C" fn bethkit_plugin_cache_resolve(
/// Searches all plugins in the cache for a record with the given editor ID.
///
/// On success, writes the global FormID into `*out_gfid` (the `plugin_name`
/// pointer inside is borrowed from the cache and valid until the cache is
/// freed) and returns a borrowed pointer to the record.
/// pointer inside is interned into the cache's name arena and valid until the
/// cache is freed) and returns a borrowed pointer to the record.
///
/// Returns null if no matching record is found.
/// Returns null (without setting the last error) if no matching record is
/// found. Check the return value to distinguish not-found from an error.
///
/// # Arguments
///
/// * `cache` — Cache to search. Borrows.
/// * `cache` — Cache to search. Borrows mutably (to intern plugin names).
/// * `edid` — NUL-terminated editor ID string. Borrows.
/// * `out_gfid` — Written with the global FormID on success. May be null
/// (in which case the FormID is not written).
///
/// # Errors
///
/// Returns null and sets the last error if `cache` or `edid` is null.
/// Returns null and sets the last error if `cache` or `edid` is null, or
/// if `edid` is not valid UTF-8.
#[no_mangle]
pub extern "C" fn bethkit_plugin_cache_find_by_editor_id(
cache: *const BethkitPluginCache,
cache: *mut BethkitPluginCache,
edid: *const c_char,
out_gfid: *mut BethkitGlobalFormId,
) -> *const BethkitRecord {
@ -203,24 +213,35 @@ pub extern "C" fn bethkit_plugin_cache_find_by_editor_id(
};
// SAFETY: cache is non-null.
match unsafe { &*cache }.0.find_by_editor_id(edid_str) {
None => {
set_last_error(format!(
"bethkit_plugin_cache_find_by_editor_id: editor ID '{edid_str}' not found"
));
std::ptr::null()
}
Some((gfid, record)) => {
if !out_gfid.is_null() {
// SAFETY: out_gfid is non-null.
unsafe {
*out_gfid = BethkitGlobalFormId {
plugin_name: gfid.plugin_name.as_ptr().cast::<c_char>(),
object_id: gfid.object_id,
};
}
}
record as *const _ as *const BethkitRecord
let cache_mut = unsafe { &mut *cache };
// Extract plugin_name by value (clone) and object_id so the borrow of
// cache_mut.inner ends before we push into cache_mut.name_cstrings.
let (plugin_name_owned, object_id, record_ptr) =
match cache_mut.inner.find_by_editor_id(edid_str) {
// Not found is not an error — return null without touching last_error.
None => return std::ptr::null(),
Some((gfid, record)) => (
gfid.plugin_name.clone(),
gfid.object_id,
record as *const _ as *const BethkitRecord,
),
};
if !out_gfid.is_null() {
// Intern the plugin name as a NUL-terminated CString so the pointer is
// stable for the lifetime of the cache.
let cs = std::ffi::CString::new(plugin_name_owned.as_bytes()).unwrap_or_else(|_| {
std::ffi::CString::new(b"?".as_ref()).expect("single byte is always valid")
});
let ptr = cs.as_ptr();
cache_mut.name_cstrings.push(cs);
// SAFETY: out_gfid is non-null.
unsafe {
*out_gfid = BethkitGlobalFormId {
plugin_name: ptr,
object_id,
};
}
}
record_ptr
}

View file

@ -11,11 +11,17 @@
//! # Ownership
//!
//! [`BethkitRecordView`] is owned and must be freed with
//! [`bethkit_record_view_free`].
//! [`bethkit_record_view_free`]. Freeing the view also frees all nested
//! [`BethkitFieldEntries`] and [`BethkitFieldValues`] objects reachable from
//! it. **Do not call [`bethkit_field_entries_free`] or
//! [`bethkit_field_values_free`] on objects obtained from a view** - doing so
//! would cause a double-free. Those free functions exist only for objects
//! that are detached from any view.
//!
//! [`BethkitFieldEntries`] and [`BethkitFieldValues`] are owned sub-objects
//! that appear in nested struct / array field values; free them with
//! [`bethkit_field_entries_free`] / [`bethkit_field_values_free`].
//! All `name` pointers inside [`BethkitNamedField`] (field names, enum
//! variant names, flag bit names) are interned in the owning view's string
//! arena. They are NUL-terminated and valid until the view is freed; never
//! free them individually.
//!
//! The [`BethkitSchemaRegistry`] returned by [`bethkit_schema_registry_sse`]
//! points to a `'static` value and must never be freed.
@ -69,11 +75,15 @@ pub union BethkitFieldValuePayload {
/// Active when `kind == Flags`. The flags value owns its active-names
/// array and is dropped when the enclosing [`BethkitNamedField`] is freed.
pub flags_val: ManuallyDrop<BethkitFlagsVal>,
/// Active when `kind == Struct`. Owned; free with
/// [`bethkit_field_entries_free`].
/// Active when `kind == Struct`. Owned by the enclosing
/// [`BethkitRecordView`]; recursively freed by [`bethkit_record_view_free`].
/// **Do not pass to [`bethkit_field_entries_free`] if this value was
/// obtained from a view** — that causes a double-free.
pub struct_entries: *mut BethkitFieldEntries,
/// Active when `kind == Array`. Owned; free with
/// [`bethkit_field_values_free`].
/// Active when `kind == Array`. Owned by the enclosing
/// [`BethkitRecordView`]; recursively freed by [`bethkit_record_view_free`].
/// **Do not pass to [`bethkit_field_values_free`] if this value was
/// obtained from a view** — that causes a double-free.
pub array_values: *mut BethkitFieldValues,
/// Active when `kind == LocalizedId`.
pub localized_id: u32,
@ -86,23 +96,34 @@ pub union BethkitFieldValuePayload {
/// [`BethkitFieldEntries`].
#[repr(C)]
pub struct BethkitNamedField {
/// Human-readable field name from the schema. Points to a `'static`
/// string; never free this pointer.
/// Human-readable field name from the schema. Points into the owning
/// view's string arena; valid until the view is freed. Never free this
/// pointer directly.
pub name: *const c_char,
/// The decoded field value.
pub value: BethkitFieldValue,
}
/// An owned, heap-allocated list of named fields (from a decoded struct).
/// A heap-allocated list of named fields decoded from a struct field.
///
/// Free with [`bethkit_field_entries_free`].
/// Ownership depends on how this was obtained:
/// - **Detached** (returned directly to the caller): free with
/// [`bethkit_field_entries_free`].
/// - **Embedded in a [`BethkitRecordView`]**: freed automatically by
/// [`bethkit_record_view_free`] — **do not** call [`bethkit_field_entries_free`]
/// on it or a double-free will occur.
pub struct BethkitFieldEntries {
entries: Vec<BethkitNamedField>,
}
/// An owned, heap-allocated list of field values (from a decoded array).
/// A heap-allocated list of field values decoded from an array field.
///
/// Free with [`bethkit_field_values_free`].
/// Ownership depends on how this was obtained:
/// - **Detached** (returned directly to the caller): free with
/// [`bethkit_field_values_free`].
/// - **Embedded in a [`BethkitRecordView`]**: freed automatically by
/// [`bethkit_record_view_free`] — **do not** call [`bethkit_field_values_free`]
/// on it or a double-free will occur.
pub struct BethkitFieldValues {
values: Vec<BethkitFieldValue>,
}
@ -113,8 +134,11 @@ pub struct BethkitFieldValues {
/// [`bethkit_record_view_free`].
pub struct BethkitRecordView {
fields: Vec<BethkitNamedField>,
/// Heap-allocated CStrings for inline string values.
_owned_strings: Vec<std::ffi::CString>,
// NOTE: string_arena is never read explicitly; it exists solely to keep
// NOTE: the CStrings alive (RAII). All `name` and `str_val` pointers in
// NOTE: `fields` point into this arena.
#[allow(dead_code)]
string_arena: Vec<std::ffi::CString>,
}
/// An opaque handle to a schema registry (a map from record signature to
@ -212,8 +236,7 @@ pub extern "C" fn bethkit_record_view_new(
.map(|fe| {
let value = convert_field_value(&fe.value, &mut owned_strings);
BethkitNamedField {
// SAFETY: fe.name is a 'static &str from the schema definition.
name: fe.name.as_ptr().cast::<c_char>(),
name: intern_str(fe.name, &mut owned_strings),
value,
}
})
@ -221,12 +244,14 @@ pub extern "C" fn bethkit_record_view_new(
Box::into_raw(Box::new(BethkitRecordView {
fields,
_owned_strings: owned_strings,
string_arena: owned_strings,
}))
}
/// Frees a record view and all owned sub-objects (field entries, values,
/// flags arrays).
/// Frees a record view and recursively all owned sub-objects — nested
/// [`BethkitFieldEntries`] (struct fields), [`BethkitFieldValues`] (array
/// fields), and flags-name arrays. All `name` and `str_val` pointers
/// borrowed from the view become invalid after this call.
///
/// Passing a null pointer is a no-op.
#[no_mangle]
@ -319,7 +344,12 @@ pub extern "C" fn bethkit_field_entries_get(
}
}
/// Frees an owned field entries list returned inside a struct field value.
/// Frees a **detached** field entries list — one explicitly owned by the
/// caller and not embedded in a [`BethkitRecordView`].
///
/// **Do not call this on values obtained from a [`BethkitRecordView`].**
/// [`bethkit_record_view_free`] handles recursive cleanup automatically;
/// calling this on view-owned entries causes a double-free.
///
/// Passing a null pointer is a no-op.
#[no_mangle]
@ -371,7 +401,12 @@ pub extern "C" fn bethkit_field_values_get(
}
}
/// Frees an owned field values list returned inside an array field value.
/// Frees a **detached** field values list — one explicitly owned by the
/// caller and not embedded in a [`BethkitRecordView`].
///
/// **Do not call this on values obtained from a [`BethkitRecordView`].**
/// [`bethkit_record_view_free`] handles recursive cleanup automatically;
/// calling this on view-owned values causes a double-free.
///
/// Passing a null pointer is a no-op.
#[no_mangle]
@ -386,10 +421,25 @@ pub extern "C" fn bethkit_field_values_free(values: *mut BethkitFieldValues) {
}
}
/// Interns `s` as a NUL-terminated [`std::ffi::CString`] into `arena` and
/// returns a stable pointer to its data.
///
/// The pointer is valid for as long as `arena` is alive. Any embedded NUL
/// bytes in `s` are replaced with `?` to guarantee a valid C string.
fn intern_str(s: &str, arena: &mut Vec<std::ffi::CString>) -> *const c_char {
let sanitized: Vec<u8> = s.bytes().map(|b| if b == 0 { b'?' } else { b }).collect();
let cs = std::ffi::CString::new(sanitized)
.unwrap_or_else(|_| std::ffi::CString::new("?").expect("single char is always valid"));
let ptr = cs.as_ptr();
arena.push(cs);
ptr
}
/// Recursively converts a [`FieldValue`] into a [`BethkitFieldValue`].
///
/// String values are interned into `owned_strings` so their pointers remain
/// stable for the lifetime of the view.
/// String values and schema label strings (field names, enum variant names,
/// flag bit names) are interned into `owned_strings` so their pointers are
/// NUL-terminated and stable for the lifetime of the view.
fn convert_field_value<'a>(
fv: &FieldValue<'a>,
owned_strings: &mut Vec<std::ffi::CString>,
@ -447,17 +497,20 @@ fn convert_field_value<'a>(
enum_val: BethkitEnumVal {
value: *value,
name: match name {
Some(n) => n.as_ptr().cast::<c_char>(),
Some(n) => intern_str(n, owned_strings),
None => std::ptr::null(),
},
},
},
},
FieldValue::Flags { value, active } => {
// Build a heap-allocated array of *const c_char pointing to
// 'static schema strings.
let name_ptrs: Vec<*const c_char> =
active.iter().map(|s| s.as_ptr().cast::<c_char>()).collect();
// Build a heap-allocated array of *const c_char. Each name pointer
// is interned into owned_strings, so it is NUL-terminated and
// stable for the lifetime of the enclosing view.
let name_ptrs: Vec<*const c_char> = active
.iter()
.map(|s| intern_str(s, owned_strings))
.collect();
let count = name_ptrs.len();
let boxed = name_ptrs.into_boxed_slice();
let ptr = boxed.as_ptr();
@ -480,7 +533,7 @@ fn convert_field_value<'a>(
.map(|fe| {
let value = convert_field_value(&fe.value, owned_strings);
BethkitNamedField {
name: fe.name.as_ptr().cast::<c_char>(),
name: intern_str(fe.name, owned_strings),
value,
}
})
@ -560,3 +613,86 @@ fn drop_field_value(v: BethkitFieldValue) {
_ => {}
}
}
#[cfg(test)]
mod tests {
use std::ffi::{CStr, CString};
use super::*;
/// Verifies that `intern_str` produces a NUL-terminated pointer into the arena.
#[test]
fn intern_str_is_nul_terminated() -> std::result::Result<(), Box<dyn std::error::Error>> {
// given
let mut arena: Vec<CString> = Vec::new();
// when
let ptr = intern_str("TestField", &mut arena);
// then
// SAFETY: ptr points into arena, which is alive for the rest of this function.
let cstr = unsafe { CStr::from_ptr(ptr) };
assert_eq!(cstr.to_str()?, "TestField");
assert_eq!(arena.len(), 1);
Ok(())
}
/// Verifies that `drop_field_value` correctly frees the flags active-names
/// array without panicking or leaking.
#[test]
fn drop_field_value_flags_cleans_up() -> std::result::Result<(), Box<dyn std::error::Error>> {
// given: build a Flags field value exactly as convert_field_value does.
let mut arena: Vec<CString> = Vec::new();
let name_ptrs: Vec<*const c_char> = vec![
intern_str("BitA", &mut arena),
intern_str("BitB", &mut arena),
];
let count = name_ptrs.len();
let boxed = name_ptrs.into_boxed_slice();
let ptr = boxed.as_ptr();
std::mem::forget(boxed);
let fv = BethkitFieldValue {
kind: BethkitFieldValueKind::Flags,
payload: BethkitFieldValuePayload {
flags_val: ManuallyDrop::new(BethkitFlagsVal {
raw_value: 0b11,
active_names: ptr,
active_count: count,
}),
},
};
// when / then: must not panic or leak
drop_field_value(fv);
Ok(())
}
/// Verifies that `drop_field_value` recursively cleans up nested struct
/// entries without panicking or leaking.
#[test]
fn drop_field_value_struct_recursively_drops(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
// given: a Struct containing one Int entry.
let inner = BethkitNamedField {
name: std::ptr::null(),
value: BethkitFieldValue {
kind: BethkitFieldValueKind::Int,
payload: BethkitFieldValuePayload { int_val: 99 },
},
};
let entries = Box::new(BethkitFieldEntries {
entries: vec![inner],
});
let fv = BethkitFieldValue {
kind: BethkitFieldValueKind::Struct,
payload: BethkitFieldValuePayload {
struct_entries: Box::into_raw(entries),
},
};
// when / then: recursive drop must not panic or leak
drop_field_value(fv);
Ok(())
}
}

View file

@ -116,17 +116,19 @@ pub extern "C" fn bethkit_string_table_len(st: *const BethkitStringTable) -> usi
/// to the raw string bytes. The bytes are **borrowed** from the table and
/// are valid until the table is mutated or freed.
///
/// Returns null if `id` is not present in the table.
/// When `id` is not present in the table, returns null and writes `0` into
/// `*out_len` without setting the last error.
///
/// # Arguments
///
/// * `st` — String table. Borrows.
/// * `id` — String table entry ID.
/// * `out_len` — Written with the number of bytes on success.
/// * `out_len` — Written with the byte count on success, or `0` if not found.
///
/// # Errors
///
/// Returns null and sets the last error if `st` or `out_len` is null.
/// Returns null, writes `0` into `*out_len`, and sets the last error if
/// `st` or `out_len` is null.
#[no_mangle]
pub extern "C" fn bethkit_string_table_get(
st: *const BethkitStringTable,
@ -140,6 +142,8 @@ pub extern "C" fn bethkit_string_table_get(
std::ptr::null()
);
// SAFETY: st and out_len are non-null.
// Zero out_len first so every return path leaves a defined value.
unsafe { *out_len = 0 };
match unsafe { &*st }.0.get(id) {
None => std::ptr::null(),
Some(bytes) => {
@ -348,18 +352,20 @@ pub extern "C" fn bethkit_localization_set_free(ls: *mut BethkitLocalizationSet)
/// to the raw bytes. The bytes are borrowed from the set and are valid until
/// the set is mutated or freed.
///
/// Returns null if `id` is not present.
/// When `id` is not present, returns null and writes `0` into `*out_len`
/// without setting the last error.
///
/// # Arguments
///
/// * `ls` — Localization set. Borrows.
/// * `kind` — Which table to look in.
/// * `id` — String entry ID.
/// * `out_len` — Written with the number of bytes on success.
/// * `out_len` — Written with the byte count on success, or `0` if not found.
///
/// # Errors
///
/// Returns null and sets the last error if `ls` or `out_len` is null.
/// Returns null, writes `0` into `*out_len`, and sets the last error if
/// `ls` or `out_len` is null.
#[no_mangle]
pub extern "C" fn bethkit_localization_set_get(
ls: *const BethkitLocalizationSet,
@ -374,6 +380,8 @@ pub extern "C" fn bethkit_localization_set_get(
std::ptr::null()
);
// SAFETY: ls and out_len are non-null.
// Zero out_len first so every return path leaves a defined value.
unsafe { *out_len = 0 };
match unsafe { &*ls }.0.get(string_kind_to_rust(kind), id) {
None => std::ptr::null(),
Some(bytes) => {

View file

@ -137,8 +137,8 @@ pub struct BethkitTypedFormId {
/// An enumeration field value with its raw integer and optional resolved name.
///
/// `name` is null when the raw value does not correspond to any known variant
/// in the schema. When non-null it points to a static string and must not
/// be freed.
/// in the schema. When non-null it points into the owning view's string
/// arena and is valid until the view is freed; never free this pointer.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct BethkitEnumVal {
@ -150,11 +150,12 @@ pub struct BethkitEnumVal {
/// A flags field value with the raw integer and the names of all active bits.
///
/// `active_names` points to an array of `active_count` static C-string
/// `active_names` points to an array of `active_count` NUL-terminated C-string
/// pointers. The *array itself* is heap-allocated and is freed when the
/// enclosing [`BethkitFieldValue`] is released (via the view or entry free
/// functions). The individual string pointers point into static memory and
/// must not be freed.
/// functions). The individual string pointers are interned in the owning
/// view's string arena and are valid until that view is freed. Do not free
/// the individual string pointers.
#[repr(C)]
pub struct BethkitFlagsVal {
/// The raw integer value from the record.

View file

@ -153,17 +153,17 @@ pub extern "C" fn bethkit_plugin_writer_write_to_file(
/// On success, writes the buffer length into `*out_len` and returns a pointer
/// to the buffer. The buffer must be freed with [`bethkit_bytes_free`].
///
/// Returns null on failure.
/// Returns null on failure and writes `0` into `*out_len`.
///
/// # Arguments
///
/// * `pw` — Plugin writer. Borrows.
/// * `out_len` — Written with the buffer size on success.
/// * `out_len` — Written with the buffer size on success, or `0` on failure.
///
/// # Errors
///
/// Returns null and sets the last error if `pw` or `out_len` is null, or
/// serialization fails.
/// Returns null, writes `0` into `*out_len`, and sets the last error if
/// `pw` or `out_len` is null, or serialization fails.
#[no_mangle]
pub extern "C" fn bethkit_plugin_writer_write_to_bytes(
pw: *const BethkitPluginWriter,
@ -181,6 +181,8 @@ pub extern "C" fn bethkit_plugin_writer_write_to_bytes(
);
// SAFETY: pw is non-null.
// Zero out_len before the operation so every return path leaves a defined value.
unsafe { *out_len = 0 };
let bytes = ffi_try!(
unsafe { &*pw }.0.write_to_vec().map_err(FfiError::Core),
std::ptr::null_mut()

View file

@ -117,8 +117,12 @@ impl<'a> SliceCursor<'a> {
/// # Errors
///
/// Returns [`IoError::UnexpectedEof`] if fewer than `N` bytes remain.
/// Returns [`IoError::OffsetOverflow`] if `pos + N` overflows `usize`.
pub fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
let end: usize = self.pos + N;
let end: usize = self.pos.checked_add(N).ok_or(IoError::OffsetOverflow {
offset: self.pos,
len: N,
})?;
if end > self.data.len() {
return Err(IoError::UnexpectedEof { offset: self.pos });
}
@ -134,8 +138,12 @@ impl<'a> SliceCursor<'a> {
/// # Errors
///
/// Returns [`IoError::UnexpectedEof`] if fewer than `len` bytes remain.
/// Returns [`IoError::OffsetOverflow`] if `pos + len` overflows `usize`.
pub fn read_slice(&mut self, len: usize) -> Result<&'a [u8]> {
let end: usize = self.pos + len;
let end: usize = self.pos.checked_add(len).ok_or(IoError::OffsetOverflow {
offset: self.pos,
len,
})?;
if end > self.data.len() {
return Err(IoError::UnexpectedEof { offset: self.pos });
}
@ -146,9 +154,10 @@ impl<'a> SliceCursor<'a> {
/// Peeks at the next `n` bytes without advancing the position.
///
/// Returns `None` if fewer than `n` bytes remain.
/// Returns `None` if fewer than `n` bytes remain or if `pos + n` overflows
/// `usize`.
pub fn peek_bytes(&self, n: usize) -> Option<&[u8]> {
let end: usize = self.pos + n;
let end: usize = self.pos.checked_add(n)?;
if end > self.data.len() {
return None;
}
@ -167,8 +176,12 @@ impl<'a> SliceCursor<'a> {
/// # Errors
///
/// Returns [`IoError::UnexpectedEof`] if `n` exceeds the remaining bytes.
/// Returns [`IoError::OffsetOverflow`] if `pos + n` overflows `usize`.
pub fn skip(&mut self, n: usize) -> Result<()> {
let end: usize = self.pos + n;
let end: usize = self.pos.checked_add(n).ok_or(IoError::OffsetOverflow {
offset: self.pos,
len: n,
})?;
if end > self.data.len() {
return Err(IoError::UnexpectedEof { offset: self.pos });
}
@ -312,4 +325,23 @@ mod tests {
assert_eq!(b, 0x42);
Ok(())
}
/// Verifies that read_slice returns OffsetOverflow when pos + len wraps.
#[test]
fn read_slice_overflow_returns_offset_overflow(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
// given: position at usize::MAX - 1 via a synthetic cursor.
// We fabricate this by constructing a cursor and manually setting its
// position through from_offset with a slice sized exactly to that offset.
let data: Vec<u8> = vec![0u8; 2];
let mut cursor = SliceCursor::from_offset(&data, 1)?;
// Advance to 1; now attempt a read of usize::MAX bytes (wraps on add).
// when
let result = cursor.read_slice(usize::MAX);
// then
assert!(matches!(result, Err(IoError::OffsetOverflow { .. })));
Ok(())
}
}

View file

@ -16,6 +16,13 @@ pub enum IoError {
/// A decompression operation failed.
#[error("Decompression failed: {0}")]
Decompress(String),
/// Arithmetic overflow computing the end offset of a read operation.
///
/// This can only occur when `offset + len` wraps around `usize::MAX`,
/// which indicates malformed or adversarial input.
#[error("offset overflow at offset {offset} adding {len} bytes")]
OffsetOverflow { offset: usize, len: usize },
}
/// Convenience alias for `Result<T, IoError>`.

View file

@ -1,453 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
//!
//! Integration tests for `bethkit-core` using real plugin files.
//!
//! All `.esp`, `.esm`, and `.esl` files found in `tests/testdata/` are tested
//! automatically. Place any Skyrim SE plugin file there to add it to the suite.
//!
//! Run with:
//! ```text
//! cargo test --test esp_roundtrip -- --nocapture
//! ```
use std::path::{Path, PathBuf};
use bethkit_core::{GameContext, Plugin, PluginKind, RecordFlags, Signature};
/// Returns all `.esp` / `.esm` / `.esl` files in `tests/testdata/`.
///
/// Navigates from `CARGO_MANIFEST_DIR` (crate root) up to the workspace root,
/// then into `tests/testdata/`. Returns an empty list if the directory is
/// missing so that CI without real plugin files still passes.
fn collect_testdata() -> Vec<PathBuf> {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
// CARGO_MANIFEST_DIR = .../bethkit/crates/bethkit-core
// parent() = .../bethkit/crates
// parent() = .../bethkit (workspace root)
let dir = manifest
.parent()
.and_then(|p| p.parent())
.map(|root| root.join("tests").join("testdata"))
.unwrap_or_else(|| manifest.join("testdata"));
if !dir.exists() {
return Vec::new();
}
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("failed to read testdata dir")
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
let ext = path.extension()?.to_ascii_lowercase();
if ext == "esp" || ext == "esm" || ext == "esl" {
Some(path)
} else {
None
}
})
.collect();
paths.sort();
paths
}
/// Opens a plugin with SSE context and reports the path on error.
fn open_plugin(path: &Path) -> Result<Plugin, String> {
Plugin::open(path, GameContext::sse())
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Every plugin in testdata/ must open without error.
#[test]
fn all_plugins_parse_without_error() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() {
eprintln!("SKIP: no files in tests/testdata/ — add real SSE plugins to enable");
return Ok(());
}
let mut failures: Vec<String> = Vec::new();
let mut count = 0usize;
for path in &paths {
// when
match open_plugin(path) {
Ok(_) => count += 1,
Err(msg) => failures.push(msg),
}
}
// then
if !failures.is_empty() {
eprintln!("\n{} of {} plugins failed to parse:", failures.len(), paths.len());
for f in &failures {
eprintln!(" FAIL: {f}");
}
panic!("{} plugin(s) failed to parse — see stderr for details", failures.len());
}
eprintln!("OK: {count} plugins parsed successfully");
Ok(())
}
/// Every plugin must have an accessible group list (may be empty for stubs).
#[test]
fn all_plugins_have_accessible_groups() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
// then — just ensure the call does not panic and returns a slice
let _groups = plugin.groups();
}
Ok(())
}
/// Every record signature must consist of ASCII alphanumeric chars or `_`.
#[test]
fn all_record_signatures_are_valid_ascii() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut bad: Vec<String> = Vec::new();
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
let sig = record.header.signature;
// then
if !sig.0.iter().all(|b| b.is_ascii_alphanumeric() || *b == b'_') {
bad.push(format!(
"{}: invalid signature {sig} in FormID {:08X}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
if !bad.is_empty() {
for b in &bad { eprintln!("BAD: {b}"); }
panic!("{} bad signature(s) found", bad.len());
}
Ok(())
}
/// Plugin kind must be one of the five known variants.
#[test]
fn all_plugins_have_known_kind() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
let kind = plugin.kind();
// then — pattern match ensures exhaustiveness at compile time
let _ok = matches!(
kind,
PluginKind::Plugin | PluginKind::Master | PluginKind::Light
| PluginKind::Medium | PluginKind::Update
);
}
Ok(())
}
/// `.esm` files must not be detected as the Update-only variant (SSE has no
#[test]
/// Update plugins — that is a Starfield concept).
fn esm_files_are_never_update_kind() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
for path in &paths {
let Ok(plugin) = open_plugin(path) else { continue };
let ext = path.extension().map(|e| e.to_ascii_lowercase());
if ext.as_deref() == Some("esm") && plugin.kind() == PluginKind::Update {
failures.push(format!(
"{}: .esm detected as Update (unexpected for SSE)",
path.file_name().unwrap().to_string_lossy()
));
}
}
if !failures.is_empty() {
for f in &failures { eprintln!("FAIL: {f}"); }
panic!("{} kind mismatch(es)", failures.len());
}
Ok(())
}
/// All master filenames from MAST subrecords must be non-empty ASCII strings.
#[test]
fn all_master_names_are_non_empty_ascii() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
for master in plugin.masters() {
// then
if master.is_empty() {
failures.push(format!(
"{}: empty master filename",
path.file_name().unwrap().to_string_lossy()
));
}
if !master.is_ascii() {
failures.push(format!(
"{}: non-ASCII master filename: {master:?}",
path.file_name().unwrap().to_string_lossy()
));
}
}
}
if !failures.is_empty() {
for f in &failures { eprintln!("FAIL: {f}"); }
panic!("{} invalid master name(s)", failures.len());
}
Ok(())
}
/// Triggering lazy subrecord parsing on every record must not produce errors.
#[test]
fn all_subrecords_parse_without_error() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
let mut total_records = 0usize;
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
total_records += 1;
// then — trigger lazy subrecord parsing
if let Err(e) = record.subrecords() {
failures.push(format!(
"{}: FormID {:08X} subrecord parse failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
if !failures.is_empty() {
eprintln!("{} of {total_records} records failed subrecord parsing:", failures.len());
for f in failures.iter().take(20) { eprintln!(" {f}"); }
panic!("{} record(s) failed subrecord parsing", failures.len());
}
eprintln!("OK: {total_records} records had subrecords parsed");
Ok(())
}
/// EDID (editor ID) subrecords, where present, must decode as valid UTF-8.
#[test]
fn edid_subrecords_are_valid_utf8() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
let mut edid_count = 0usize;
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
let Ok(Some(edid_sr)) = record.get(Signature::EDID) else { continue };
edid_count += 1;
// then
if let Err(e) = edid_sr.as_zstring() {
failures.push(format!(
"{}: FormID {:08X} EDID decode failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
if !failures.is_empty() {
for f in failures.iter().take(20) { eprintln!("FAIL: {f}"); }
panic!("{} EDID(s) failed UTF-8 decode", failures.len());
}
eprintln!("OK: {edid_count} EDID subrecords decoded");
Ok(())
}
/// Compressed records must decompress without error and not panic.
#[test]
fn compressed_records_decompress_correctly() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
let mut compressed_count = 0usize;
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
if !record.header.flags.contains(RecordFlags::COMPRESSED) {
continue;
}
compressed_count += 1;
// then — trigger decompression via subrecord parse
if let Err(e) = record.subrecords() {
failures.push(format!(
"{}: FormID {:08X} decompression failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
if !failures.is_empty() {
for f in failures.iter().take(20) { eprintln!("FAIL: {f}"); }
panic!("{} compressed record(s) failed to decompress", failures.len());
}
eprintln!("OK: {compressed_count} compressed records decompressed");
Ok(())
}
/// The HEDR version float must be positive and finite for every plugin.
#[test]
fn all_plugins_have_valid_hedr_version() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
let mut failures: Vec<String> = Vec::new();
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
let v = plugin.header.hedr_version;
// then
if !v.is_finite() || v <= 0.0 {
failures.push(format!(
"{}: invalid HEDR version {v}",
path.file_name().unwrap().to_string_lossy()
));
}
}
if !failures.is_empty() {
for f in &failures { eprintln!("FAIL: {f}"); }
panic!("{} plugin(s) with invalid HEDR version", failures.len());
}
Ok(())
}
/// `find_record` must return the correct record when searching by FormID.
#[test]
fn find_record_returns_correct_record() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() { return Ok(()); }
for path in &paths {
// when
let Ok(plugin) = open_plugin(path) else { continue };
// Extract the first FormID without keeping a borrow into plugin.
let first_fid: Option<FormId> = plugin
.groups()
.iter()
.flat_map(|g| g.records_recursive())
.next()
.map(|r| r.header.form_id);
if let Some(fid) = first_fid {
// then
let found = plugin.find_record(fid);
assert!(
found.is_some(),
"{}: find_record({:08X}) returned None, expected Some",
path.file_name().unwrap().to_string_lossy(),
fid.0,
);
assert_eq!(
found.unwrap().header.form_id,
fid,
"find_record returned wrong record"
);
}
}
Ok(())
}
/// Print a summary table of all testdata plugins. Informational only.
#[test]
fn print_plugin_summary() -> Result<(), Box<dyn std::error::Error>> {
// given
let paths = collect_testdata();
if paths.is_empty() {
eprintln!("SKIP: no testdata files");
return Ok(());
}
eprintln!("\n{:<52} {:8} {:6} {:7}", "File", "Kind", "Groups", "Masters");
eprintln!("{}", "-".repeat(76));
let mut ok = 0usize;
let mut err = 0usize;
for path in &paths {
// when
match open_plugin(path) {
Ok(plugin) => {
ok += 1;
eprintln!(
"{:<52} {:8} {:6} {:7}",
path.file_name().unwrap().to_string_lossy(),
format!("{:?}", plugin.kind()),
plugin.groups().len(),
plugin.masters().len(),
);
}
Err(e) => {
err += 1;
eprintln!(
"{:<52} ERROR: {e}",
path.file_name().unwrap().to_string_lossy()
);
}
}
}
eprintln!("{}", "-".repeat(76));
eprintln!("Total: {ok} OK, {err} errors");
Ok(())
}

View file

@ -1,917 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
//!
//! Live integration + benchmark suite against a real Fallout 4 game
//! installation.
//!
//! # Precondition
//!
//! Set the environment variable `FO4_DATA_DIR` to the path of your Fallout 4
//! `Data/` folder, **or** place the installation at the default path:
//!
//! ```text
//! E:\SteamLibrary\steamapps\common\Fallout 4\Data
//! ```
//!
//! If neither path exists the entire suite is skipped so that CI passes
//! without a game installation.
//!
//! # Run
//!
//! ```text
//! cargo test --test fo4_live -- --nocapture
//! ```
use std::{
collections::HashMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use bethkit_core::{
GameContext, Plugin, PluginKind, RecordFlags, RecordView, SchemaRegistry, Signature,
};
// ── constants ─────────────────────────────────────────────────────────────────
const DEFAULT_DATA_DIR: &str = r"E:\SteamLibrary\steamapps\common\Fallout 4\Data";
// Record types that are placement / navmesh records and intentionally have no
// schema entry (REFR, ACHR, etc. are not in the type-level registry).
const KNOWN_NO_SCHEMA: &[&[u8; 4]] = &[
b"NAVM", b"NAVI", b"REFR", b"ACHR", b"PGRE", b"PMIS", b"PARW", b"PBAR", b"PBEA", b"PCON",
b"PFLA", b"PHZD", b"ACRE",
];
// ── helpers ───────────────────────────────────────────────────────────────────
/// Locates the Fallout 4 Data directory.
///
/// Returns `None` when the suite should be skipped.
fn find_data_dir() -> Option<PathBuf> {
if let Ok(val) = std::env::var("FO4_DATA_DIR") {
let p = PathBuf::from(val);
if p.exists() {
return Some(p);
}
eprintln!(
"FO4_DATA_DIR is set but path does not exist: {}",
p.display()
);
return None;
}
let default = PathBuf::from(DEFAULT_DATA_DIR);
if default.exists() {
return Some(default);
}
None
}
/// Collects all `.esp` / `.esm` / `.esl` files in `dir`, sorted by name.
fn collect_plugins(dir: &Path) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.expect("failed to read Data directory")
.filter_map(|e| {
let e = e.ok()?;
let p = e.path();
let ext = p.extension()?.to_ascii_lowercase();
if ext == "esp" || ext == "esm" || ext == "esl" {
Some(p)
} else {
None
}
})
.collect();
paths.sort();
paths
}
/// Opens a plugin with FO4 context, returning an error message on failure.
fn open(path: &Path) -> Result<Plugin, String> {
Plugin::open(path, GameContext::fallout4()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Prints a section banner to stderr.
fn banner(title: &str) {
eprintln!();
eprintln!("━━━ {title} ━━━");
}
/// Formats a byte count as a human-readable string.
fn fmt_bytes(n: u64) -> String {
const GIB: u64 = 1 << 30;
const MIB: u64 = 1 << 20;
const KIB: u64 = 1 << 10;
if n >= GIB {
format!("{:.2} GiB", n as f64 / GIB as f64)
} else if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else {
format!("{:.1} KiB", n as f64 / KIB as f64)
}
}
/// Formats a rate as MB/s.
fn fmt_mbps(bytes: u64, elapsed: Duration) -> String {
let secs = elapsed.as_secs_f64();
if secs == 0.0 {
return "∞ MB/s".to_owned();
}
format!("{:.1} MB/s", bytes as f64 / (1 << 20) as f64 / secs)
}
// ── Benchmark result accumulator ──────────────────────────────────────────────
struct BenchResult {
label: &'static str,
files: usize,
bytes: u64,
elapsed: Duration,
records: u64,
errors: usize,
}
impl BenchResult {
fn print(&self) {
let mbps = fmt_mbps(self.bytes, self.elapsed);
let rps = if self.elapsed.as_secs_f64() > 0.0 {
format!("{:.0}", self.records as f64 / self.elapsed.as_secs_f64())
} else {
"".to_owned()
};
eprintln!(
" {:50} {:>6} files {:>10} {:>8.3} s {:>12} MB/s {:>12} rec/s {} err",
self.label,
self.files,
fmt_bytes(self.bytes),
self.elapsed.as_secs_f64(),
mbps.trim_end_matches(" MB/s"),
rps,
self.errors,
);
}
}
// ── Test 1: Dataset discovery ─────────────────────────────────────────────────
/// Reports dataset statistics — does NOT assert, just prints.
///
/// Verifies that the live FO4 data directory is readable and contains a
/// plausible number of plugin files.
#[test]
fn fo4_live_01_dataset_discovery() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
eprintln!("SKIP: Fallout 4 Data directory not found");
return Ok(());
};
banner("DATASET DISCOVERY");
let paths = collect_plugins(&dir);
let total_bytes: u64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
let esm = paths
.iter()
.filter(|p| p.extension().map(|e| e == "esm").unwrap_or(false))
.count();
let esp = paths
.iter()
.filter(|p| p.extension().map(|e| e == "esp").unwrap_or(false))
.count();
let esl = paths
.iter()
.filter(|p| p.extension().map(|e| e == "esl").unwrap_or(false))
.count();
eprintln!(" Data dir : {}", dir.display());
eprintln!(" ESM : {esm}");
eprintln!(" ESP : {esp}");
eprintln!(" ESL : {esl}");
eprintln!(
" Total : {} files ({})",
paths.len(),
fmt_bytes(total_bytes)
);
assert!(
paths
.iter()
.any(|p| p.file_name().map(|n| n == "Fallout4.esm").unwrap_or(false)),
"Fallout4.esm not found in Data directory"
);
assert!(
paths.len() >= 5,
"too few plugin files — expected at least 5"
);
Ok(())
}
// ── Test 2: All plugins open without error ────────────────────────────────────
/// Every `.esp` / `.esm` / `.esl` file in the Data directory must parse
/// without returning an error.
#[test]
fn fo4_live_02_all_plugins_open() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("ALL PLUGINS OPEN");
let paths = collect_plugins(&dir);
let mut ok = 0usize;
let mut failures: Vec<String> = Vec::new();
for path in &paths {
match open(path) {
Ok(_) => ok += 1,
Err(e) => failures.push(e),
}
}
eprintln!(" Opened {ok} / {} plugins without error", paths.len());
if !failures.is_empty() {
eprintln!(" FAILURES ({}):", failures.len());
for f in failures.iter().take(30) {
eprintln!(" {f}");
}
if failures.len() > 30 {
eprintln!(" … and {} more", failures.len() - 30);
}
panic!("{} plugin(s) failed to open", failures.len());
}
Ok(())
}
// ── Test 3: Record signature validity ─────────────────────────────────────────
/// All record signatures across every plugin must consist exclusively of
/// ASCII alphanumeric bytes or `_`.
#[test]
fn fo4_live_03_all_signatures_are_valid_ascii() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("RECORD SIGNATURE VALIDITY");
let paths = collect_plugins(&dir);
let mut bad: Vec<String> = Vec::new();
let mut total_records = 0u64;
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
total_records += 1;
let sig = record.header.signature;
if !sig
.0
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
bad.push(format!(
"{}: invalid signature {sig} at FormID {:08X}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
eprintln!(" Checked {total_records} record signatures");
if !bad.is_empty() {
for b in bad.iter().take(20) {
eprintln!(" BAD: {b}");
}
panic!("{} invalid signature(s) found", bad.len());
}
Ok(())
}
// ── Test 4: Plugin kinds ──────────────────────────────────────────────────────
/// Every plugin must have a recognised PluginKind.
#[test]
fn fo4_live_04_plugin_kinds_are_valid() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("PLUGIN KINDS");
let paths = collect_plugins(&dir);
let mut kind_counts: HashMap<&'static str, usize> = HashMap::new();
for path in &paths {
let Ok(plugin) = open(path) else { continue };
let label = match plugin.kind() {
PluginKind::Plugin => "Plugin (.esp)",
PluginKind::Master => "Master (.esm)",
PluginKind::Light => "Light (.esl)",
PluginKind::Medium => "Medium",
PluginKind::Update => "Update",
};
*kind_counts.entry(label).or_default() += 1;
}
for (label, count) in &kind_counts {
eprintln!(" {label:20} : {count}");
}
Ok(())
}
// ── Test 5: HEDR version validity ─────────────────────────────────────────────
/// The HEDR version float must be positive and finite for every plugin.
#[test]
fn fo4_live_05_hedr_versions_are_valid() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("HEDR VERSION VALIDITY");
let paths = collect_plugins(&dir);
let mut bad: Vec<String> = Vec::new();
let mut versions: HashMap<u32, usize> = HashMap::new();
for path in &paths {
let Ok(plugin) = open(path) else { continue };
let v = plugin.header.hedr_version;
*versions.entry(v.to_bits()).or_default() += 1;
if !v.is_finite() || v <= 0.0 {
bad.push(format!(
"{}: invalid HEDR version {v}",
path.file_name().unwrap().to_string_lossy()
));
}
}
let mut sorted_versions: Vec<(f32, usize)> = versions
.into_iter()
.map(|(bits, count)| (f32::from_bits(bits), count))
.collect();
sorted_versions.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
for (v, count) in &sorted_versions {
eprintln!(" HEDR version {v:.4} : {count} plugin(s)");
}
if !bad.is_empty() {
for f in &bad {
eprintln!(" BAD: {f}");
}
panic!("{} invalid HEDR version(s)", bad.len());
}
Ok(())
}
// ── Test 6: Master filename validity ──────────────────────────────────────────
/// All MAST subrecords must be non-empty, printable ASCII strings.
#[test]
fn fo4_live_06_master_filenames_are_valid() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("MASTER FILENAME VALIDITY");
let paths = collect_plugins(&dir);
let mut bad: Vec<String> = Vec::new();
let mut total_masters = 0u64;
let mut master_counts: HashMap<usize, usize> = HashMap::new();
for path in &paths {
let Ok(plugin) = open(path) else { continue };
let masters = plugin.masters();
*master_counts.entry(masters.len()).or_default() += 1;
total_masters += masters.len() as u64;
for m in masters {
if m.is_empty() || !m.is_ascii() {
bad.push(format!(
"{}: invalid master {:?}",
path.file_name().unwrap().to_string_lossy(),
m
));
}
}
}
eprintln!(" Total MAST references : {total_masters}");
let mut buckets: Vec<(usize, usize)> = master_counts.into_iter().collect();
buckets.sort_by_key(|(k, _)| *k);
for (count, n) in &buckets {
eprintln!(" {count:3} master(s) : {n} plugin(s)");
}
if !bad.is_empty() {
for f in bad.iter().take(20) {
eprintln!(" BAD: {f}");
}
panic!("{} invalid master filename(s)", bad.len());
}
Ok(())
}
// ── Test 7: Subrecord parsing ──────────────────────────────────────────────────
/// Triggering lazy subrecord parsing on every record across every plugin
/// must not return an error.
#[test]
fn fo4_live_07_all_subrecords_parse() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("SUBRECORD PARSE COVERAGE");
let paths = collect_plugins(&dir);
let mut failures: Vec<String> = Vec::new();
let mut total_records = 0u64;
let mut total_subrecords = 0u64;
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
total_records += 1;
match record.subrecords() {
Ok(srs) => total_subrecords += srs.len() as u64,
Err(e) => {
failures.push(format!(
"{}: FormID {:08X} ({}) parse failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
record.header.signature,
));
}
}
}
}
}
eprintln!(" Records : {total_records}");
eprintln!(" Subrecords : {total_subrecords}");
if !failures.is_empty() {
eprintln!(" FAILURES ({}):", failures.len());
for f in failures.iter().take(30) {
eprintln!(" {f}");
}
panic!("{} subrecord parse failure(s)", failures.len());
}
Ok(())
}
// ── Test 8: EDID subrecords are valid UTF-8 ───────────────────────────────────
/// Every EDID (Editor ID) subrecord must decode to a valid UTF-8 string.
#[test]
fn fo4_live_08_edid_subrecords_are_valid_utf8() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("EDID UTF-8 VALIDITY");
let paths = collect_plugins(&dir);
let sig_edid = Signature(*b"EDID");
let mut failures: Vec<String> = Vec::new();
let mut edid_count = 0u64;
let mut max_len = 0usize;
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
let Ok(Some(sr)) = record.get(sig_edid) else {
continue;
};
edid_count += 1;
match sr.as_zstring() {
Ok(s) => max_len = max_len.max(s.len()),
Err(e) => {
failures.push(format!(
"{}: FormID {:08X} EDID decode failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
));
}
}
}
}
}
eprintln!(" EDID subrecords : {edid_count} (max length: {max_len})");
if !failures.is_empty() {
for f in failures.iter().take(20) {
eprintln!(" BAD: {f}");
}
panic!("{} EDID(s) failed UTF-8 decode", failures.len());
}
Ok(())
}
// ── Test 9: Compressed records decompress ─────────────────────────────────────
/// Every compressed record must decompress without error and produce a
/// non-empty subrecord list.
#[test]
fn fo4_live_09_compressed_records_decompress() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("COMPRESSED RECORD DECOMPRESSION");
let paths = collect_plugins(&dir);
let mut failures: Vec<String> = Vec::new();
let mut compressed_count = 0u64;
let mut total_records = 0u64;
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
total_records += 1;
if !record.header.flags.contains(RecordFlags::COMPRESSED) {
continue;
}
compressed_count += 1;
match record.subrecords() {
Ok(_) => {}
Err(e) => {
failures.push(format!(
"{}: FormID {:08X} ({}) decompression failed: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.form_id.0,
record.header.signature,
));
}
}
}
}
}
let pct = if total_records > 0 {
compressed_count as f64 / total_records as f64 * 100.0
} else {
0.0
};
eprintln!(" Compressed : {compressed_count} / {total_records} records ({pct:.1}%)");
if !failures.is_empty() {
for f in failures.iter().take(20) {
eprintln!(" FAIL: {f}");
}
panic!(
"{} compressed record(s) failed to decompress",
failures.len()
);
}
Ok(())
}
// ── Test 10: Record flag inventory ────────────────────────────────────────────
/// Collects flag statistics across all plugins. Does not fail — informational
/// only.
#[test]
fn fo4_live_10_record_flag_inventory() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("RECORD FLAG INVENTORY");
let paths = collect_plugins(&dir);
let mut deleted_count = 0u64;
let mut localized_count = 0u64;
let mut compressed_count = 0u64;
let mut ignored_count = 0u64;
let mut initially_disabled = 0u64;
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
let f = record.header.flags;
if f.contains(RecordFlags::DELETED) {
deleted_count += 1;
}
if f.contains(RecordFlags::LOCALIZED) {
localized_count += 1;
}
if f.contains(RecordFlags::COMPRESSED) {
compressed_count += 1;
}
if f.contains(RecordFlags::IGNORED) {
ignored_count += 1;
}
if f.contains(RecordFlags::INIT_DISABLED) {
initially_disabled += 1;
}
}
}
}
eprintln!(" DELETED : {deleted_count}");
eprintln!(" LOCALIZED : {localized_count}");
eprintln!(" COMPRESSED : {compressed_count}");
eprintln!(" IGNORED : {ignored_count}");
eprintln!(" INITIALLY_DISABLED: {initially_disabled}");
Ok(())
}
// ── Test 11: FO4 schema coverage ──────────────────────────────────────────────
/// Measures what fraction of record types encountered in the wild are
/// covered by our FO4 schema registry.
///
/// Emits a detailed coverage report. Does not fail — schema coverage is
/// tracked as a metric, not an invariant.
#[test]
fn fo4_live_11_schema_coverage() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("FO4 SCHEMA COVERAGE");
let paths = collect_plugins(&dir);
let reg = SchemaRegistry::fo4();
let mut sig_counts: HashMap<[u8; 4], u64> = HashMap::new();
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
*sig_counts.entry(record.header.signature.0).or_default() += 1;
}
}
}
let total_distinct = sig_counts.len();
let covered: Vec<([u8; 4], u64)> = sig_counts
.iter()
.filter(|(sig, _)| reg.get(Signature(**sig)).is_some())
.map(|(sig, &count)| (*sig, count))
.collect();
let mut uncovered: Vec<([u8; 4], u64)> = sig_counts
.iter()
.filter(|(sig, _)| reg.get(Signature(**sig)).is_none())
.map(|(sig, &count)| (*sig, count))
.collect();
let total_covered_records: u64 = covered.iter().map(|(_, c)| c).sum();
let total_uncovered_records: u64 = uncovered.iter().map(|(_, c)| c).sum();
let total_records: u64 = total_covered_records + total_uncovered_records;
let coverage_pct = total_covered_records as f64 / total_records as f64 * 100.0;
let type_coverage_pct = covered.len() as f64 / total_distinct as f64 * 100.0;
eprintln!(" Schema registry size : {}", reg.len());
eprintln!(" Distinct record types : {total_distinct} found in wild");
eprintln!(
" Type coverage : {} / {total_distinct} ({type_coverage_pct:.1}%)",
covered.len()
);
eprintln!(
" Record coverage : {total_covered_records} / {total_records} \
({coverage_pct:.1}%)"
);
uncovered.sort_by_key(|b| std::cmp::Reverse(b.1));
if !uncovered.is_empty() {
eprintln!(" Uncovered types (sorted by frequency, known placement records marked *):");
for (sig, count) in uncovered.iter().take(40) {
let s = Signature(*sig);
let known = KNOWN_NO_SCHEMA.contains(&sig);
let marker = if known { " *" } else { "" };
eprintln!(" {s} {count:>8} records{marker}");
}
}
Ok(())
}
// ── Test 12: Schema-guided field decode (RecordView) ──────────────────────────
/// Runs RecordView field decoding on every record whose type is covered by
/// the FO4 schema. Counts decode successes, benign-missing fields, and hard
/// decode errors.
#[test]
fn fo4_live_12_schema_field_decode() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("SCHEMA-GUIDED FIELD DECODE (RecordView)");
let paths = collect_plugins(&dir);
let reg = SchemaRegistry::fo4();
let mut records_decoded = 0u64;
let mut records_skipped = 0u64;
let mut fields_decoded = 0u64;
let mut fields_missing = 0u64;
let mut decode_errors: Vec<String> = Vec::new();
for path in &paths {
let Ok(plugin) = open(path) else { continue };
for group in plugin.groups() {
for record in group.records_recursive() {
let Some(schema) = reg.get(record.header.signature) else {
records_skipped += 1;
continue;
};
records_decoded += 1;
let view = RecordView::new(record, schema, plugin.is_localized());
match view.fields() {
Ok(fields) => {
for f in &fields {
use bethkit_core::FieldValue;
match &f.value {
FieldValue::Missing => fields_missing += 1,
_ => fields_decoded += 1,
}
}
}
Err(e) => {
if decode_errors.len() < 50 {
decode_errors.push(format!(
"{}: {} FormID {:08X}: {e}",
path.file_name().unwrap().to_string_lossy(),
record.header.signature,
record.header.form_id.0,
));
}
}
}
}
}
}
eprintln!(" Records decoded : {records_decoded}");
eprintln!(" Records skipped : {records_skipped} (no schema)");
eprintln!(" Fields decoded (value) : {fields_decoded}");
eprintln!(" Fields missing : {fields_missing}");
eprintln!(" Decode errors : {}", decode_errors.len());
for e in decode_errors.iter().take(10) {
eprintln!(" ERR: {e}");
}
let error_rate = decode_errors.len() as f64 / records_decoded.max(1) as f64;
assert!(
error_rate < 0.005,
"field decode error rate {:.3}% exceeds 0.5% threshold",
error_rate * 100.0
);
Ok(())
}
// ── Test 13: Fallout4.esm deep analysis ───────────────────────────────────────
/// Performs a thorough analysis of `Fallout4.esm` — the base game master —
/// and prints a detailed breakdown.
#[test]
fn fo4_live_13_fallout4_esm_deep_analysis() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("FALLOUT4.ESM DEEP ANALYSIS");
let esm_path = dir.join("Fallout4.esm");
assert!(
esm_path.exists(),
"Fallout4.esm not found at {}",
esm_path.display()
);
let file_size = std::fs::metadata(&esm_path)?.len();
eprintln!(" File size : {}", fmt_bytes(file_size));
let t0 = Instant::now();
let plugin = open(&esm_path).map_err(|e| e.to_string())?;
let open_time = t0.elapsed();
eprintln!(" Open time : {:.3} s", open_time.as_secs_f64());
eprintln!(" HEDR ver : {}", plugin.header.hedr_version);
eprintln!(" Masters : {:?}", plugin.masters());
eprintln!(" Groups : {}", plugin.group_count());
eprintln!(" Localized : {}", plugin.is_localized());
let mut sig_counts: HashMap<[u8; 4], u64> = HashMap::new();
let mut total_records = 0u64;
let mut compressed_records = 0u64;
let mut deleted_records = 0u64;
let mut localized_records = 0u64;
let mut total_subrecords = 0u64;
let mut failed_subrecords = 0u64;
for group in plugin.groups() {
for record in group.records_recursive() {
total_records += 1;
*sig_counts.entry(record.header.signature.0).or_default() += 1;
let f = record.header.flags;
if f.contains(RecordFlags::COMPRESSED) {
compressed_records += 1;
}
if f.contains(RecordFlags::DELETED) {
deleted_records += 1;
}
if f.contains(RecordFlags::LOCALIZED) {
localized_records += 1;
}
match record.subrecords() {
Ok(srs) => total_subrecords += srs.len() as u64,
Err(_) => failed_subrecords += 1,
}
}
}
eprintln!(" Total records : {total_records}");
eprintln!(" Compressed records : {compressed_records}");
eprintln!(" Deleted records : {deleted_records}");
eprintln!(" Localized records : {localized_records}");
eprintln!(" Total subrecords : {total_subrecords}");
eprintln!(" Subrecord failures : {failed_subrecords}");
let mut sorted: Vec<([u8; 4], u64)> = sig_counts.into_iter().collect();
sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
eprintln!(" Top 30 record types:");
for (sig, count) in sorted.iter().take(30) {
let pct = *count as f64 / total_records as f64 * 100.0;
eprintln!(" {} {:>8} ({pct:4.1}%)", Signature(*sig), count);
}
assert_eq!(
failed_subrecords, 0,
"Fallout4.esm had subrecord parse failures"
);
Ok(())
}
// ── Test 14: Throughput benchmark ─────────────────────────────────────────────
/// Measures raw plugin-open throughput across all FO4 plugins.
///
/// Does not assert performance numbers — prints a benchmark summary only.
#[test]
fn fo4_live_14_throughput_benchmark() -> Result<(), Box<dyn std::error::Error>> {
let Some(dir) = find_data_dir() else {
return Ok(());
};
banner("THROUGHPUT BENCHMARK");
let paths = collect_plugins(&dir);
let total_bytes: u64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
// Warm up: open once, discard.
for path in paths.iter().take(3) {
let _ = open(path);
}
let t0 = Instant::now();
let mut records = 0u64;
let mut errors = 0usize;
for path in &paths {
match open(path) {
Ok(plugin) => {
for group in plugin.groups() {
for _record in group.records_recursive() {
records += 1;
}
}
}
Err(_) => errors += 1,
}
}
let elapsed = t0.elapsed();
BenchResult {
label: "open + record scan (all plugins)",
files: paths.len(),
bytes: total_bytes,
elapsed,
records,
errors,
}
.print();
Ok(())
}

File diff suppressed because it is too large Load diff