Skip to content

Rust + KID pivot — M2 Part 2: mora-esp#43

Merged
halgari merged 24 commits intomasterfrom
m2-mora-esp-foundation
Apr 21, 2026
Merged

Rust + KID pivot — M2 Part 2: mora-esp#43
halgari merged 24 commits intomasterfrom
m2-mora-esp-foundation

Conversation

@halgari
Copy link
Copy Markdown
Owner

@halgari halgari commented Apr 21, 2026

Summary

Delivers mora-esp end-to-end per
docs/superpowers/plans/2026-04-21-rust-kid-pivot-plan-5-mora-esp.md:

  • Signature type + byte-reader primitives
  • Header parsers — Record (24 bytes), Group (24 bytes), Subrecord (6 bytes, with XXXX override for large payloads)
  • LZ4 Frame decompression for the 0x00040000 compressed-record flag. Verified format via Task 8 Step 0 against xEdit source: LZ4 Frame format (not Block) with 4-byte LE u32 decompressed-size prefix, using lz4_flex::frame::FrameDecoder.
  • TES4 file-header parser — HEDR + MAST + CNAM + SNAM; ESM / ESL flag detection
  • plugins.txt parser — CRLF-tolerant, comment-aware, active-marker *
  • Load-order resolver — implicit Bethesda ESMs + user plugins; 0x00-0xFD full pool + 0xFE light-slot sub-indexing (12-bit)
  • Master-index FormID remapping — local → resolved FormID
  • EspPlugin — mmap a plugin file, parse TES4 header
  • EspWorld — iterate records of a given signature across all active plugins in load order, with resolved FormIDs
  • Subrecord parsers — EDID (editor ID), KWDA (keyword FormID array)
  • Record-type accessorsWeaponRecord, ArmorRecord (editor_id + keywords; handles LZ4 compression)
  • mora-core breaking changeDistributor trait is now generic over World to avoid a mora-core ↔ mora-esp cycle
  • docs/src/mora-esp-reference.md — binary-format spec + plugins.txt + load-order

Test plan

  • cargo test --workspace124 tests pass (72 from M2 Part 1 + 52 new)
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • cargo xwin check --target x86_64-pc-windows-msvc --workspace clean
  • Self-hosted skyrim-integration still red until Unraid runner image refresh (blocked since Plan 3)

Notable findings

  • LZ4 format confirmed by Task 8 Step 0 subagent fetching xEdit's lz4/lz4io.pas + wbImplementation.pas: LZ4 Frame (magic 0x184D2204) with 4-byte LE u32 decompressed-size prefix, read via lz4_flex::frame::FrameDecoder. Thank you for the correction on Frame vs Block.

Scope discipline

  • SSE/AE only — LE/Oldrim unsupported (different compression, record version).
  • Subrecord parsers at M2: EDID + KWDA. Trait-bearing subrecords (DNAM for PLAYABLE/UNIQUE, etc.) land when mora-kid needs them.
  • Record types at M2: Weapon + Armor. Other 18 from the KID compat matrix land per-type in Plan 6+.
  • No mora-kid code. That's Plan 6.

Next up

Plan 6: mora-kid MVP — KID INI parser, filter AST, distributor impl for Weapon + Armor, first end-to-end mora compile producing mora_patches.bin.

halgari added 24 commits April 20, 2026 21:45
Delivers mora-esp end-to-end. Covers: byte-reader primitives, TES4
header parsing, record/group/subrecord iteration, LZ4 Frame
decompression (Task 8 Step 0 verifies against xEdit/CommonLibSSE-NG),
plugins.txt parser, load-order resolver (full + light slot pool),
master-index remapping, EspPlugin (mmap), EspWorld (cross-plugin
indexed view), EDID/KWDA subrecord parsers, WEAP/ARMO record types.

22 tasks across 13 phases. mora-core::Distributor becomes generic over
World to avoid a mora-core -> mora-esp cycle. Synthetic ESP fixture
builders in tests/fixtures.rs drive end-to-end integration tests
covering the full parser stack.
ESP binary format (SSE/AE), plugins.txt spec, load-order rules
(including ESL light-slot pool + sub-indexing), master-index
remapping algorithm, LZ4 Frame compression notes, synthetic-fixture
test strategy. Cited by every task in Plan 5.
lz4_flex = 0.11 added to workspace deps (pure-Rust LZ4 for
SSE/AE compressed records). mora-esp src/: module tree for reader,
signature, tes4, record, group, subrecord, compression, plugins_txt,
load_order, plugin, world, records/, subrecords/. All stubs minimal.
4-byte ASCII signatures (TES4/GRUP/WEAP/ARMO/HEDR/MAST/EDID/KWDA/XXXX).
Display / as_str / as_bytes accessors. 3 unit tests.
le_u8/u16/u32/f32 + read_signature + read_bytes + read_cstr against
a &[u8] cursor. ReadError::Truncated for short input. 6 unit tests
covering happy path, truncation, and NUL handling.
24-byte header parse → Record { signature, flags, form_id,
record_version, body: &[u8] }. is_compressed() / is_deleted() flag
accessors. 5 unit tests covering minimal header, body, compressed
flag, deleted flag, truncation.
24-byte group header → Group { label, group_type, contents: &[u8] }.
Top-level test (group_type == 0) + label_signature accessor. 2 unit
tests: empty WEAP group, group with payload.
Implements subrecord.rs — SubrecordIter that iterates over a record body,
handling the XXXX subrecord whose 4-byte u32 payload overrides the next
subrecord's u16 size field. Three tests: happy path, 100k XXXX override,
and empty body.
decompress(body) reads a 4-byte LE u32 decompressed_size prefix
then inflates the LZ4 Frame payload via lz4_flex::frame::FrameDecoder.
Errors: Read (truncation), Lz4 (frame error), SizeMismatch (prefix
disagrees with actual inflated length). 3 round-trip tests: small
payload, 10k-element payload, size-mismatch detection.

Step 0 findings: plan's assumption confirmed correct. xEdit lz4io.pas
selectDecoder dispatches on LZ4S_MAGICNUMBER ($184D2204) = LZ4 Frame.
wbImplementation.pas TwbMainRecord.DecompressIfNeeded reads the first
4 bytes as PCardinal (decompressed size), then passes the remainder
starting at dcDataBasePtr+SizeOf(Cardinal) to lz4DecompressToUserBuf.
Format is Frame (not Block) with 4-byte LE u32 prefix — exactly as the
plan assumed. No adjustments to code or tests were needed.
parse_tes4(bytes) -> Tes4Header { flags, version, num_records,
next_object_id, masters, author, description }. is_esm() / is_esl()
flag accessors. CNAM/SNAM signatures added. 2 smoke tests: TES4
with two masters, TES4 with no masters.
parse(content) -> Vec<PluginEntry { name, active }>, preserving file
order. active_plugins filter. Handles CRLF/LF, comments, blank lines,
leading/trailing whitespace. 4 unit tests.
LoadSlot::Full(u8) | Light(u12) with compose_form_id helper.
LoadOrder indexes plugins by lowercased name. build() assigns slots
in (implicit first, user-active second) order; ESL-flagged plugins
go to the 0xFE light pool without consuming a full slot. 5 unit tests.
Adds master-index remapping helper that converts plugin-local FormIDs
into fully-resolved FormIDs using the plugin's master list and live
load order. Includes 5 tests covering master references, self-reference,
ESL remap, and out-of-range validation.
EspPlugin::open(path) mmaps the file and parses the TES4 header.
Exposes filename, masters, is_esm / is_esl, and body() — the slice
after the TES4 record. Arc<Mmap> for multi-threaded access. 1 smoke
test: open a synthesized ESM file, confirm flags + masters.
EspWorld::open(data_dir, plugins_txt) -> World. records(signature)
iterates all records of that type across plugins in load order,
with resolved FormIDs. Nested groups are skipped. Integration tests
land in tests/esp_format.rs (Task 21).
Plan 5's EspWorld placeholder becomes real in mora-esp, but adding
a mora-core -> mora-esp dep would cycle. Generic World param lets
frontends bind to mora_esp::EspWorld without the cycle. Old EspWorld
type retained with #[deprecated]; removed from crate re-exports.
parse(data) -> Result<String, ReadError> reads a NUL-terminated
ASCII string. 3 unit tests: happy, empty, missing NUL errors.
parse(data) -> Vec<u32> reads LE u32 FormIDs. Rejects payloads not a
multiple of 4 bytes. 3 unit tests: 3-id happy, empty, unaligned error.
IDs are local — callers remap via the plugin's master list.
parse(record) -> WeaponRecord { editor_id, keywords }. Handles
compressed records (LZ4 inflation). Keywords are local FormIDs —
caller remaps via plugin's master list. Full-stack tests in
tests/esp_format.rs.
parse(record) -> ArmorRecord { editor_id, keywords }. Mirrors
weapon.rs; the two share no code to let per-type struct evolve
independently.
PluginBuilder / GroupBuilder / RecordBuilder / SubrecordBuilder +
edid_payload / kwda_payload helpers. Composes ESP byte buffers
inline — reused by esp_format.rs integration tests.
4 tests using synthetic plugins written to tmp: open single plugin,
EspWorld iterates across A.esm + B.esp, WEAP parse extracts EDID +
KWDA, ARMO smoke. Exercises full stack: mmap -> TES4 parse ->
plugins.txt parse -> load order -> group scanning -> record iteration
-> subrecord parsing.
Applied cargo clippy --fix (for-loops over while-let, .is_multiple_of,
repeat_n, lifetime elisions) plus cargo fmt. Also added crate-level
#[allow(dead_code, clippy::should_implement_trait)] to tests/fixtures.rs
since builder methods are test-only and some (esl, flag) aren't called
yet by every integration test.
KWDA FormIDs in records are plugin-local (high byte = index into the
source plugin's master list), not yet resolved runtime FormIDs. Plan
5 originally returned raw Vec<u32>; this commit resolves them at
parse time using the enclosing EspWorld's load order, so callers
receive Vec<FormId> (the nominal type from mora-core).

- WeaponRecord::keywords: Vec<u32> -> Vec<FormId>
- ArmorRecord::keywords: Vec<u32> -> Vec<FormId>
- weapon::parse(record, plugin_index, world) -> adds context
- armor::parse(record, plugin_index, world) -> adds context
- WorldRecord::resolved_form_id: u32 -> FormId
- New EspWorld::resolve_in_plugin(plugin_index, raw_local) helper
- New EspWorld::weapons() / armors() typed iterators yielding
  Result<(FormId, TypedRecord), _>
- Unresolvable keywords (referenced plugin not in load order) are
  silently dropped to match KID behavior

Integration tests extended to cover silent-drop semantics and the
typed weapons() iterator. 126 tests pass workspace-wide (124 + 2).
@halgari halgari merged commit 4e58b3b into master Apr 21, 2026
6 checks passed
@halgari halgari deleted the m2-mora-esp-foundation branch April 21, 2026 04:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant