Skip to content

Add add_record_override() for REFR overrides with proper group hierarchy - #4

Open
jeditobe1 wants to merge 3 commits into
BadDogSkyrim:mainfrom
jeditobe1:pr/add-record-override
Open

Add add_record_override() for REFR overrides with proper group hierarchy#4
jeditobe1 wants to merge 3 commits into
BadDogSkyrim:mainfrom
jeditobe1:pr/add-record-override

Conversation

@jeditobe1

Copy link
Copy Markdown

add_record() places everything in flat type-0 groups, which causes
save-time crashes for REFR overrides — placed records must live
inside the correct cell/worldspace structure (WRLD > worldspace >
cell block > persistent children) alongside the parent WRLD/CELL
records the engine expects.

This PR adds add_record_override(record, source_plugin) plus the
supporting machinery to clone a placed record from a source plugin
into a destination patch with the full hierarchy intact.

Three commits, each with a self-contained rationale in the message:

  1. Add add_record_override() with proper REFR group hierarchy.
    Walks the source plugin's group path, collecting parent WRLD
    before type-1 groups and parent CELL before type-6/8/9 groups,
    then creates/finds matching groups in the destination and clones
    the parent records into place.

  2. Translate strings and remap FormIDs on parent-record clones.
    copy.copy() of the parent CELL/WRLD brings every subrecord along
    verbatim — including FULL string IDs and FormID subrecords (LTMP,
    XLCN, XEZN, XOWN, etc.) that point into the source plugin's
    tables and master list. Skyrim does not field-merge CELL
    overrides, so the clone fully replaces vanilla. Without this fix
    cell names render as garbage (non-localized destination) or
    resolve against the wrong table (localized destination), and
    FormID subrecords with self-index master refs alias to the wrong
    plugin after master-list reordering, half-loading the cell at
    runtime.

    Adds _localize_strings (inverse of _delocalize_strings),
    StringTable.allocate_id, and a _LOCALIZED_STRING_TABLE_TYPE
    signature → table-type map (FULL/SHRT/ITXT → .STRINGS,
    DESC/NNAM → .DLSTRINGS; note MESG ITXT lives in .STRINGS not
    .ILSTRINGS despite the extension naming). Extends
    _FORMID_SUBRECORD_SIGS_FALLBACK with CELL FormID signatures
    (LTMP, XLCN, XEZN, XCMO, XCAS, XCCM, XCIM, XCWT, XOWN) for
    records that hit the fallback path with no schema bound.

  3. Add clone_for_override + remap record FormID in
    add_record_override.
    Documents the deep-copy + master-remap
    step as a public method, and fixes a latent bug where the
    override record's own FormID kept the source-side master index
    when source/destination master orderings differed (no-op for
    Skyrim.esm-only callers since index 0 maps to 0 in any list
    that includes it).

jeditobe1 and others added 3 commits May 6, 2026 10:55
REFR and other placed records must live inside the correct cell/
worldspace group structure (WRLD > worldspace > cell block >
persistent children) — and alongside parent WRLD/CELL records the
engine expects in the hierarchy. The existing add_record() places
everything in flat type-0 groups, which causes save-time crashes
for REFR overrides.

add_record_override(record, source_plugin) finds the group path to
the record in the source plugin (collecting parent WRLD before
type-1 groups and parent CELL before type-6/8/9 groups), then
creates/finds matching groups in the destination and clones the
parent records into place. Falls back to flat grouping if the path
isn't found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cord_override

When add_record_override clones a parent CELL/WRLD record to host
the override REFR's group hierarchy, copy.copy() brings every
subrecord along verbatim — including FULL string IDs and FormID
subrecords (LTMP, XLCN, XEZN, XOWN, etc.) that point into the
source plugin's tables and master list. Skyrim does NOT field-merge
CELL overrides, so the cloned CELL fully replaces vanilla and the
engine reads stale bytes when resolving names and references.

Symptoms before this fix:

- In a non-localized destination the FULL bytes rendered as garbage
  windows-1252 text ("Xa") for any cascade walking into the cloned
  cell (REFR -> XTEL -> dest REFR -> parent CELL -> FULL).
- In a localized destination the FULL ID resolved against our own
  empty/wrong table.
- LTMP/XLCN/etc. with master indices pointing at the source plugin's
  self-index aliased to a different plugin after master-list
  reordering, half-loading the cell at runtime (skybox only) on any
  transition into the clone. Surfaced on Skyrim Randomizer Phase 2.7
  door gating: LTMP on DLC1DimHollowCrypt01 was 0x02006AE9 (master 2
  = Dawnguard self in Dawnguard.esm), should remap to master 1 in a
  destination pack with master list [Skyrim, Dawnguard,
  SkyrimRandomizer].

Fixes for the parent-clone path in add_record_override:

1) String localization: branch on destination localization.
   - Non-localized destination: call _delocalize_strings to resolve
     the source's string ID via the source plugin's strings table
     and write the resolved text as inline cp1252 bytes. Mirrors
     the existing copy_record delocalize step.
   - Localized destination: call new _localize_strings to resolve
     the source's string ID, allocate a fresh ID via
     StringTable.allocate_id (max+1, starting at 1), and write the
     new 4-byte ID + register the text in our table. The
     destination string_tables is lazy-initialized as a fresh
     StringTableManager if not already present.

2) FormID remapping: call _remap_subrecord_formids on the parent
   clone before localization fixups, and remap the cloned parent's
   own FormID through remap_formid (the override record itself was
   already getting this treatment further down; parent clones were
   missing it).

3) Extend _FORMID_SUBRECORD_SIGS_FALLBACK with CELL FormID subrecord
   signatures (LTMP, XLCN, XEZN, XCMO, XCAS, XCCM, XCIM, XCWT, XOWN).
   Without them, CELL records hitting the fallback path (no schema
   bound) still miss their FormID subrecords.

Adds:
- Plugin._LOCALIZED_STRING_TABLE_TYPE: signature -> table-type map.
  Verified against vanilla Skyrim.esm: FULL/SHRT/ITXT live in
  .STRINGS, DESC/NNAM in .DLSTRINGS. Note that MESG ITXT lives in
  .STRINGS not .ILSTRINGS despite the extension naming.
- Plugin._localize_strings: inverse of _delocalize_strings.
- StringTable.allocate_id: simple max(keys)+1 allocator, starts at 1.

Verified end-to-end via test_door_lock_esplib.py fixture in the
randomizer repo, both flavors:
- Flavor A (non-localized destination): cloned BFB01 CELL FULL bytes
  become b'Bleak Falls Temple\x00'.
- Flavor B (localized destination): cloned BFB01 CELL FULL bytes
  become a 4-byte string ID, sidecar contains the text at that ID.

In-game (Skyrim SE): both flavors display "Bleak Falls Temple"
correctly on the BFB main entrance prompt and on the deep interior
REFR 0x37603 (BFB01 -> exterior reverse door) which was previously
unfixable under any workaround.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds Plugin.clone_for_override(source_record, source_plugin) as a
documented public alternative to caller-side hand-rolled REFR clones.
It deep-copies the source record and remaps master indices in
subrecord FormIDs (NAME, XTEL, KWDA, VMAD, etc.) from the source
plugin's master ordering to ours, but leaves the record's own FormID
in source-plugin space so add_record_override can use it to look up
the source group hierarchy.

Also updates add_record_override to remap the record's own FormID
from source-plugin space to destination-plugin space after the group
path lookup completes. This was previously a silent latent bug — when
overriding a record from a non-Skyrim master into a plugin where that
master's index differs (e.g. Dawnguard.esm at index 0 in
Dawnguard.esm itself, but at index 1 in a multi-master content pack),
the override's own FormID kept the source-side master index and ended
up pointing into the wrong slot of the destination's master list.
Skyrim.esm-only callers were never affected because index 0 maps to
index 0 in any master list that includes it.

Both changes are backward compatible: clone_for_override is a new
method, and the FormID remap inside add_record_override is a no-op
when source and destination master orderings already align (which is
the only state existing callers exercised).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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