Skip to content

ChangeForm parser coverage fixes for heavily-modded saves (7 byte-exact fixes)#1

Open
WingedGuardian wants to merge 7 commits into
mdfairch:mainfrom
WingedGuardian:changeform-parser-coverage
Open

ChangeForm parser coverage fixes for heavily-modded saves (7 byte-exact fixes)#1
WingedGuardian wants to merge 7 commits into
mdfairch:mainfrom
WingedGuardian:changeform-parser-coverage

Conversation

@WingedGuardian

Copy link
Copy Markdown

ChangeForm parser coverage fixes for heavily-modded saves

Seven byte-level fixes to the resaver.ess ChangeForm parsers, found while parsing a heavily-modded
Skyrim VR save (708 plugins). Each was derived by decoding the actual save bytes and validated
read-only and byte-exact before inclusion — no fix was guessed.

How these were validated (no-guess methodology)

REFR and ACHR end their parse with if (super.readUnparsed(input)) throw new UnparsedException(), so a
correct body layout consumes the whole body and leaves zero residual. That boundary is the oracle:
a candidate layout is accepted only when every affected change-form reaches position == body.limit()
across three independent saves with no regression on the rest. For QUST (which lacked that
boundary) the body length is known, so the same position == limit check was constructed as a gate —
and the missing boundary is added here so the reader is self-checking like the others. Layouts were
cross-referenced against the UESP Save File Format pages where documented.

The fixes

# File Fix Effect
1 ChangeFormExtraDataData Implement type 16 (ExtraExtraData4, 4 nested) was commented out → desync
2 ChangeFormExtraDataData Implement type 20 (ExtraExtraData5, 5 nested) hit default → throw
3 ChangeFormExtraDataData Implement type 135 (FollowerSwimBreadcrumbs) TEST stub under-read → ~450 follower forms/save failed
4 ChangeFormExtraDataData Fix type 113 (SayToTopicInfo) under-read unread count byte corrupted next wstring → BufferUnderflowException
5 ChangeFormQust Fix ClassCastException (cast Element[] to concrete types) every quest with stages/objectives/run-data threw → +2146 quests
6 ChangeFormQust Read QuestInstances + accept QuestRunData type 0 + add readUnparsed gate 492 silent under-reads + 136 throws → 0; all 2611 QUST byte-exact
7 ChangeFormRefr Remove duplicate promoted-ref read second read ran past body end → BufferUnderflowException on every promoted REFR (thousands/save)

Notes:

  • #5 and #6 both touch ChangeFormQust and are ordered so the cast fix lands first; #6 is split out
    because it is a larger, independent parsing change (and adds the boundary check).
  • #7: the promoted-reference list is already read as the PromotedRef EXTRADATA element (case 140),
    which the same CHANGE_REFR_PROMOTED flag triggers; the separate read was a duplicate.
  • Each commit is self-contained and individually cherry-pickable, with the byte-level rationale in its
    message.

Scope / safety

Read-path only — no change to write()/updateRawData, so this cannot alter how a save is written; it
only lets ReSaver parse change-forms it previously failed on. Validated on three saves with zero
regression to the previously-parsing forms. Builds cleanly against the current tree.

🤖 Generated with Claude Code

WingedGuardian and others added 7 commits June 30, 2026 00:40
Type 16 is part of the ExtraExtraData family (4/8/12/16/20), each reading
N = type/4 nested ChangeFormExtraDataData with no count prefix. It was left
commented out, so any extra-data array containing it desynced.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Final member of the ExtraExtraData family; reads 5 nested ChangeFormExtraDataData.
Previously hit the default case. Validated byte-exact across 3 saves.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…mbs)

case 135 was an unfinished TEST stub reading 4 RefIDs; the real record is larger,
so the stub under-read and desynced the extra-data array. The misaligned bytes were
then misread as bogus unknown types and Excessive array count errors, failing ~450
follower changeforms per save. Layout per UESP. Validated byte-exact (buffer position
== bodyLen) across saves; phantom-type failures cleared with no regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Type 113's DATA2 is a SayToTopicInfoData (vsval count + SayToTopicInfoData2[count]
+ uint16 + refID[4]), not a single bare SayToTopicInfoData2. The unread count byte
corrupted the following TEXT1 wstring length -> BufferUnderflowException. Validated
byte-exact; recovered the affected REFR/ACHR forms with zero regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ctives/run-data

readVSElemArray/readElements always return Element[], so the casts to QuestStage[],
QuestObjective[], QuestRunDataItem1[], QuestRunDataItem2[], RefID[] threw
ClassCastException unconditionally whenever the matching quest change flag was set.
Only quests with none of those flags parsed. Byte-neutral: relax the 5 casts and 5
field types to Element[]. +2146 quests recovered on a 708-plugin save.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…nparsed gate

Two byte-level parser bugs on quests with live run-time state:
 - CHANGE_QUEST_INSTANCES (bit 27) was never read; per UESP a QuestInstances block
   sits between RUNDATA and ALREADY_RUN, so every instanced quest silently under-read.
 - QuestRunDataItem3Data threw on type 0, which is valid and refID-typed (like 1/2/4).
Also adds the missing self-checking 'if (!inline && readUnparsed) throw' gate that
REFR/ACHR have (its absence is why the under-reads were silent). Validated byte-exact
(position == body.limit) on all 2611 QUST across 3 saves; 0 throw, 0 residual.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…rflow)

The promoted-ref list is already read as the EXTRADATA PromotedRef element
(ChangeFormExtraDataData case 140), which the CHANGE_REFR_PROMOTED flag also triggers.
The separate CHANGE_REFR_PROMOTED read here is a duplicate that consumes past the body
end and throws BufferUnderflowException on every promoted REFR. Removing it recovers
thousands of REFRs per save; every recovered form reaches the readUnparsed() boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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