mirror of
https://github.com/Modding-Forge/bethkit.git
synced 2026-05-25 21:50:03 -07:00
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:
parent
bdf2f99f4e
commit
3955dd9e44
17 changed files with 2435 additions and 2835 deletions
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
2026
crates/bethkit-ffi/bethkit.h
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue