This page covers the build route for a DS2 (Scholar of the First Sin) humanoid as a Nightreign chr. One Kotlin script does the whole port. A small Python script per port wraps it. The C# tools and the DS2 Python steps come from eclipse-build.
Checked against code: the wrapper scripts, npmirror.py, anim_stage.py, the tool entry points, and stages 1 to 5 and 7 to 10 of
the Kotlin script. Not checked: the per-port data file formats (port.json, slots.json, animmap.csv, tae_extra.json),
the _ds2audit kit, and anything in game.
EB means py -3.12 -I -B S:/eclipse-build/eb.py. Shared tools only: see ../build/use-shared-tools.md.
What you need
- DS2 SotFS and Nightreign installed. The script's defaults are
S:/SteamLibrary/steamapps/common/Dark Souls II Scholar of the First Sin/GameandS:/SteamLibrary/steamapps/common/ELDEN RING NIGHTREIGN/Game(--ds2-game,--nr-game). - The pinned Python at
S:/toolchains/python-ds2/Scripts/python.exe. If it is missing, build it:py -3.12 -I -B S:/eclipse-build/toolchain/python.py ds2. The Kotlin script uses it when it exists, elsepythonfrom PATH. - The installed script host:
S:/souls-script-kt/script-host/build/install/script-host/bin/script-host.bat. - A port folder under
S:/souls-script-kt/scripts/tools/npc-ports/ds2-<name>/withdata/<slug>_port.json, the otherdata/<slug>_*files andai/<goal>_battle.lua. Copy an existing port (for exampleds2-heide-knight) as the pattern. - About 4 GB of memory for one port build.
Steps
-
Find out how DS2 really plays each clip before you fill Nightreign animation slots. See Read DS2's clip usage below.
-
Write the port's data files and its
build.py. The existingbuild.pyfiles all have the same shape (next section). -
Run the build twice, into two work folders, as an eb job. Example with the Heide Knight wrapper:
EB run --mem 4 -- py -3.12 -I -B S:/souls-script-kt/scripts/tools/npc-ports/ds2-heide-knight/build.py sword --work <work>/run-A --stage <stage>/run-A EB run --mem 4 -- py -3.12 -I -B S:/souls-script-kt/scripts/tools/npc-ports/ds2-heide-knight/build.py sword --work <work>/run-B --stage <stage>/run-B--stage DIRcopies the shipped files toDIR/modand writesDIR/<chr>.sha256.--skip-ktsreuses the Kotlin script's output and only redoes the later steps.ds2build.pyalso has--no-anim-ops. -
Read
output/npc-port-<slug>/reports/port-lint-final.md. Fix every FAIL. -
Make the stage with
EB stage make(see ../build/start-a-stage.md).
What a build.py does
Read from ds2-belfry-gargoyle/ds2build.py and ds2-heide-knight/build.py:
-
Sets a private
APPDATAper build. Soulstruct writes a fixed%APPDATA%\__temp_spline__.hkx, so two builds sharing it break each other. -
Calls
npmirror.tools(work, NP, run)fromS:/eclipse-build/tools/ds2/npmirror.py. This makes<work>/np, a mirror ofnpc-portsbuilt from junctions, in which each tool's bin folder is the eclipse-build build of that tool. -
Waits for free commit memory (6 GB in
ds2build.py, 8 GB in the Heide Knight script), then runs the Kotlin script:script-host.bat scripts/mods/build-npc-port-pursuer.nr.kts --port-dir=<port dir> --slug=<slug> --work=<dir> --keep-work=1 --npc-ports=<work>/np -
Applies TAE fix TSVs to the built anibnd with
ds3-arena/patches/apply_tae_checked.py. -
Runs
pursuer/py/pose_cancels.py(attack cancels by pose) and applies its TSV the same way. -
ds2build.pyonly: normalises Kraken output withdcxnorm.pyand appliesai-port/hidden_guard.pyto the battle luabnd. -
Runs
port-lint.exeon the package and writesreports/port-lint-final.mdand.tsv.
What the Kotlin script does
scripts/mods/build-npc-port-pursuer.nr.kts, one stage after another. A failing stage stops the build.
| Stage | Tools | What it makes |
|---|---|---|
| extract | Ds2Unpack, NrUnpackCli, Ds2Port bnd-x |
the DS2 model, rig, TAE, sound files and the NR template files |
| model | Ds2HumanPort flver, Ds2HumanPort tex, material-fix/apply_tex.py |
the FLVER and the _h / _l texbnds |
| anim | Ds2Port flver-bind, Ds2Port nsa-dump, anim_build.py, tna1.py |
the hkx clips and parts.json |
| tae | Ds2HumanPort attacks, Ds2HumanPort tae |
the TAE and <chr>.events.tsv |
| behaviour | Ds2HumanPort anibnd, AddLayers additive, Ds2HumanPort behbnd, Ds2HumanPort ragdoll, Havok2014 relayout, Ds2HumanPort chrbnd, Ds2Port hks, Havok2014 tagcheck |
anibnd, behbnd, chrbnd, hks |
| ai | AiPort localize, then aicheck and luabnd-pack in Kotlin |
the battle luabnd |
| sfx | pursuer/fx/build_fx.mjs (Node) with FxBnd |
the chr's sfxbnd, skipped when fx_spec is null |
| sound | py/build_sounds.py, optional bank-budget/bank_budget.py |
the chr's sound bank, and boss music unless music is false |
| params | StatPort port, Ds2HumanPort params |
test-only/regulation.bin and params/rows.csv |
| port-lint | port-lint fix, port-lint |
fixes in place, then the lint report |
Script options, from its header: --ds2-game, --nr-game, --nr-unpack-cli, --vgmstream, --python, --node,
--soulstruct-lib, --ds2-defs, --work=<dir>, --keep-work=1, --no-music=1, --strict-lint=1, --no-port-fix=1,
--port-dir=<dir>, --slug=<s>, --npc-ports=<dir>.
Output goes to S:/souls-script-kt/output/npc-port-<slug>/: mod/, params/, test-only/, reports/.
The tools
All three build with EB tool build ds2humanport ds2port ds2unpack. In a port build you never call them by hand: npmirror.py places them.
Ds2HumanPort
Edit it in S:/eclipse-build/tools/dotnet/Ds2HumanPort. Usage text from Program.cs:
tae-dump <anibnd|tae> [--ids a,b] [--template TAE.Template.ERNR.xml]
flver --ds2 <c####.flv> --ref <NR template chrbnd> --out <out.flver> --src c#### --dst c#### [--dummies <json>] [--glow]
dummies <in chrbnd> <out chrbnd> <dummies json> [--only id,..]
tex --ds2 <ds2 texbnd> --out <mod dir> --src c#### --dst c#### --suffix _h|_l [--scale N] [--verify]
attacks <enc_regulation.bnd.dcx> <ds2 defs dir> <ds2 _pl.tae> <slots.json> <out dir> [--verify]
tae --config <tae.json>
behbnd --template <NR behbnd> --template-anibnd <NR anibnd with skeleton.hkx> --from c#### --chr c#### --skeleton <skeleton.hkx> --out <mod dir> [--bone T=N ...]
chrbnd --template <NR chrbnd> --from c#### --chr c#### --flver <flver> --ragdoll <hkx> --out <mod dir>
anibnd --template <NR anibnd> --from c#### --chr c#### --hkx <dir> --tae <tae> --out <mod dir>
ragdoll <ds2 ragdoll.hkx> <donor NR chrbnd> <skeleton.hkx|bind.json> <out.hkx> [--verify] [--name c####]
param-dump <regulation.bin> <Param> <id|lo-hi>...
matscan <dir of chrbnds> <matbin name>
bndcheck <binder>...
The code also has params and tex-dump, which the usage text leaves out. The Kotlin script calls tae with separate options
(--ds2-pl, --ds2-snd, --ds2-sfx, --template, --parts, --attacks, --slots, --fx, --chr, --out, --music-stop,
--stop-music-on, --tpl-alias, --bullet-dmy, --extra, --patch, --sound-map, --blend-nr, --turn-fill), not
--config. The header of TaeBuild.cs explains what each does.
Ds2Port
The commands the route uses: bnd-x <bnd[.dcx]> <outDir>, flver-bind <flver> <out.json>,
nsa-dump <ds2 anibnd> <outDir> (set NSA_MAPOFF=1 for c6790), hks <tpl.hks> <tplChr> <chr> <modDir>.
The full list is in reference.md.
Ds2Unpack
Ds2Unpack --game <DS2 SotFS Game dir> --out <dir> [--include <prefix>]... [--list]
--dictionary <file> names the UXM Scholar dictionary. Its default is a path in a Downloads folder. The tool writes
Assets/UXM Dictionaries/ into its own folder on every run, so npmirror.py gives each build a private copy of it. AddLayers
gets a private copy for the same reason.
The DS2 Python steps
tools/ds2/py/pursuer/ holds anim_build.py, tna1.py, pose_cancels.py, build_sounds.py, build_throw_receive.py,
ds2_throw_receive.py, make_tae_plan.py, nrclip.py, fev44.py. tools/ds2/py/aava/ holds hk_build.py, build_sounds.py,
audio_prep.py, fev44.py. npmirror.py maps them over npc-ports/pursuer/py and npc-ports/aava/py in the mirror.
Scripts shared with the DS1 and DS3 builds (apply_tae_checked.py, crit_fix.py, hidden_guard.py, the bank builders) still
live in souls-script-kt.
Environment switches
| Variable | Read by | Effect |
|---|---|---|
EB_DHP_LIVE=1 |
npmirror.py |
Ds2HumanPort only is a private build of the souls-script-kt source npc-ports/pursuer/Ds2HumanPort, into <work>/np-dhp-bin. For trying an edit there. |
EB_TOOLS_LIVE=1 |
npmirror.py |
No swaps at all. Every tool is the live souls-script-kt bin, and Ds2HumanPort is the private live build. |
EB_NO_CACHE (any value) |
the Kotlin script | The anim stage runs anim_build.py then tna1.py directly, without the cache. |
EB_FIX_CHAIN=<file> |
Ds2HumanPort tae |
The fix-chain file to read. Fails if the file is missing. See the traps. |
ECLIPSE_NO_ANIM_OPS=1 |
set by ds2build.py --no-anim-ops |
Skips the chr's anim-ops.tsv lines. |
The anim stage cache
With S:/eclipse-build/eb.py and tools/ds2/anim_stage.py present and EB_NO_CACHE unset, the Kotlin script runs the anim
stage as one cached step:
py -3.12 -I -B S:/eclipse-build/eb.py cache run --mem 3 --py <python> --in <inputs...> --out <hkx dir> <parts.json> -- py -3.12 -I -B anim_stage.py --python <exe> --soulstruct-lib <io_soulstruct_lib> --anim-build <anim_build.py> --tna1 <tna1.py> --out-dir <hkx dir> --tna1-args "<args after --dir>" -- <anim_build args except --out>
The inputs it names are the NSA dump, bind.json, the compendium folder, animmap.csv, the three Python scripts,
hk_build.py, the TNA1 converter folders, the Ds2Port bin folder and the two Soulstruct source folders. A hit restores the hkx
files and parts.json. patches/README.md records the measurement: 113 to 176 s uncached, 1 to 2 s on a hit, on the Rotten.
Known traps
- Death fade twice on chain characters (state unclear).
Ds2HumanPort taeadds a default death fade unless the chr is listed inds3-arena/patches/fix-chain.tsv. It finds that file at<exe folder>/../../../ds3-arena/patches/fix-chain.tsv, or atEB_FIX_CHAIN. The 2026-11-07 memory note says a junctioned bin folder made .NET resolve the exe folder outside the mirror, so the file was not found and chain chrs (c9470, c9530, the Pursuer, DS1 and DS3 chain ports) got the fade twice. The fix then was to make the bin a real folder. Todaynpmirror.pymakes that bin a junction to the eclipse-build folder again, and no build script I read setsEB_FIX_CHAIN. I could not prove from code which way .NET resolves it. Before staging a chain chr, setEB_FIX_CHAINto the real file and diff the result against the shipped anibnd. - Dummies 960. The death dissolve spawns on dummy set 960.
port-lint fixadds the set and the dissolve (PortLint/DeathDissolve.cs). The memory note says shipped 0.0.82 Last Giant and Flexile Sentry FLVERs lacked them. Not rechecked here. - Two anim builds at once. Keep the private
APPDATAstep in any newbuild.py. - Tools that write into their own folder. Never point a build at the shared eclipse-build folder of
Ds2UnpackorAddLayers. Go throughnpmirror.py. - DS2 clips at about 20 fps.
anim.fps_outinport.jsonresamples them. Without it they play 1.5 times too fast (script header). - CritFix changes the current directory to a hard-coded souls-script-kt path (
kneel-retarget\bin) to find Oodle.
Read DS2's clip usage with the morpheme MCP
DS2 uses morpheme networks (.nmb), not a Havok behaviour graph. The port reads only the rig and clips from them, so it does
not know how DS2 blends a clip. Look it up first.
- The MCP server is registered as
morpheme. Source:S:/_modding/tools/DS2AnimToolset/mcp/morpheme_mcp.py. - Tools:
export_network(takes an anibnd.dcx),network_summary,list_anims,anim_usage,node,find_nodes,export_dir. - Without an MCP client:
py -3.12 -I morpheme_mcp.py --call anim_usage '{"chr":"c6790","anim":"DmgBossHit"}'. - The decompiler behind it:
morphemeNetCli.exe <cXXXX.nmb> <out dir> [--fps 30] [--resample]. The.nsaclips must sit beside the.nmb. - Cache:
S:\_modding\tools\DS2AnimToolset\mcp-cache\<chr>\(override withMORPHEME_MCP_CACHE).
Example from the MCP's README: Aava's DmgBossHit is only used in additive layers on top of the main motion. Ported as a
full-body clip it reads as a freeze.
The toolset clone has no license. Keep it local.
Dated note (memory, 2026-11-07, not rechecked): the audit kit is S:/souls-script-kt/scripts/tools/npc-ports/_ds2audit
(anidiff.py, taediff.py and others). Use anidiff.py and taediff.py to compare a rebuild with the shipped files.