Summary
NiShape.demote_skin_instance() correctly flips the block type from BSDismemberSkinInstance to NiSkinInstance in the live-loaded NIF (verifiable via nifly.getBlockname() immediately after the call), but the change does not survive NifFile.save() — the output file still contains BSDismemberSkinInstance. A save-reopen-demote-save sequence works around it, which suggests saveSkinnedNif regenerates the skin instance from state cached on the separate _skin_handle rather than reading the current block table.
Hit this building a FaceGen engine: Skyrim's NIF-vs-NPC consistency check rejects NIFs that use BSDismemberSkinInstance for shapes where CK emits plain NiSkinInstance (mouth, eyes, brows, scar marks on vanilla heads), falling back to the race default head. The demote call is the documented path for matching CK's output, so needing a double-save workaround is surprising.
Reproduction
Using PyNifly 25.14.0, on any skinned BSDynamicTriShape:
nif = NifFile()
nif.initialize("SKYRIMSE", "out.nif", root_type="BSFadeNode", root_name="test.nif")
# ... build a shape, skin it, add bones + weights in the normal way ...
shape.skin()
shape.add_bone("NPC Head [Head]")
shape.set_skin_to_bone_xform("NPC Head [Head]", xf)
shape.setShapeWeights("NPC Head [Head]", weights)
# Demote to plain NiSkinInstance
shape.demote_skin_instance()
# Verify it took — this returns "NiSkinInstance":
buf = create_string_buffer(128)
nifly.getBlockname(nif._handle, shape.properties.skinInstanceID, buf, 128)
print(buf.value) # -> b'NiSkinInstance'
nif.save()
# Reopen and check what actually landed on disk:
reopened = NifFile("out.nif")
nifly.getBlockname(reopened._handle, reopened.shapes[0].properties.skinInstanceID, buf, 128)
print(buf.value) # -> b'BSDismemberSkinInstance' (bug)
Workaround
Save once, reopen from disk, demote on the reopened NifFile, save again. The second save persists because the reopened NifFile has no stale skin-handle state from the original build flow:
nif.save()
reopened = NifFile(str(path))
for s in reopened.shapes:
if s.name in names_that_should_be_ni_skin_instance:
nifly.demoteSkinInstance(reopened._handle, s._handle)
reopened.save() # demote persists here
Suggested fix direction
Either propagate demoteSkinInstance through the skin handle (so saveSkinnedNif sees the change), or have NiShape.demote_skin_instance() invalidate/rebuild the skin handle so the next save writes from the current block table.
Environment
- PyNifly 25.14.0
- Windows, standalone Python 3.13 usage (no Blender)
NiflyDLL.dll loaded via niflydll.py; DLL exports demoteSkinInstance with argtypes=[c_void_p, c_void_p] and restype=c_int (returns 0 on the call that doesn't persist)
Related
While we're here, a few other quirks worth noting from the same project — happy to file separately if useful, but dumping them in one place for now:
NifFile.initialize(..., root_name="X") doesn't actually set the root node's name; the output has name=''. Workaround: nif.root.name = "X" after initialize().
NiShape.partition_tris returns partition indices (0, 1, ...); NiShape.set_partitions(parts, trilist) wants partition IDs (e.g. 130, 143). Round-tripping requires converting index → id before passing back.
createShapeFromData applies (u, 1-v) to UVs on write but shape.uvs reads raw, so round-tripping UVs flips them. Pre-unflip at the caller to compensate.
add_bone resets skin state each call — this is documented, but easy to miss; worth an example in the skinning docs.
Thanks for PyNifly! Without it this project wouldn't be possible.
Summary
NiShape.demote_skin_instance()correctly flips the block type fromBSDismemberSkinInstancetoNiSkinInstancein the live-loaded NIF (verifiable vianifly.getBlockname()immediately after the call), but the change does not surviveNifFile.save()— the output file still containsBSDismemberSkinInstance. A save-reopen-demote-save sequence works around it, which suggestssaveSkinnedNifregenerates the skin instance from state cached on the separate_skin_handlerather than reading the current block table.Hit this building a FaceGen engine: Skyrim's NIF-vs-NPC consistency check rejects NIFs that use
BSDismemberSkinInstancefor shapes where CK emits plainNiSkinInstance(mouth, eyes, brows, scar marks on vanilla heads), falling back to the race default head. The demote call is the documented path for matching CK's output, so needing a double-save workaround is surprising.Reproduction
Using PyNifly 25.14.0, on any skinned BSDynamicTriShape:
Workaround
Save once, reopen from disk, demote on the reopened NifFile, save again. The second save persists because the reopened NifFile has no stale skin-handle state from the original build flow:
Suggested fix direction
Either propagate
demoteSkinInstancethrough the skin handle (sosaveSkinnedNifsees the change), or haveNiShape.demote_skin_instance()invalidate/rebuild the skin handle so the next save writes from the current block table.Environment
NiflyDLL.dllloaded vianiflydll.py; DLL exportsdemoteSkinInstancewithargtypes=[c_void_p, c_void_p]andrestype=c_int(returns 0 on the call that doesn't persist)Related
While we're here, a few other quirks worth noting from the same project — happy to file separately if useful, but dumping them in one place for now:
NifFile.initialize(..., root_name="X")doesn't actually set the root node's name; the output hasname=''. Workaround:nif.root.name = "X"afterinitialize().NiShape.partition_trisreturns partition indices (0, 1, ...);NiShape.set_partitions(parts, trilist)wants partition IDs (e.g. 130, 143). Round-tripping requires converting index → id before passing back.createShapeFromDataapplies(u, 1-v)to UVs on write butshape.uvsreads raw, so round-tripping UVs flips them. Pre-unflip at the caller to compensate.add_boneresets skin state each call — this is documented, but easy to miss; worth an example in the skinning docs.Thanks for PyNifly! Without it this project wouldn't be possible.