Skip to content

Keep both halves of a gendered pair split by other subrecords - #700

Open
Avick3110 wants to merge 3 commits into
Mutagen-Modding:devfrom
Avick3110:gendered-pair-split-markers
Open

Avick3110 wants to merge 3 commits into
Mutagen-Modding:devfrom
Avick3110:gendered-pair-split-markers

Conversation

@Avick3110

Copy link
Copy Markdown
Contributor

A gendered field whose male and female halves are separate subrecords loses the half it read first when another subrecord sits between the two. Each generated case arm reads the pair, builds a new GenderedItem and assigns it, so when the arm is entered again for the later half it replaces the pair. The direct parse and the overlay both do this, and a write then omits the lost subrecords.

The order that triggers it

An ARMA written as

EDID, DNAM, MOD2, MO2T, MOD4, MO4T, MOD3, MOD5

rather than the Creation Kit's MOD2, MO2T, MOD3, MO3T, MOD4, MO4T, MOD5. xEdit and the game both read it. Mutagen reads both male models as absent, and a write carries only MOD3 and MOD5.

Which fields are affected

Field Record Pair Separated half lost?
WorldModel, FirstPersonModel ARMA MOD2/MOD3, MOD4/MOD5 yes, fixed
SkinTexture, TextureSwapList ARMA NAM0/NAM1, NAM2/NAM3 yes, fixed
WorldModel ARMO MOD2/MOD4 yes, fixed
ParentTitle, Title ASTP MPRT/FPRT, MCHT/FCHT yes, fixed
SkeletalModel, HeadData RACE MNAM/FNAM; NAM0 ahead of each yes, fixed
BodyData, BehaviorGraph RACE NAM1/NAM3 block, then MNAM/FNAM if the block marker repeats; fixed
Title Rank (FACT) MNAM/FNAM a different failure (the arm stops on re-entry); not fixed here
SkeletalModel Starfield RACE MNAM/FNAM not covered: hand-written in Race.cs and routed with other RACE fields
Voices, DecapitateArmors, DefaultHairColors RACE one subrecord no

Rows are Skyrim unless named; the other games get the same change on their split pairs.

The fix

When a gendered arm is entered and the field already holds a pair, the newly read half is merged into it instead of replacing it.

  • GenderedItemBinaryTranslation and GenderedItemBinaryOverlay: the helpers a split pair reaches take an optional existing pair and replace only the halves they read. A held half is never replaced by an unconverted subrecord such as Starfield's FLLD.
  • GenderedTypeBinaryTranslationGeneration: IsSplitPair decides where existing is passed. A split pair routed to a helper that cannot merge, such as ParseRequired without markers, is refused at generation.
  • PluginTranslationModule: CopyInFromBinary starts each split pair empty, so a pair present in the bytes is replaced whole. A split pair's own subrecord types go straight to its arm, which fixes Starfield ARMA, where a MOD3 after MOD4 fell into the ExtraLightLayers branch.
  • Regenerated output in its own commit.

I chose merging over scanning ahead for the other marker, which would still leave the arm entered again at the later marker.

Open questions

  • A subrecord reaching an arm whose pair is already complete is now skipped, where it used to overwrite the male half. Would you rather it threw?
  • CopyInFromBinary blanks each split pair before the parse and restores it if the arm never ran. I can move this onto the parse loop's per-record state (PreviousParse) instead.
  • No test covers a complete pair yet, pending your answer to the first.

Behaviour change

None for callers: merging happens only within one record. On malformed input the overlay now returns what the direct parse already did.

How tested

GenderedSplitPairTests in Skyrim, Starfield, Fallout 4, Oblivion and Fallout 3: 18 cases on raw record bytes, each read through the direct parser and the overlay. 16 fail on dev, in every game; the two that pass are the Creation Kit order control and a check that copy-in still replaces a pair present in the bytes whole.

Mutagen.Records.sln builds in Release with 0 errors, and the unit tests pass on net9.0 and net10.0. Every ARMA, ARMO, RACE, ASTP and FACT in Skyrim.esm and its DLC (6,586 records), read by both paths and written back, hashes the same as on dev. I had no data for Fallout 4, Starfield, Oblivion or Fallout 3, so please run their passthrough tests before merging.

A separate issue, not fixed here: ArmorAddonBinaryOverlay.GetWeightSliderEnabledCustom() throws on an ARMA with no DNAM (#699).

I found this through houseCARL, a tool built on Mutagen, which read and then rewrote an ARMA from a plugin generated by CBBEtoUBE and lost its male models.

🤖 Generated with Claude Code

Avick3110 and others added 3 commits October 1, 2026 18:50
A gendered field whose male and female halves are separate subrecords can
have its arm entered once per half when another subrecord sits between
them. Each entry built a new pair and replaced the field, so the half read
first was lost, in the direct parse and in the overlay, and a write then
dropped that half's subrecords.

The parse and overlay helpers that a split pair reaches now take the
field's current value as existing and replace only the halves they read.
A marker followed by nothing that parses keeps the half it had. In the
converter forms a held half is replaced only by a subrecord converted to
that half; a subrecord that neither converter maps (Starfield's FLLD and
XFLG on ARMA models) goes to a half the pair does not hold yet, from an
earlier entry or from this one, and the overlay keeps it only if the item
reads something.

The lazy GenderedItemBinaryOverlay keeps, per half, the memory that half
starts at, taken from this entry or copied from the previous one.
FactorySkipMarkersPreRead read the next subrecord's header and skipped its
content before checking whether it was a marker, so it swallowed the
subrecord that ended a separated pair. It now peeks at the header and
stops without moving.

In generation, IsSplitPair (a trigger and no single record type) decides
where existing is passed, in the parse and in the overlay. A split pair
that would reach a helper which cannot keep a half across entries is
refused at generation: the ParseRequired overloads without markers, which
always read the first item as male; the contentMarker and MarkerWithinItem
parses; and in the overlay, the marker-ahead form that takes
translationParams and the branch that reads both halves from one
subrecord. No field reaches any of them.

The generated CopyInFromBinary sets each split pair to null before the
parse and puts the old value back only if the arm was never entered, so a
pair present in the bytes replaces the target's pair whole, like every
other present field.

When a split pair shares some subrecord types with other fields, the
generator routed all of its types by the last parsed field. Starfield
ARMA's WorldModel shares FLLD and XFLG with FirstPersonModel and
ExtraLightLayers, so a MOD3 after MOD4 fell into the ExtraLightLayers
branch. A split pair's own subrecord types now go straight to its arm;
only the shared ones are routed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Output of Mutagen.Bethesda.Generator.All: the 18 files that hold a split
gendered pair (ARMA, ARMO, ASTP, RACE and Rank in Skyrim and Fallout 4;
ARMA, ARMO, RACE and Rank in Starfield; RACE and Rank in Oblivion and
Fallout 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds records from raw bytes and reads each through the direct parser
and the overlay, in Skyrim, Starfield, Fallout 4, Oblivion and Fallout 3:
ARMA models in the order the CBBEtoUBE v1.5 converter writes them (MOD2,
MOD4, MOD3), the Creation Kit order as a control, interleaved texture and
ASTP title pairs, an ARMO whose MOD2 and MOD4 are separated, RACE
HeadData, SkeletalModel and a repeated BodyData block, a marker with
nothing after it, unconverted FLLD subrecords in the WorldModel arm, a
male-only Starfield world model, a write of the interleaved ARMA, and
CopyInFromBinary into a filled ARMA. The ARMA records carry a zeroed
DNAM, as every real ARMA does, because the overlay reads it when writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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