bethkit/docs/modules/ROOT/pages/reading-plugins.adoc

280 lines
7.4 KiB
Text

= Reading Plugins
This page covers everything about opening plugin files and navigating their contents with bethkit.
== Opening a plugin
[source,rust]
----
use bethkit_core::{GameContext, Plugin};
let ctx = GameContext::sse(); // Skyrim Special Edition
let plugin = Plugin::open("MyMod.esp".as_ref(), ctx)?;
----
`Plugin::open` memory-maps the file.
No data is copied into heap buffers — all records reference the memory-mapped region directly.
=== Inspecting the file header
[source,rust]
----
println!("kind: {:?}", plugin.kind()); // Esp / Esm / Esl
println!("masters: {:?}", plugin.masters()); // &[String]
println!("description: {:?}", plugin.header.description); // Option<String>
----
`plugin.kind()` returns a `PluginKind` based on the flags in the TES4 record header, not the
file extension.
The plugin description and author are accessible through `plugin.header`, which is a public
`PluginHeader` struct.
=== Supported game contexts
[cols="1,1",options="header"]
|===
| Context | Game
| `GameContext::sse()`
| Skyrim Special Edition (primary target; named constructor)
| `GameContext { game: Game::SkyrimLE }`
| Skyrim Legendary Edition
| `GameContext { game: Game::SkyrimVR }`
| Skyrim VR
| `GameContext { game: Game::Fallout4 }`
| Fallout 4
| `GameContext { game: Game::Fallout76 }`
| Fallout 76
| `GameContext { game: Game::Starfield }`
| Starfield
| `GameContext { game: Game::Oblivion }`
| Oblivion
| `GameContext { game: Game::Fallout3 }`
| Fallout 3
| `GameContext { game: Game::FalloutNV }`
| Fallout: New Vegas
| `GameContext { game: Game::Morrowind }`
| Morrowind
|===
NOTE: `GameContext` only encodes binary-format differences (header signature, HEDR version,
light-flag bit position).
Record *schema* definitions currently exist only for SSE.
== Groups
A plugin contains a flat list of top-level groups.
Each group holds records of one type (or nested sub-groups for cell/worldspace data).
=== Group types
`GroupType` is an `enum` that identifies how a GRUP header's label should be interpreted.
[cols="1,2",options="header"]
|===
| Variant | Meaning
| `GroupType::Normal`
| Top-level group. Label is a 4-byte record signature (`NPC_`, `WEAP`, …).
| `GroupType::WorldChildren`
| Children of a worldspace (WRLD). Label is the parent worldspace FormID.
| `GroupType::InteriorCellBlock` / `InteriorCellSubBlock`
| Interior cell block/sub-block. Label is an `i32` block number.
| `GroupType::ExteriorCellBlock` / `ExteriorCellSubBlock`
| Exterior cell block/sub-block. Label is a grid coordinate (`i16 x`, `i16 y`).
The on-disk byte layout stores Y in bytes 0-1 and X in bytes 2-3; the API
exposes conventional `{ x, y }` field order.
| `GroupType::CellChildren`
| Children of a CELL record. Label is the cell FormID.
| `GroupType::TopicChildren`
| Children of a DIAL topic. Label is the topic FormID.
| `GroupType::CellPersistentChildren` / `CellTemporaryChildren`
| Persistent / temporary placed-reference children of a cell.
|===
`GroupLabel` is the interpreted label value, produced by `GroupHeader::label`.
Its variants mirror `GroupType`:
* `GroupLabel::Signature(Signature)` — for `Normal` groups.
* `GroupLabel::FormId(FormId)` — for worldspace / cell / topic children.
* `GroupLabel::GridCell { x, y }` — for exterior cell blocks.
* `GroupLabel::BlockNumber(i32)` — for interior cell blocks.
`GroupChild` is the type yielded by iterating a group's children.
It is an enum with two variants:
[source,rust]
----
match child {
GroupChild::Record(record) => { /* a main record */ }
GroupChild::Group(sub_group) => { /* a nested GRUP */ }
}
----
Use `group.children()` to iterate `GroupChild` directly, or the convenience
methods `group.records()` / `group.records_recursive()` for records-only traversal.
[source,rust]
----
for group in plugin.groups() {
println!("GRUP type: {:?}", group.header.group_type);
println!("GRUP label: {:?}", group.header.label);
for record in group.records() {
// direct children only — no recursion into sub-groups
}
for record in group.records_recursive() {
// all records including those in nested sub-groups
}
}
----
`records_recursive()` is the usual choice — it handles cell block / sub-block nesting
transparently.
=== Filtering by record type
[source,rust]
----
use bethkit_core::Signature;
for group in plugin.groups() {
// Only descend into NPC_ groups
if group.header.label == GroupLabel::RecordType(Signature(*b"NPC_")) {
for record in group.records() {
// …
}
}
}
----
== Records
Every `Record` exposes its header and a lazy view of its subrecords.
[source,rust]
----
for group in plugin.groups() {
for record in group.records_recursive() {
let sig = record.header.signature; // Signature (4-byte array)
let fid = record.header.form_id; // FormId (u32 wrapper)
let flags = record.header.flags; // RecordFlags (bitflags)
println!("{sig} {fid:#010X} {:?}", flags);
}
}
----
=== Convenience accessors
[source,rust]
----
// EditorID (EDID subrecord) — None if absent
if let Some(edid) = record.editor_id()? {
println!("EditorID: {edid}");
}
// Full name (FULL subrecord, inline) — None if absent
if let Some(full) = record.full_name()? {
println!("Name: {full}");
}
----
=== Accessing subrecords by signature
[source,rust]
----
use bethkit_core::Signature;
// First matching subrecord (returns None if not present)
if let Some(sr) = record.get(Signature(*b"DATA"))? {
let bytes = sr.as_bytes(); // &[u8]
println!("DATA length: {}", bytes.len());
}
// All subrecords with a given signature (for repeating fields)
for sr in record.get_all(Signature(*b"KWDA"))? {
let kw = sr.as_bytes();
// each KWDA is a 4-byte FormID
}
----
== SubRecord payloads
`SubRecord::as_bytes()` always returns the complete, decompressed payload.
bethkit also provides typed convenience methods:
[source,rust]
----
let sr: &SubRecord = /* … */;
let raw: &[u8] = sr.as_bytes();
let u8val: u8 = sr.as_u8()?;
let u16val: u16 = sr.as_u16()?;
let u32val: u32 = sr.as_u32()?;
let f32val: f32 = sr.as_f32()?;
let i8val: i8 = sr.as_i8()?;
let i16val: i16 = sr.as_i16()?;
let i32val: i32 = sr.as_i32()?;
let s: &str = sr.as_zstring()?; // NUL-terminated UTF-8
----
For richer, schema-aware decoding see xref:schema.adoc[Record Schema].
== Checking record flags
[source,rust]
----
use bethkit_core::RecordFlags;
if record.header.flags.contains(RecordFlags::DELETED) {
// record is deleted — skip
}
if record.header.flags.contains(RecordFlags::COMPRESSED) {
// decompressed automatically by as_bytes()
}
if record.header.flags.contains(RecordFlags::LOCALIZED) {
// LString fields contain string-table IDs — pass a LocalizationSet to resolve them
}
----
== Working example — dump all NPC_ EditorIDs
[source,rust]
----
use bethkit_core::{GameContext, Plugin, Signature};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ctx = GameContext::sse();
let plugin = Plugin::open("Skyrim.esm".as_ref(), ctx)?;
for group in plugin.groups() {
for record in group.records_recursive() {
if record.header.signature != Signature(*b"NPC_") {
continue;
}
if let Some(edid) = record.editor_id()? {
println!("{edid}");
}
}
}
Ok(())
}
----