OBR Mod Creation Wiki
A complete guide from first texture swap to full custom armor with cloth physics, scripted spells, and new rooms, every step in order, every tool linked, honest about what works in mid-2026.
The One Thing You Need to Know
Oblivion Remastered is two game engines stacked together. Once you understand this, everything else clicks.
Unreal Engine 5.3.2, visual
Models · textures · lighting · materials · animations · UI · cloth physics · audio (Wwise)
Gamebryo 2006, logic
Stats · quests · scripts · AI · die rolls · item records · cells · saves
What This Covers
- Retextured & reskinned items
- Brand-new weapons & armor (full 3D pipeline)
- Capes & cloth physics workarounds
- Custom animations & retargeting from any source
- Scripted abilities via OBScript + Lua bridge
- New interior rooms & player homes
- Custom creatures via blueprint duplication
- Facial animation with MetaHuman / DNA files
- Wwise audio integration & External Sources
- UE4SS Lua scripting, hooks, snippets, recipes
- Blueprint behavior mods (custom UI, spawning)
- Debugging, crash logs & rapid testing workflow
- Mod distribution, compatibility patching & save safety
- NPCs, custom races, hair modding
- Dialogue, voice lines, lip sync workarounds
Known Limits (mid-2026)
- UE-side terrain editing (no clean workflow yet)
- New exterior worldspace cells (unsolved)
- Custom Chaos Cloth Assets (crash on load)
- Brand-new standalone sounds (experimental only)
- NPC pathfinding in new cells still needs a NavMeshBoundsVolume + RecastNavMesh in the base level (see Cells & Interior Maps)
- Multiple enchantments (backend blocks it)
- Fully custom voiced dialogue indoors: interior Wwise bank residency is still unsolved (the pipeline is otherwise mapped: see Dialogue, Voice & Lip Sync)
- Game Pass / Epic stores (no OBSE64)
- Classic .nif meshes (dead format for rendering)
- Arbitrary body slot expansion (Altar enums fixed)
Your Learning Path
These steps are designed to be followed in order, each builds on the last.
How Modding Works
The dual-engine model. Ten minutes that make everything else make sense.
Set Up Your Tools
Every tool, in order, with links. FModel, xEdit, UE4SS, UE 5.3.2, Blender, all of it.
Make Mods: Basics
Retexture, swap sounds, edit stats, add new gear with existing models. Easiest first.
Make Mods: Advanced
Custom 3D armor, cloth physics, animations, MetaHuman faces, Wwise audio, new rooms.
Scripting & Custom Code
OBScript, OBSE64, UE4SS Lua, the deepest layer, where "impossible" mods live.
Reference, Always Here
CTD fixes, what-works status board, full glossary, console commands.
All Links & Resources
Every tool, every Nexus mod, every guide, every link in one place.
Step 1 of 5
How Modding This Game Works
Before installing a single tool, understand the one idea that reframes every decision you will ever make about Oblivion Remastered modding.
The Dual-Engine Architecture
When you look at Oblivion Remastered you see Unreal Engine 5 graphics. But underneath, the original 2006 Gamebryo engine is still running the actual game, quests, stats, scripts, AI, die rolls. UE5 handles rendering only.
The original Oblivion.esm is essentially byte-identical to the classic game. The remaster’s changes are layered on top through the Altar ESPs rather than rewriting the master file.
The Face, Unreal Engine 5.3.2 visual
Models · textures · materials · lighting · menus · animations · cloth physics · audio (Wwise)
The Brain, Gamebryo 2006 logic
Logic · data · rules · OBScript · AI packages · inventory · quests · cells · saves
Two Kinds of Mod Files
brain ESP / ESM, Logic
Classic Bethesda plugin files. Item names and stats, creature health, quest steps, AI packages, script behavior. Same format as the 2006 game.
Location: ...\\Content\\Dev\\ObvData\\Data\\
visual PAK, Visuals
Unreal Engine content packages (.utoc + .ucas + .pak) containing all 3D models, textures, materials, and animations. All three files must be present together.
Location: ...\\Content\\Paks\\~mods\\
.nif files and textures were BSA-packed. None of that applies to the visuals here. UE5 reads all models and textures from PAK files. The old NIF format is dead for rendering. Loose BSA assets are simply ignored. This invalidates a huge amount of 15-year-old modding knowledge, exactly why this game-specific codex exists.The Altar ESPs (never disable)
The remaster ships three special plugins that must stay enabled and correctly ordered at all times:
AltarESPMain.esp, applies the remaster’s record changes over the vanilla originals. Crucially, it rewrites all item names to UE5-managed localization keys (LOC_FN_IronSword, etc.). These keys are resolved at runtime from the UE localization system in the PAK files.AltarDeluxe.esp, Deluxe Edition content (present in all editions).AltarESPLocal.esp, referenced in Plugins.txt; may not be physically present on disk.
AltarESPMain rewrites every vanilla item name to a localization key, if your mod edits a vanilla record without forwarding that change, the display name reverts to the raw Gamebryo string and shows up tagged [nl]Something in-game. The NL-Tag Remover mod (mods/473) fixes this artifact on your new items.The FormID Bridge, How the Engines Talk
Every item, NPC, and cell with a visual representation has a corresponding UE5 asset (Blueprint / form file) in the PAK files. This is how they connect:
- Gamebryo processes game logic (equipping a sword, placing an NPC) using internal FormIDs.
- Altar receives the FormID, converts it from hex to decimal integer, and looks it up in a pre-loaded map called the SyncMap.
- The SyncMap maps FormID → UE asset path.
- UE loads and renders the corresponding Blueprint/mesh/material.
0x0003AA82. UE form files use the decimal equivalent: 240258. The first two hex digits (e.g. 03) are the load order index, always zero these out when referencing from the UE side. Formula: int(hexFormID, 16) with the first byte zeroed.| Adding a new sword requires | Engine | File type |
|---|---|---|
| The sword’s name, damage, weight, value, FormID | brain | ESP record |
| The actual 3D model and texture the player sees | visual | PAK asset |
| Wiring the ESP record to the PAK model | bridge | SyncMap .ini |
.ini. A genuinely new model still requires the full UE5 asset pipeline.The PAK Container Format
Oblivion Remastered uses UE5’s IO Store format, not traditional loose PAK files. This means every visual mod is three files that must all be present together:
.pak, loose files (config, fonts), packed with standard UnrealPak.ucas, Content Addressable Store, contains all cooked.uassetdata.utoc, Table of Contents, the index for the.ucasfile
The global.ucas / global.utoc pair provides dependency resolution for the whole game.
No Official Toolkit
- Bethesda does not officially support mods for Oblivion Remastered and there is no Construction Kit built for the remaster.
- The entire toolchain is community-built and partly reverse-engineered since April 2025. Capabilities change month to month.
- The Xbox Game Pass version shipped with extra debug data which significantly accelerated community reverse engineering.
- There is an estimated ~30% chance an official CK will be released, no announcement as of mid-2026.
- The Altar plugin code is proprietary (licensed third-party components from Virtuous) and is extremely unlikely to ever be publicly released.
Step 2 of 5
Set Up Your Tools
From zero to a fully working modding workbench. Follow these steps in order, each is the foundation for the next.
Confirm Your Install & Back Up
- Steam build only. OBSE64 supports the Steam version only, not Game Pass, Microsoft Store, or Epic. If you own a non-Steam version, most of this codex still applies but scripting tools will not.
- Note your exact game version (build number visible in-game), several tools are matched to specific builds and will break on updates.
- Back up your save files and make a clean copy of
Plugins.txtbefore installing anything.
Mod Manager - pick one
- pick oneMod Organizer 2, profiles + virtual file system. Recommended for creators, keeps your game folder clean. Install the MO2 OR plugin (mods/366) for automatic argument passing.
- altVortex, Nexus’s official manager, beginner-friendly. nexusmods.com/site/mods/1
- altNORMM, dedicated Oblivion Remastered manager built specifically for this game.
Loaders & Bridge bridge
The foundation everything else runs on. Install in this exact order.
- requiredOBSE64, script extender / DLL plugin loader. Drop
.dll+.exeinBinaries\Win64. Currently only a plugin loader, not a full script extender yet. Steam only. - requiredAddress Library for OBSE Plugins (mods/4475), makes OBSE DLL plugins version-independent. Required by most plugins.
- requiredUE4SS (mods/32, OR build v3.0.1), Blueprint mod loader and Lua scripting runtime for UE5. Required by TesSyncMapInjector. Install to
Binaries\Win64\. Use the Nexus OR build, not the GitHub experimental build, the generic build doesn’t hook into UE 5.3 properly. - requiredTesSyncMapInjector (mods/1272), the bridge that links new ESP items to UE5 meshes at runtime. Runtime UE4SS Lua mod.
- when neededMagicLoader + MagicPatcher (mods/1966), enables new interior cells. Install when you reach Lesson 14.
- handyOBRConsole (mods/2205), lets UE4SS Lua mods run Oblivion console commands. ⚠ Crashes if you tab out of the game while active.
- altSML / Simple BP Mod Loader (mods/1172), alternative Blueprint loader with better input action recognition and documentation.
UE4SS Configuration visual
After installing UE4SS to Binaries\Win64\, configure it for development work:
UE4SS-settings.ini, enable the GUI console
[Debug] ConsoleEnabled = 1 GuiConsoleEnabled = 1 GuiConsoleVisible = 1 GraphicsAPI = d3d11
With this enabled, UE4SS opens a GUI window alongside the game showing logs, a live object viewer, and function hooks. Press Ctrl+Numpad 6 in the GUI console to dump an up-to-date .usmap mappings file.
.usmap file after any game patch, asset structures change between updates. The Nexus mappings file may be outdated. UE4SS generates your own from the live game.Hot Reloading Lua Mods
Enable hot reloading in ue4ss-settings.ini and press Ctrl+R to reload Lua mods without restarting the game. Note: mods that hook into game-spawned actors may not hot-reload cleanly.
ESP / Data Tools brain
- requiredxEdit 4.1.5n+ (TES4R / OR build), primary ESP authoring and conflict detection. Get the OR-compatible build from the official xEdit Discord
#xedit-buildschannel, not the standard release. Launch with:TES4R64.exe -D:"...\ObvData\Data" -I:"...\ObvData\Oblivion.ini" - requiredNL-Tag Remover (mods/473), fixes the
[nl]artifact on new item names caused by Altar localization. - requiredLOOT, automatic load-order sorting. Sort with LOOT, then hand-fix the special cases below.
- handyRuntime EditorIDs (mods/1331), surfaces EditorIDs in the in-game console for quick testing.
- situationalConstruction Set (2006 original) + CSE, visual editor for cells and containers. Setup details below. The Altar ESPs are not safe to load in CS by default, CSE fails to recognize and load their dependencies correctly and will error out. However you can load them: add all the dependency ESPs as masters one by one in xEdit first, then load in CS. Not recommended for most use cases.
Construction Set Extender (CSE) Full Setup
- Download: OBSE for original Oblivion · CSE (mods/36370) · Official Construction Set · vcredist_x86.exe
- Extract all into your
ObvDatadirectory. - Run
TES4_Construction_Set_1.2.404.exeand install to yourObvDatafolder. Ignore the “Oblivion not installed” error. - Edit
Launch CSE.batand replace its contents with:obse_loader.exe -editor -notimeout - Right-click
Launch CSE.bat→ Run as Administrator.
- Your saved
.espappears inObvData\Data\Data, move it up one level toObvData\Dataand add it toplugins.txt. - For protected directories: create a file named
BGSEE_DirectoryCheckOverride(no extension) in ObvData. - For MO2 without elevation: create
BGSEE_ElevatedPrivilegesCheckOverride(no extension) in ObvData. - Missing DLL error: install
vcredist_x86.exe, the x64 version alone is insufficient. - Use the CSE beta from the Nexus Mirrors tab for fixes to script editor crashes and dialog size issues.
UE5 Asset Pipeline visual
Large downloads, install now so the workbench is complete before you need them.
- requiredBlender + PSK/PSA plugin, model and animation authoring. If the current PSK plugin breaks on your imports, try the older Befzz plugin.
- requiredUnreal Engine 5.3.2, cook art assets. Use 5.3.2 specifically, not the latest version. Install via the Epic Games Launcher to an unprotected path like
C:/, not Program Files or Desktop. - requiredFModel + mappings file (.usmap, mods/47), browse and extract game assets. Set Archive Directory to game root (not a subfolder), UE Version to
GAME_UE5_3. - requiredOR Mod Tools bundle (mods/3918), retoc + UAssetGUI + oo2core in one package. Use the Nexus version of UAssetGUI, not the GitHub release (it has a critical PackageName bug fix).
- materialsJsonAsAsset (C0bra5+Tectors fork recommended) + j0.dev, import vanilla materials from FModel JSON exports into your UE project.
- armor/formsC.A.F.E. (mods/4891), CosmicBoogaloo’s tool for setting asset paths in armor and weapon forms quickly.
- texturesNNRM Merge/Split Tool (mods/3051), split and recombine the NNRM channel-packed textures that OBR uses.
- blueprintsOR SDK (Kein/Altar) + Visual Studio 2022, the Altar stub project for custom blueprints and cloth physics. No full Unreal Engine source build required.
- nanitesimple-nanite-parser (c0bra5), extract full-detail Nanite meshes that FModel only exports the fallback version of.
- audiosound2wem, convert audio files to Wwise
.wemformat for sound replacement. - audiowwiser (github.com/bnnm/wwiser), browse Wwise
.bnksoundbank files to locate specific.wemaudio file IDs.
Build the Altar SDK Project (for Blueprints & Cloth Physics)
Required only if you’re doing custom blueprints, cloth physics, or C++ plugins. Skip if you’re only doing texture/sound/ESP work. Alternatively, use a precompiled build (see Community Starter Packs below) and skip steps 1-4 entirely.
- Download and extract github.com/Kein/Altar.
- Install Visual Studio 2022 with the workload: Game Development with C++ + individual component:
MSVC v143 - VS 2022 C++ x64/x86 build tools (v14.38-17.8). - Right-click
OblivionRemastered.uproject→ Generate Visual Studio project files. - Open
OblivionRemastered.slnin VS2022 → right-click the project in Solution Explorer → Build. Wait for it to finish, then close VS. - Open the
.uprojectwith UE 5.3.2. - On first launch: if it warns about a missing water collision channel, click “Add to Engine.ini” and continue.
C:\Users\YOU\AppData\Roaming\Unreal Engine\UnrealBuildTool\BuildConfiguration.xml:
<Configuration xmlns="https://www.unrealengine.com/BuildConfiguration">
<WindowsPlatform>
<CompilerVersion>14.38.33130</CompilerVersion>
<ToolchainVersion>14.38.33130</ToolchainVersion>
</WindowsPlatform>
</Configuration>Source/OblivionRemastered/ in Windows Explorer and physically delete (not just exclude in VS) the 6 problem files listed in this Discord post: fix thread. VS “Remove from project” is not enough, delete from disk. Credit: agentlefox, dicene.// Fix throughout the project to find them all). These affect fundamental classes like VPairedPawn and VWeapon, preventing certain Details panel fields from working. .yeah.nah.yeah.’s precompiled build has fixed additional stubs beyond the base Kein project. If you need full functionality for fields like the Form entry in blueprints, use the precompiled build or build from source.Enable Chunking in UE (Required for Packaging)
Edit → Project Settings → Packaging: enable Generate Chunks and Use IO Store. Disable Share Material Shader Code.Edit → Editor Preferences: search “chunk” → enable Allow ChunkID Assignments.
Creating a Chunk Assignment
- In the Content Browser, create a Data Asset → Primary Asset Label.
- Set: Priority = 1 · Chunk ID = any number 1-300 · uncheck Apply Recursively · Cook Rule = Always Cook · check Label Assets in My Directory.
- Name it
ChunkYOURNUMBERfor easy reference.
First Launch & Verify
- Launch the game once WITHOUT OBSE so UE4SS can configure itself properly.
- Then launch through OBSE.
- Verify OBSE is working (in-game version check command:
GetObseVersionin console). - Verify UE4SS is working (its GUI console window should appear).
- Check load order in LOOT, Altar/base ESPs should be present and correctly ordered.
Plugins.txt backup is still safe before doing anything else.Community Starter Packs
The community has assembled pre-packaged tool bundles to get you started faster:
Migrate (right-click → Asset Actions) to move assets from another 5.3 project into it. Note: “Tripster” and “.yeah.nah.yeah.” are the same person, older listings of “Tripster’s Altar Project” point to this exact same build.Step 3 of 5
Make Mods: The Basics
Your first real mods, easiest first. Retexture, swap sounds, edit stats, add new gear without any 3D work required.
Retexture or Recolor Something visual
Changing how an existing item, surface, or character looks by editing its texture. This is where everyone should start.
- Open FModel. Set Archive Directory to your game root (the folder containing
OblivionRemastered.exe), UE Version toGAME_UE5_3, and load your.usmapmappings file. - Browse the Folders tab to find your texture. Right-click → Save Texture. Always use TGA format, not PNG, TGA preserves all channels including alpha, which PNG discards.
- Edit the image in Photoshop, GIMP, or Substance. The game uses DirectX normal map format, if you’re working with normal maps, the Y channel is not inverted (unlike OpenGL).
- Import into your UE project at the exact same folder path as the original. Check FModel to match the original’s Texture Group and Compression Settings.
- Assign to a chunk. Package. Rename output files with
_Psuffix. Drop all three files (.pak/.ucas/.utoc) in~mods.
OBR packs multiple maps into one image in the NNRM format (Normal/Normal/Roughness/Metallic):
| Channel | Contains | Notes |
|---|---|---|
| R | Normal X | DirectX format |
| G | Normal Y | DirectX format (NOT inverted like OpenGL) |
| B | Roughness | Black = shiny, White = matte |
| A (alpha) | Metallic | Black = non-metal, White = full metal |
Variants: NNRS (specular instead of metallic) · NNRE (emissive) · NNR (no metallic) · NNRAO (ambient occlusion).
Import settings in UE: use BC7 compression and untick sRGB. Do not use the Normal compression preset for NNRMs, it breaks them.
In Blender: when working with OBR assets, invert the Green channel of the NNRM (UE uses DirectX -Y, Blender uses OpenGL Y). Set color space to Non-Color.
Use the free NNRM Merge/Split Tool (mods/3051) to split channels and recombine them.
ffmpeg command, preserve RGB, set alpha=255
ffmpeg -i input.png -filter_complex "[0]geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255'" out.png
_D) textures in OBR pack extra data in their alpha channel. What that data encodes depends on the material. Common examples: gem/crystal materials use the alpha to define where gem-like inner reflections appear (IOR-like effect, higher alpha = more gem-like). Fabric/cloth materials may use alpha for roughness variation. When a texture looks nearly transparent in Photoshop or Blender but renders correctly in-game, this is usually why. To investigate: split the texture into RGBA channels using the ffmpeg scripts or Substance Designer and look at the alpha channel independently. When replacing or modifying diffuse textures, preserve the original alpha unless you know what it controls. Credit: c0bra5.Making an NNRM in GIMP
- Open Normal, Roughness, and Metallic maps in GIMP. Add an alpha channel to the Normal map (Layers → Transparency → Add Alpha Channel).
- On the Normal map: Colors → Components → Decompose. Set Color Model to RGBA, click OK. A new tab opens with the channels as separate layers.
- Set Roughness and Metallic maps to Grayscale mode (Image → Mode → Grayscale).
- Create a new blank image the same size as your maps, using Fill: Transparency (not white or any color).
- From the decomposed Normal layers, select the Red layer, copy (Ctrl+C), paste into the blank image (Ctrl+V), then flatten into a new layer and rename it “red”.
- Repeat for the Green layer, place it below red. Then paste your Roughness as “blue” and Metallic as “alpha”. Do not merge these, keep them as separate layers.
- Go to Colors → Components → Compose (NOT Re-compose). Set Color Model to RGBA: R = red layer, G = green layer, B = roughness, A = metallic. Click OK.
- Export as .tga 32-bit. PNG will drop the alpha channel.
Making an NNRM in Photoshop
- Open your Normal, Roughness, and Metallic maps.
- In the Channels panel of a new document: paste Roughness → Blue · Metallic → Alpha · Normal R → Red · Normal G → Green.
- Save as .TGA 32-bit, PNG will discard the alpha channel.
NNRM ffmpeg Split Scripts (c0bra5)
Split an NNRM into component images
# Normal XY channels (produces a blueish image - that's correct) ffmpeg -hide_banner -v error -i "%1" -filter_complex "[0]geq=r='r(X,Y)':g='g(X,Y)':b='255':a='255'" "%~n1_n.png" # Roughness (from B channel) ffmpeg -hide_banner -v error -i "%1" -filter_complex "[0]geq=r='b(X,Y)':g='b(X,Y)':b='b(X,Y)':a='255'" "%~n1_r.png" # Metallic (from Alpha channel) ffmpeg -hide_banner -v error -i "%1" -filter_complex "[0]geq=r='255':g='255':b='255':a='p(X,Y)'" "%~n1_m.png"
For a model with no normal map detail, create a flat NNRM: set R=128, G=128 (neutral normal), fill B with your roughness value, fill Alpha with your metallic value.
Skin materials use SubsurfaceProfile (SP_SkinBase). Diffuse/BaseColor format: RGB = color, A = emissive mask. Hair textures use the _RAUD suffix multi-channel format, always export TGA from FModel, the alpha channel contains the opacity/strand mask.
Swap or Replace a Sound visual
The game uses Audiokinetic Wwise for all audio. Sounds are stored as .wem files (Wwise Encoded Media) inside .bnk soundbank containers.
- Use FModel + retoc to unpack the
.wemaudio file you want to replace. - Convert your replacement audio to
.wemformat using sound2wem. Alternatively, use older Wwise installer tools; see the RE Modding forum guide. - Swap in your audio keeping the exact same filename. Repack with retoc into a
_Ppak. Drop in~mods.
Finding a Specific Voice Line
- In FModel, browse to
Localization/String Tables. Find the line you want and copy its event name number (looks like00047660). - Browse to
WWise/Event/English. Find the.bnkbank file matching the NPC race and dialogue category. - Open that bank in wwiser and navigate the tree to find your number in a
CAkSoundnode. That node’s value is the SOURCE ID, the number matching the corresponding.wemfile. - Extract that
.wem, replace with your converted audio, repack.
PlaySound commands only work for a subset of sounds explicitly wired through Altar.Dialogue Timing Without Voice
Placing .mp3 files in the legacy Sound/Voice/ folder matching the vanilla dialogue filename convention controls subtitle display duration, even though no audio plays. The mp3 file duration equals the subtitle display time, useful for localization subtitle syncing.
Unpack & Repack Game Files visual
The game’s files are sealed in the IO Store container format. Pull one out, change it, seal it back. This underlies almost every visual mod.
Extracting with retoc
retoc.exe to-legacy --filter "OblivionRemastered/Content/YourPath/YourFile" ^ "[GameDir]\Content\Paks" "C:\Output"
Use forward slashes in the filter path. To extract multiple files at once, add multiple --filter arguments.
.uasset/.uexp blueprint files (forms, BDPs), use retoc to extract, not FModel, FModel doesn’t export .uexp files from IO Store archives, giving you only half the file.Editing with UAssetGUI
UAssetGUI needs both the .uasset and .uexp files in the same folder. Set the version dropdown to 5.3. Load your .usmap mappings file via Utils → Import Mappings. If you get “Failed to Parse X Exports”, extract the referenced dependency files alongside the main asset in the same directory structure.
Repacking with retoc
retoc.exe to-zen --version=UE5_3 "OblivionRemastered" "MyMod_P.utoc"
Input directory must be the OblivionRemastered folder (not deeper). This produces all three files (.pak, .ucas, .utoc) at once.
.ubulk files. If retoc doesn’t pack these correctly, textures appear blurry in-game. Fix: set “Never Stream” on the texture in UE Editor (the old ini-based setting is from UE4 and doesn’t work in UE5). Update retoc to a recent version, GitHub issue #22 was resolved. Check: if the .ubulk is larger than .uexp → fix not applied. If .uexp is larger → fix worked.You can combine art assets and form assets in the same retoc folder before packing to produce one single pak for your entire mod.
Change an Item’s Stats or Enchantment brain
Making a sword hit harder, armor weigh less, or adding enchantments. Uses xEdit to edit game data records on the Gamebryo side.
Launch xEdit with the correct flags
TES4R64.exe -D:"[GameDir]\Content\Dev\ObvData\Data" -I:"[GameDir]\Content\Dev\ObvData\Oblivion.ini"
- Let xEdit fully load
Oblivion.esmand the DLC files. - Find your item inside
AltarESPMain.esp. Right-click it → “Copy as new record into…” a new mod file. Never edit the originals directly. - Give it a fresh EditorID so it can’t conflict with anything, then edit the numbers in the DATA section, damage, weight, value, enchantments.
- Add your new
.esptoPlugins.txtand test.
[nl]Something in-game. Use the NL-Tag Remover mod (mods/473) to fix the broken tag, or use xEdit’s new-string workflow for properly localized names.Finding Your FormIDs In-Game
Your mod’s load order index determines the first two hex digits of all FormIDs it creates. Formula: (line number in plugins.txt minus the comment lines) → convert to hex. Example: your mod is the 23rd line → index 21 → hex 15 → your FormIDs start with 15XXXXXX. Install the Runtime EditorIDs mod (mods/1331) to surface EditorIDs directly in the console.
Game Settings (GMSTs) via GameSettings Loader
Use the Game Settings Loader (mods/833) to override game settings from a config file without touching ESPs:
[GameSettings] fJumpHeightMin = 64 fJumpHeightMax = 128 fHealthRegenDelay = 999999
Add a New Item Using an Existing Model bridge
Creating a genuinely new item, your own named, custom-stat weapon or armor, while reusing a model the game already has. No Blender or UE required thanks to TesSyncMapInjector.
- In xEdit, create your new item record (new EditorID, set your stats). In the model/mesh field, point it at an existing in-game model path you found in FModel.
- Run the Smart Mapper xEdit script. It reads your ESP and automatically writes a
.inilink file toxEdit\SyncMap\. - Copy that
.inifile to[GameDir]\Content\Dev\ObvData\Data\SyncMap\. - Put your
.espinObvData\Dataand add it toPlugins.txt. - Critical load order rule: your plugin must appear above
AltarDeluxe.espin Plugins.txt. Below it, new items go invisible or crash on cell entry.
If you’re writing the SyncMap .ini manually:
[Meshes] ; DecimalFormID = /Game/path/to/UEasset.UEasset 002350=/Game/Forms/items/armor/BP_DaedricCuirass.BP_DaedricCuirass
Where the decimal FormID is converted from the hex FormID in xEdit (with load order bytes zeroed). Multiple mods coexist fine with separate INI files.
Add a New Container or World Object bridge
- In the Construction Set, load MagicLoader’s example file (
IntTestMod.esp) and click Set As Active File. - Duplicate an existing chest or container, give it a new EditorID, say “Yes” to creating a new object. Place it in the world, save, close.
- Open the mod in xEdit (with all Altar files loaded) and run Haphestia’s Fix and Port Script (
fixmod). This fixes the two missing parameters that the Remaster requires but the CS doesn’t add. - Run the Smart Mapper xEdit script. Copy the resulting
.initoData\SyncMap. - Run MagicLoader → “Do Magic!” once to register the new cell entries.
- Enable your esp in
Plugins.txt, aboveAltarDeluxe.esp.
CellsToMapPath DataTable to link Gamebryo cells to UE map files.Add Items to a Vendor’s Inventory brain
Vendors in Oblivion Remastered sell items through leveled lists, the same system as the original game. Leveled lists are records in the ESP that tell the engine “when this merchant restocks, pick from these items at these levels.”
Finding the Right Leveled List
- In xEdit, open
Oblivion.esm(orOblivionRemastered.esm) and expand Leveled Item records. - Merchant inventories are typically referenced by the merchant’s NPC record → Merchant Container field, which points to a chest with leveled list entries.
- Alternatively, search for the merchant by EditorID (e.g.
SheopetraTrader), open their NPC record, find Buy/Sell and Training Flags section and the Merchant Container reference. Follow that reference to find the inventory chest. - For generic class-based merchants (all blacksmiths carry the same list), find the LeveledItem records named something like
LvlWeaponBlacksmith, these are what you want to inject into.
Adding Your Item to a Leveled List
- Right-click the leveled list record you found → Copy as Override into → select your ESP as the destination. This creates a patched copy in your mod rather than editing the master file.
- Expand the override. You’ll see a list of entries with Level and Count columns plus Object references.
- Right-click an existing entry → Add to create a new slot. Set the Level (1 means it always appears), Count (how many units per restock), and Object (your new item’s FormID).
- Save your ESP. The next time that merchant restocks (rest/wait 2 days or use
pcbin console), your item should appear in their inventory.
Leveled List Flags
- Calculate from all levels ≤ PC’s level, if checked, all entries at or below player level are eligible; if unchecked, only the closest level gets picked.
- Calculate for each item in count, rolls the list separately for each unit of Count. Useful for randomized stacks.
- Use All, gives the player every item on the list instead of picking one.
ResetInventory on the merchant in the console, but this clears any items they currently have equipped.Step 4 of 5
Make Mods: Advanced
Custom 3D armor, cloth physics, animations, MetaHuman facial rigging, Wwise audio, world editing, new rooms. Requires Blender and Unreal Engine 5.3.2.
Build Custom 3D Armor from Scratch bridge
Asset Structure, What You Need to Create
Each armor piece in OBR consists of 7 components. For a mesh replacer you only need to swap the skeletal mesh. For a standalone new item you need all of them:
| # | Component | Path pattern | Required for |
|---|---|---|---|
| 1 | ESP Form (Gamebryo side) | ObvData/Data/MyMod.esp | All new items |
| 2 | Form Blueprint | /Game/Forms/items/armor/ArmName.uasset | New items |
| 3 | BDP Blueprint | /Game/Forms/items/armor/BP_BDP_ArmName.uasset | New items, the key file |
| 4 | Skeletal Mesh | /Game/Art/Equipment/armor/type/SK_Piece.uasset | Replacers + new items |
| 5 | Material Instance (MIC) | /Game/Materials/MIC_Piece.uasset | Custom textures |
| 6 | GND mesh (ground model) | /Game/Art/armor/SM_Piece_gnd.uasset | Dropped item appearance |
| 7 | Icon texture | /Game/Art/UI/Icons/.../T_MyPiece | Inventory icon |
A, Blender Setup
- Import SK_HumanoidFull (extracted from FModel via Save Model as PSK) as your body reference mesh. Color body yellow and head red for visual clarity.
- Import your armor mesh PSK. On PSK import in Blender: set Linear Color, scale 3m, scale factor 0.01.
- Rename the armature object to
Armaturein Blender’s Outliner. PSK meshes from FModel carry an extra root bone; this naming tells UE to skip that extra bone on import. Exception: do NOT rename NBO (New Body Options) skeletons, they are already set up correctly. - For static meshes (
SM_prefix, like ground models): remove the armature entirely before exporting.
PSK issues: causes seam splitting and blendshape/morph artifacts; adds extra joints that cause Joint Count Mismatch errors.
UEFormat advantages/disadvantages: avoids morph issues. Problem: exports vertex colors as sRGB instead of linear, causing incorrect body part hiding in-game. Also doesn’t export sockets. Plugin: github.com/h4lfheart/UEFormat
Workaround: use UEFormat to get correct morphs, use PSK to get correct vertex colors, then copy the vertex color data from the PSK import to the UEFormat import in Blender.
B, Material Slots (Control First-Person Visibility)
| Slot | Purpose | Notes |
|---|---|---|
0 | Body main material | Hidden in first-person view |
1 | Sleeves / arms | Remains visible in first-person |
2 | Skirt / physics proxy | Should have no material assigned, disable in LOD 0 sections |
An alternative to the BDP bitmask for first-person slot hiding: edit the form blueprint with retoc and UAssetGUI to implement the MaterialSlotsHiddenInFirstPerson property directly. This approach was documented by the Emperor's Robe First Person Mesh Fix (mods/3671). Workflow: extract form with retoc, open .uasset/.uexp pair in UAssetGUI, find or add MaterialSlotsHiddenInFirstPerson, set the appropriate slot indices, save, repack. No UE project needed.
MIC_Piece_TEMP) before exporting FBX. In UE, open the skeletal mesh editor and rename it back to the correct name, then assign the correct MIC to both slots.C, Weight Painting
- Parent your clothing mesh to the armature: select mesh, Shift-click armature, Ctrl+P → Object. For NBO armors: first un-parent with Alt+P → Keep Transformations.
- Transfer weights using the Data Transfer modifier: Source = Humanoid/NBO mesh, Mapping = “Nearest Face Interpolated” → click Generate Data Layers → Apply.
- Add an Armature modifier, do NOT apply this one.
- Delete unused vertex groups (use a Blender plugin for bulk cleanup, vanilla body meshes ship with 407 vertex groups). Hand-paint any problem areas in Pose mode.
Character Mesh Editing, Key Insight (MikhailTeslov, mods/214)
The pioneering tutorial for character body mesh editing in OBR, from the Nordic Muscles mod (mods/214) by MikhailTeslov. The critical rule that the entire mesh modding scene built on:
Blender Sculpt Mode Tips
- Use Sculpt Mode instead of Edit Mode for mesh fitting adjustments, prevents accidental backface selection.
- Grab and Smooth tools are most useful for fine-tuned fitting to the body.
- Enable Backface Culling in Viewport Shading to catch normal direction issues early.
Fixing Blade Mesh Shadow Issues
- Don’t use Merge by Distance on blade edges, it breaks the sharp normals that give blades their edge.
- Fix: in Edit Mode, select the sharp edges → Mark as Sharp (turns blue).
- Or: Mesh → Normals → Average Vectors → Face Area.
- To reset normals entirely: Mesh → Normals → Reset Vectors.
D, FBX Export Settings from Blender
Name skeletal meshes with SK_SetName_PieceName_m convention (_m for male, _f for female variants).
E, Import into UE 5.3.2
- Replicate the game’s exact directory structure in your UE Content folder. This is non-negotiable, the game resolves paths at runtime.
- For Skeletal Meshes: assign the correct skeleton on import:
- Regular clothing/armor →
SKEL_HumanoidSkeleton - NBO-specific female armor →
SKEL_HumanoidFemaleAdd - Helmets → usually
SKEL_HumanoidSkeletonorHumanoidHeadRig
- Regular clothing/armor →
- Set Import Normals and Tangents to preserve your custom normals (not Compute Normals).
- Enable Import Morph Targets if you’re editing head/face meshes.
- For Static Ground Meshes: set Collision preset to Custom. Enable Allow CPU Access (required for enchantment Niagara VFX on weapons). Set Collision Complexity to “Simple As Complex” and check Customized Collision.
F, Vertex Colors & Body Part Hiding System
Vertex colors on the body mesh control which parts disappear when armor is worn, via material function MF_HideByVertexColorInt3. They must be linear, the game crashes if exported as sRGB.
| Body Part | Channel | Value | Body Part | Channel | Value |
|---|---|---|---|---|---|
| pecs + mid-back | R | 128 | shoulder | G | 2 |
| sternum | R | 64 | upper-back | G | 1 |
| front-abs | R | 32 | bicep | G | 4 |
| side-abs + lumbar | R | 16 | forearm | G | 8 |
| thigh | R | 8 | hand | G | 32 |
| knee | R | 4 | underwear | G | 128 |
| low-calf | R | 2 | bra | G | 64 |
| foot-top | R | 1 | low-ankle | G | 16 |
| high-ankle | B | 64 | toes + foot-bottom | B | 128 |
Altar_enums.hpp dump under EBodyPartSlot. Note: Ears are NOT in this vertex color hiding system, hair/ear hiding is separate, handled by the head slot / biped slot. Merged armor meshes don’t support morphs. Use Blender’s Vertex Color Controls addon (BlenderMarket) to accurately sample linear RGBA values from imported meshes.
G, Bitmask Calculator (BP_BDP Body Section Hidden)
- Extract the item’s
BP_BDPblueprint with retoc. Open in UAssetGUI. View → Expand All. FindMaleBodySectionHiddenandFemaleBodySectionHidden. Add them if not present (add as 0 first, save, then change). - Copy the current number into Windows Calculator → Programmer mode → Bit Toggling Keyboard.
- Using the body part color table above, set each bit to 1 (hidden) or 0 (visible).
- Copy the resulting number back into UAssetGUI. Save. Repack. Test.
0 means inherit hidden parts from parent blueprint, NOT “show all body parts.” To explicitly keep parts visible (e.g. sandals showing feet), set a non-zero value with all relevant bits cleared. The one exception: when editing directly in UAssetGUI, literal 0 works as true zero and doesn’t inherit.Tails are separate, add or remove the
HideTail property independently. Setting an item to the Amulet biped slot preserves hair and ears. Setting armor on the Tail biped slot makes it not render at all regardless of BDP settings.Known working values:
4294967040 = hides everything except hair · 4294918400 = Mythic Dawn armor value · 10240 = hides head only · 0 = inherit from parent.BP_Generic_BDP_LB_Boots) as the base for your helmet form. The hair slot (Hair biped) also inherits this superclass behavior, so using a boots-based BDP for a Hair biped slot item keeps hair intact. This pattern was identified from the Demoria Armor mod. Credit: qunai.The Three Levels of Item Forms
Standalone armor requires understanding that Virtuos implemented a three-level hierarchy, not two. When you look for item properties in FModel, they may be at any level:
- Level 1, The base form (e.g.
EbonyCuirass.uasset), EditorID, ESP-side properties - Level 2, The
BP_form (e.g.BP_EbonyCuirass), mesh references, GND (world model), some hiding properties - Level 3, The
BP_BDP_form (e.g.BP_BDP_EbonyCuirass), body part hiding bitmask, first-person material slots, skeletal mesh assignments
Virtuos was inconsistent about where they put things: the world model (GND) is declared in Level 1 for some armor (e.g. Ebony Cuirass) and in Level 2 for others (e.g. Monk Robe). Always check both when troubleshooting missing GND or mesh references. Most standalone workflows only require touching Levels 1 and 3, which is why Level 2 often gets skipped, but it’s there if you need it.
H, Materials in UE 5.3.2
You cannot create fully custom materials from scratch in UE, they crash on load. You can only inherit from vanilla base materials. The authoritative workflow from c0bra5:
- Pick a MIC from the game (inspect it in FModel → Export Properties JSON to see its parent chain).
- Create a new material in your UE project at the exact same path as the vanilla parent (e.g.
/Game/Art/Character/Imperial/MIC_HumanUnderwear_M). Make it inherit fromM_Base_Char. - Create a new MIC (the location doesn’t matter) inheriting from that stub in step 2. Set up your textures only in this new MIC.
- Files to include in your mod pak: your own MICs + the skeletal mesh + your textures. Everything else (stubs, vanilla materials) stays out of the chunk, it’s reference-only.
2. Never pak the base material stub, packing it overwrites the real game material and greys out everything that shares it (potentially all weapons or all armor of one type).
3. Materials can un-assign themselves from chunks when re-opening the project. Fix: confirm Allow ChunkID Assignments is enabled in Editor Preferences and set Cook Rule to Always Cook.
- FPSClippingFix, the correct parent for any first-person-visible gear (weapons, gloves, cuirass sleeves). Prevents clipping in first-person view.
- TWO-SIDED, good parent for ground drop meshes (SM_ prefix).
I, BP_BDP Blueprint Parent Chains
Your armor blueprint must inherit from the correct parent chain. Create dummy parent blueprints in Dev/clothing/GenericChild/, they just need to exist with no content. Weapons do NOT require BDP blueprints.
| Piece Type | Required Parent Chain |
|---|---|
| Cuirass | VUpperBodyModularPart → BP_Generic_BDP_UpperBody → BP_Generic_BDP_UB_Cuirass |
| Full Armor | VUpperBodyModularPart → BP_Generic_BDP_UpperBody → BP_Generic_BDP_UB_FullArmor |
| Greaves | BP_Generic_BDP_LowerBody → BP_Generic_BDP_LB_Greaves |
| Skeletal Helmet / Hood | BP_Generic_BDP_SkeletalHelmet |
| Amulet | VAmuletModularBodyPart |
J, Ground Models (GND Assets)
When armor is dropped on the ground, it uses a separate static mesh (SM_ prefix, _gnd suffix convention). Where the GND reference is declared varies, Virtuos was inconsistent. Check both the main form asset (e.g. EbonyCuirass.uasset) and the BP_ form. To add a GND reference via UAssetGUI, add a NewWorldModels ArrayProperty of SoftObjectProperty to the BP form, then View → Recalculate Nodes. GND meshes must have proper collision or they fall through the floor. Object Channels must all be set; Complex Collision set to Default.
K, Forms with C.A.F.E., ESP, SyncMap & Packaging
- Use C.A.F.E. (mods/4891) to create armor forms. The form name must match the name in the asset path exactly.
- Create your ESP in xEdit with item records. Run Smart Mapper to generate
Data\SyncMap\YourMod.ini. - Place your ESP above
AltarDeluxe.espin Plugins.txt.
| What | Where it goes |
|---|---|
| Body blueprints (LogicMods) | ...\\Content\\Paks\\LogicMods\\YourModName\\ |
| Art assets + form blueprints | ...\\Content\\Paks\\~mods\\YourModName\\ |
| ESP + SyncMap .ini | Content\\Dev\\ObvData\\Data\ and \Data\SyncMap\ |
Additional Field Notes
- Eye color editing is possible (discovered by _trungus), method still being fully documented as of mid-2025. Check the OBR modding Discord
#research-modelingfor the current workflow. - Amulet biped slot preserves hair and ears, useful for items like head accessories or circlets that shouldn’t hide hair. Note the mesh will be positioned for the neck, so clipping adjustments may be needed.
- Tail biped slot makes armor invisible regardless of BDP settings, don’t use it for regular armor.
- Weapons do NOT require BDP blueprints, they are not assigned to body parts. Only armor/clothing needs them.
- Vertex weight limits: too many bones deforming the same area produces warped/jagged edges. Delete unused vertex groups. Body meshes ship with 407 vertex groups, use a Blender plugin for bulk cleanup.
Beast Race Armor Compatibility
- Import all skeletal meshes with SKEL_HumanoidSkeleton. Never include the skeleton in your pak chunk.
- Never include physics assets, delete them from your project before packaging.
- Khajiit material slot order must be preserved, procedural overlays depend on it.
- Wrong skeleton assignment = “origami” deformed character model in-game.
Weapon FX: Enchant Glow, Blood Splatter & Swing Trail visual
Custom weapons need specific setup to get enchantment glow, blood splatter, and swing trail effects. These are driven by vertex color channels and mesh sockets.
Two Weapon Workflows
- Replacer, ship mesh + textures only. Keep the vanilla MIC name. The game loads the real MIC from Bethesda’s pak automatically.
- Standalone, ship mesh + textures + extracted MIC + extracted BP/Form. Use UAssetGUI to copy vanilla assets, rename everything (e.g.
MIC_Moonveil,BP_BDP_Moonveil_1H), fix paths, pack the art chunk as*_Art_Pand forms as*_Forms_P.
Enchantment Glow Mask (Vertex Colors)
The glow effect reads the Red channel of vertex colors. Red = glows, Black = no effect.
- In Blender: go into Mesh Paint mode (Shift+4), paint the blade red and the hilt black. Export using linear vertex colors (must be linear, not sRGB).
- UEModel exports vertex colors incorrectly (wrong channel mapping). Use PSK format for accurate vertex colors from FModel.
- If importing via FBX directly into UE without Blender, vertex colors from PSK are preserved as-is from FModel.
Required Sockets
Add these in UE’s Socket Manager after importing your mesh. These drive the swing trail and blood/impact VFX:
Socket_Start, blade root (bottom of blade)Socket_Impact, blade tip (top)Socket_End, offset away from the blade (for blood spray and sparks, not on the blade surface)BloodEffect_Socket, on the blade surface
If parenting sockets in Blender for FBX export, prefix with Socket_ (e.g. Socket_BloodEffect_Socket) because Unreal strips one Socket_ prefix on import. When adding in UE’s socket manager directly, use the name as-is.
Full Standalone Checklist
- Import mesh with Collision ON and Allow CPU Access checked
- Duplicate vanilla MIC (or create custom). Assign to mesh. For replacer: exclude MIC from pak. For standalone: include custom MIC.
- Paint vertex color red/black mask in Blender (blade = red, hilt = black). Apply and save.
- Add 4 sockets. Save mesh.
- Test enchant glow, blood, trail in-game.
- Cook and copy pak to
Content\Paks\~mods\
Physics-Driven Weapons (Flails, Chains) visual
Weapons like flails, ball-and-chains, or any weapon where parts need to physically swing and collide are handled as skeletal meshes with their own custom armature, rather than static meshes. The physics is driven by a Physics Asset using capsule bodies and constraints, same system as cloth physics but applied to weapon bones.
Blender Setup
- Model the weapon in parts: handle, chain links (one bone per link), and ball/head.
- Create a custom armature with a bone for each physics-driven part. Name bones clearly (e.g.
chain_01,chain_02,ball). - Auto-weight paint the mesh to the armature. For rigid parts (each chain link, the ball), weight all vertices 100% to their respective bone, no blending needed.
- Export as FBX at scale 0.01 (same as all other skeletal meshes).
UE Setup
- Import the FBX. Select skeleton = None on import (create new skeleton for the weapon, it doesn’t use
SKEL_Humanoid). - UE auto-generates a Physics Asset. Accept it and open the Physics Asset editor.
- The auto-generated capsule bodies and constraints often work well out of the box, each chain link gets its own capsule and the constraints allow angular movement. Adjust constraint limits as needed.
- Create the weapon’s BP_ blueprint form (not a standard BP_ weapon, make your own custom blueprint class).
- Add the skeletal mesh component to the blueprint.
- The physics asset runs automatically on the weapon when equipped.
A flail mod was successfully created using this approach (September 2025). Each chain link had its own bone, auto-weighting worked correctly, and auto-generated Physics Asset bodies produced natural swinging behavior without additional configuration.
Materials, Blueprints & Custom Icons visual
Override Material via Blueprint (No Mesh Edit)
- Extract the item’s
BP_BDPwith retoc. Open in UAssetGUI → Import Data tab. - In the bottom empty row, add: ClassPackage =
Script/CoreUObject· ClassName =Package· OuterIndex = 0 · ObjectName = full game path of your material · BImportOptional = False. Click away and back to make it stick. A new empty row appears below. - Fill the new row: ClassPackage =
/Script/Engine· ClassName =MaterialInstanceConstant· OuterIndex = negative row number from step 2 · ObjectName = material name only. - Go to Export Data tab → find the ChaosClothComponent (or appropriate mesh component) → look for or add
OverrideMaterialsArrayProperty of ObjectProperty. Inside it, map slot numbers to your import rows. - Save → repack with retoc → test.
JsonAsAsset (Import Vanilla Materials)
- Install the JAA plugin into
Plugins/JsonAsAssetin your Altar project. Enable, restart, configure the export directory and mappings file path in plugin settings. Launchj0.dev.exe. - In FModel, right-click a material → Export Properties (.json). Keep the export path short with no spaces, close to the root drive (e.g.
C:\FModelExports). - In UE, click the JAA plugin button → select the JSON → wait. Enable the Stubs checkbox (new in 1.4.1) for crash-free import of any material without a pre-made material store.
- MIC parents are not automatically assigned in 1.4.1, set the parent manually after import. If the imported material is missing layers (the MIC Layers tab), edit that material in UAssetGUI instead, JAA doesn’t import the Layers tab.
- If JAA isn’t working: close FModel before running JAA; restart UE and re-check plugin settings.
Transparent / Masked Materials
| Method | Result | Notes |
|---|---|---|
| Blend Mode: Masked + Opacity Mask | Binary cutout (no gradient) | Works reliably for tattered cloth, hair cards |
MIC_Necromancer_Amulet_Gem | True translucency | Path: Content/Art/Clothes/Amulet/ |
/Engine/EngineDebugMaterials/M_SimpleUnlitTranslucent | Unlit translucency | Color only, can’t add textures or roughness |
Custom Inventory Icons
- In UE: create folder
Content/Art/UI/Icons/Dynamic_Icons/menus/Icons/armor/YourFolderName/ - Import your 256×256 PNG. In UE, prefix it with
T_(e.g.T_MyCuirass). Without the prefix, the game won’t detect the asset. - In xEdit, set the Icon Image path to:
Art/UI/Icons/.../YourFolderName/MyCuirass.dds, with.ddsat the end even though there’s no .dds file. Do NOT include theT_prefix in the xEdit path.
Blueprint Behavior Mods (ModActor Entry Point)
- Create
Content\Mods\YourModName_P\in your UE project. The folder name must match your final pak filenames exactly. - Create a Blueprint of type Actor and name it exactly
ModActor. UE4SS BPModLoaderMod auto-discovers and injects this on game load. - Add a Widget Blueprint (
WBP_ModHud) as your mod’s UI layer. In ModActor’s Event Graph, onEvent BeginPlay: Create Widget → Add to Viewport. This persists across level changes. - Use
Event Tick+Was Input Key Just Pressedon the ModActor (not on a hidden widget, hidden widgets don’t tick) for keypress detection. - Save mod data using
SaveGame to slot "Mods/YourModName"(not the root, to prevent crashes when the mod is removed). - Package and place files in
Paks\LogicMods\YourModName_P\.
ScrollBox widget crashes on some setups when included in a packaged pak. Workaround: create the ScrollBox at runtime using a Create Widget node rather than placing it in the widget designer. VModernScrollBox and VAltarNavigableScrollBox are custom Altar variants that partially address this but don’t solve all cases.Add Cloth Physics to Clothing bridge
SkeletalVariants property in generic armor BPs which forces CCA files to be mandatory. Hair and amulets work because they don’t have SkeletalVariants.UE Editor Steps (Apply Cloth Physics)
- Import your cloth mesh separately from the armor body. They’ll be re-attached via blueprints.
- Open the skeletal mesh → right-click the section to simulate → Create Clothing Data from Section. Name the asset, leave Physics Asset as None for now.
- Right-click again → Apply Clothing Data.
- Click Activate Cloth Paint: white = simulated (moves freely), black = anchored (fixed to skeleton). Top edge = black, bottom hangs free.
- Tune ClothConfigs for physics behavior: stiffness, damping, gravity scale. Reference: UE cloth painting tutorial.
- Right-click mesh → Create Physics Asset. Name it
PA_YourMod_Cloak. - Open the Physics Asset → replace auto-generated capsules with hand-placed ones on the bones that matter. Set all capsule Physics Type to Kinematic.
- In the clothing config, assign your physics asset.
Physics Proxy Mesh
A low-poly duplicate mesh called “PhysicsProxy” is often included in OBR meshes. Physics is calculated on the low-poly version and then interpolated to the actual mesh. If your armor doesn’t need cloth physics: right-click the PhysicsProxy section in LOD 0 → Disable. Or delete the slot entirely. A material assigned to the proxy causes z-fighting and clipping artifacts.
Blueprint Setup for Cuirass + Cape
- Create
Content/Mods/YourName/. Folder name must match pak filenames exactly. - Create a blueprint named exactly
ModActor(parent: Actor). UE4SS looks for this specific name. - Create dummy parent blueprints for your armor type (see Lesson 7 parent chain table).
- Create your main blueprint (parent = deepest appropriate dummy). Set Male Mesh and Female Mesh to the armor body mesh (without cloth). Configure Material Slots Hidden In First Person.
- Add two Skeletal Mesh components as children of Root (one male cape, one female cape).
- Event graph:
Event BeginPlay → Branch → Get Female Mesh → Is Valid Soft Object Reference → Set Leader Pose Component → Set Hidden In Game (male cape, check Hidden). Compile. - Assign blueprint + ModActor to a different chunk number from your mesh assets. Package. Drop into
Paks\LogicMods\YourName\. - In UAssetGUI, update the
BP_BDPpath in your biped model form to point to your new blueprint.
VAmuletModularBodyPart behavior changed, you must now add the cape as a child to the Root Skeletal Mesh Component, not assign it directly. For the root, create an empty skeletal mesh (duplicate any SK → disable LOD0). Also: gender swapping via Get Female/Male Mesh nodes in the event graph no longer works as of patch 1.2.Standalone Capes (Amulet Slot)
Route standalone capes through the amulet slot only, amulets are the only item type that accepts physics meshes directly without the CCA issue. Blueprint parent: VAmuletModularBodyPart. In xEdit, ensure the item uses the Amulet biped flag. Use the Hair mesh slot as an alternative if you don’t mind the workaround. Performance: cloth simulation is CPU-bound and varies significantly between systems. Use a proxy mesh (simplified low-poly version) for high-poly capes: proxy mesh guide.
Custom Animations & Replacers visual
Step 1, Import SKEL_HumanoidSkeleton via JsonAsAsset (Critical First Step)
Use JAA 1.3.7 or newer. This imports the skeleton with virtual bones (IK targets) intact. Virtual bones cannot be recovered from a PSK export, JAA is the only reliable method. The correct skeleton has 378-379 bones (without sockets exported as bones).
Step 2, Export SK_HumanoidFull from FModel Correctly
In FModel: Settings → Models → Socket Format → “Don’t Export Bone Sockets”. This prevents sockets from being exported as bones, which adds ~28 extra bones and breaks all IK at runtime.
Step 3, Fix the Extra Root Bone in Blender
The PSK importer adds a root bone named after the mesh file (e.g. SK_HumanoidFull). Fix: rename the armature object to Armature in Blender’s Outliner. This makes UE skip the extra root, and the bone index order matches correctly.
Correct workflow:
1. Set Blender timeline to 30fps before importing PSA/PSK.
2. Edit or create your animation at 30fps.
3. Export as FBX.
4. Import into UE with custom sample rate of 60fps.
Common mistake: Blender defaults to 24fps. Importing at 24fps makes the total animation length longer than the vanilla version, shifting all notifies out of sync. Verify by exporting the vanilla animation’s JSON from FModel and comparing FrameCount and Duration.
Retargeting Animations from Other Games
Any animation from any source (Elden Ring, The Witcher 3, Skyrim, Paragon, stock UE Mannequin, Mixamo, CMU mocap library, Fab marketplace free tier) can be retargeted to the OBR skeleton.
Recommended UE 5.4 bridge method (krasuepisac):
- In your 5.3.2 modding project, migrate the OBR skeleton to a UE 5.4 project via Content → Migrate.
- In 5.4: create an IK Rig and IK Retargeter for the OBR skeleton. UE 5.4’s retargeting tools are 1-3 clicks vs. building an entire IK rig manually in 5.3.2.
- Retarget your source animations onto the OBR skeleton in 5.4. Export the retargeted animations as FBX.
- Re-import FBX into your 5.3.2 modding project. (Assets can’t be migrated back from 5.4 to 5.3.2, but FBX as an intermediate works perfectly.)
For Skyrim animations specifically: import NIF animations into Blender using the NIF importer plugin, export as FBX, then use the retargeter method above.
Animation Notifies, Combat Combo Chaining
Simply importing notifies via JAA is often insufficient, they may need to be added manually. For combat combos to chain correctly, both of the following notifies are required:
BP_ActionNotifyState_ChainWindow Begin Action Event Tag: ActionEvent.Attack.ChainingWindow.Enter End Action Event Tag: ActionEvent.Attack.ChainingWindow.Exit VAnimNotify_ActionNotifyState Begin Action Event Tag: ActionEvent.Attack.InputWindow.Enter (no End tag)
Removing either one breaks chaining. Confirmed by kei7855 on their shortsword animation replacer mod (mods/2489).
For block to interrupt an attack mid-swing, the start time of this notify must fall within both VAnim_ActionMeleeHitWindow and VAnimNotifyState_ImpactSystem:
BP_ActionNotifyState_TagContainer Tags To Transmit: [Input.Action.Combat.Block] BeginActionEventTag: ActionEvent.Attack.CancelTags.Add EndActionEventTag: ActionEvent.Attack.CancelTags.Remove
Replacing Locomotion Blendspaces Persistently
Injecting a blendspace via AnimSet gets reset when the player equips or unequips a weapon, because the game stores all valid blendspaces in a TMap keyed by GameplayTags. Solution (krasuepisac’s discovery):
- Find the TMap that maps GameplayTags to blendspaces inside the AnimBP.
- Swap the entry for the relevant tag directly. Tag the actor after modification to avoid re-applying every frame.
- This persists through cell transitions, the player actor reference survives cell loads (unusual for UE games but intentional here).
Velocity reference values: Walk ~155 · Run (blendspace activates) ~509. Sprint uses a plain AnimSequence, not a blendspace. Root motion appears fully disabled, everything is root-locked; set root lock to zero on import.
Playing Animations at Runtime (Lua/Blueprint)
Triggering PlayAnimation from Lua works for the first play, but after the animation completes the character stops playing all normal animations permanently (frozen on last frame or T-pose). Use PlayMontage instead, montages blend in and out automatically via UE’s montage system and return the character to ABP-controlled state after finishing. The FullBody animation slot overwrites all other blending when active, use it for full-character override animations like emotes or cutscene poses.
ABP Architecture, Template & Linked Layers
The game uses a template ABP with linked layer instances. Each animation category (combat, idle, locomotion) is a separate linked layer instance within the template. Target the correct layer for the animation type you are replacing, this is why some replacements work and others break unexpectedly.
StrideWarping (standard UE plugin node) handles foot placement based on LocomotionSpeed. Foot lock positions only update when the character starts or stops moving.
Actor Persistence Across Cell Loads
- The player actor reference persists through cell transitions (unusual for UE games). GameplayTags on the player also persist.
- NPC actor references are completely destroyed on any cell load. Store persistent NPC mod data in a widget or custom UObject with a reference loop.
Mesh Merging & Morph Targets
UE’s Merge Meshes system combines all equipped armor pieces into one SkeletalMesh at runtime. This process destroys morph targets. NBO avoids this by using bone scaling instead, bone scale survives merging.
Gamebryo Animation (Legacy)
PlayGroup and PickIdle execute on the Gamebryo side but have no visual effect since rendering moved to UE5. AI Package idles still work. NPCs can get stuck in triggered idles, cast a spell on the NPC to break the animation lock.
Animation Workflow, Full Setup Path (miken1ke)
There are two approaches to setting up an animation project:
- Source build UE 5.3.2 (miken1ke's method), download from EpicGames GitHub (requires account linked to Epic). Run
Setup.bat,GenerateProjectFiles.bat, openUE5.slnin VS2022, build. Takes 30min-5hrs. Then switch yourOblivionRemastered.uprojectto point at the compiled source engine. Community fork available: C0bra5/UnrealEngine. - Kein/Altar project (the method documented in this wiki), pre-stubbed project, compiles in minutes. Works for most animation use cases without needing a full source build.
Which to use: For basic animation replacement, the Kein/Altar method is faster. For complex retargeting setups or advanced animation system work where you need the full editor, the source build gives you more control.
Animation File Naming and Folder Structure
From miken1ke's guide: folder structure AND filenames must match the game's exactly. Use FModel to verify the exact path. Example: replacing one-handed sprint = file must be named A_Humanoid_OneHand_Sprint.fbx placed at Content\Art\Animation\Humanoid\ThirdPerson\OneHanded\Locomotion\Normal. Wrong folder or name = animation does not load.
Packaging Settings for Animation Mods (miken1ke)
In Project Settings, ensure: Use Pak File = True, Use Io Store = True, Generate Chunk = True. Clear both "Additional Asset Directories to Cook" and "List of maps to Include in a packaged build". Build Configuration = Shipping (note: this differs from the general mod guide which uses Development, for animation replacers only, Shipping is confirmed to work by miken1ke).
Reference Mods & Resources
- miken1ke Humanoid Rig (mods/2069), standard community Blender rig built with Rigify, custom-tailored for OBR. About 400 bones with a Rig Layers panel for hiding/solo-ing bone collections. Includes a Costume system (Item tab sidebar in Blender) for body type and equipment visibility, first-person and third-person animation workspaces, and a one-click Export Animation button in the sidebar. Discord: discord.gg/E2f2umtWGd. Articles: Animating for OBR and Rig Manual.
- kei7855 shortsword animation mod (mods/2489), reference for notify setup
- Custom bow animations require two notifies:
VDrawArrow(fires the arrow, C++ class from Altar) andVAnimNotify_PlayWwiseSound(plays the fire sound, from the Wwise plugin). Both must be present. Building a Wwise-only project without Altar gives youPlayWwiseSoundbut removesVDrawArrow, breaking arrow firing. Credit: .yeah.nah.yeah. - Leaning disable mod (mods/2570), disables forward-lean locomotion behavior
- Alpakit for UE 5.3.2, OBR-compatible fork; enables one-click auto-deploy of BP mods
- Jake Alaimo live mocap, live mocap modded into OBR, reference implementation
Facial Animation & the MetaHuman System visual
Architecture
- Every voice line has its own AnimSequence stored in
Content/Art/Animation/Humanoid/Facial. Warning: the sheer number of files in this folder will freeze FModel on browse, navigate by known path instead. - Audio and face animation play independently, they start at the same time with no synchronization tracking. This means replacing either leaves the other running unchanged.
- Mouth/speech sync uses a UE function called speech2face (procedural, no per-frame data).
TABP_FacialPoseis the facial pose AnimBP.
Setup for Custom Head Facial Animation
- Enable the RigLogic, MetaHuman, and MetaHuman Identity plugins in your Altar UE project.
- Attach the appropriate
.dnafile to your face SkeletalMesh asset. DNA files are NOT standard.uassetfiles, they require CUE4Parse to extract from the game:
foreach (var kv in provider.Files) {
if (kv.Key.EndsWith(".dna")) {
var outPath = Path.Combine("extracted", Path.GetFileName(kv.Key));
await using var stream = provider.SaveAsset(kv.Value,
new FileStream(outPath, FileMode.Create));
}
}
- Create and reference a dummy ABP_HeadPostProcess blueprint. This is required for the DNA to drive the facial bones.
Community DNA Files
WSDog extracted and publicly shared DNA files for all base races and named NPCs:
- Community DNA files (mods/2592), base races and named NPCs
- Guide: Nexus mods/625
Generating New Facial Animations from Audio (Audio2Face Workflow)
- Use NVIDIA Audio2Face to generate blendshape/shape key animation from your audio file.
- Pipe through MetaHuman Live Link into UE 5.3.2. Record the result as a UE AnimSequence.
- OBR face curves share the exact same names as MetaHuman curves, they map directly with no translation needed.
- Replace the existing AnimSequence at the matching path in
Content/Art/Animation/Humanoid/Facial. - MetaHuman plugin supports batch processing for automating generation from multiple audio files at once.
Audio-Anim Sync for Localization
Since audio and face animation play independently, to fix out-of-sync dubbed audio (where the new audio is longer than the original): replace the AnimSequence at the correct path with a new one timed to match your audio duration.
Wwise Audio Integration visual standalone audio solved
The game uses Audiokinetic Wwise 2023.1.8.8601.3258 for all audio. UE’s built-in audio system has only two dummy assets (DummySoundClass, DummySoundCue).
What Works Without Full Wwise Integration
- Sound replacement: swap existing
.wemfiles inside a pak (see Lesson 2) - Posting vanilla Wwise events from Blueprints: use the
VAudioHandlerssubsystem andBPF_PostEvent(orPostEventAtLocationfor spatial audio) - Turning vanilla audio events on/off from Blueprints using vanilla
AkAudioEventclass defaults
Full Wwise Integration Setup
- Install Wwise from the Audiokinetic Launcher, select exactly the version above.
- In your Altar project folder, delete the existing
WwiseandWwiseNiagaraplugin folders. - Use the Launcher: Unreal Engine → Integrate Wwise into Project.
- Fix the compile error in
Source/Altar/Public/VAltarAkPortalComponent.h: update the include to"AkAcousticPortal.h"and parent class topublic UAkPortalComponent(the Altar project stubs reference the outdated nameAkPortalComponent). - Build via Visual Studio. Name the Wwise project
Altarand place it atC:\Altar-main\WwiseAltar\to match paths in the game’s DefaultEngine.ini (not strictly required but simplifies cross-referencing).
External Sources, Custom .wem Files Without Rebuilding Soundbanks
External Sources allow specifying which .wem file Wwise loads at runtime without rebuilding soundbanks. This enables one framework mod to handle the Wwise plumbing while other mods simply ship .wem files.
Enable External Sources by overriding the game’s DefaultEngine.ini from your mod pak:
[Audio] WwiseFileHandlerModuleName=WwiseSimpleExternalSource
Required Setup for External Sources
- Create a DataTable using the External Source cookie struct (provided by Wwise).
- Populate with: cookie value, media ID, codec ID, media name (filename of your
.wem). - Override
DefaultEngine.inito enableWwiseSimpleExternalSource. - Include your
.wemfile in the pak at the correct staging path. - Ensure your mod actor has a packaged static mesh component with a mesh assigned.
Getting a Free Non-Commercial Wwise License
Creating new Wwise audio events (new sound forms in the ESP with new UE audio) requires Wwise’s authoring tools to generate new .bnk soundbank files. Getting a free non-commercial license requires a manual review by Audiokinetic staff, fill out the form at audiokinetic.com/en/pricing honestly.
The Custom Audio Research Wall (Sep 2025 deep-dive by .yeah.nah.yeah.)
This section documents what actually happens when you push beyond sound replacement, based on months of research. Understanding these limits saves enormous time.
- What IS exposed via
UAkGameplayStatics:PostEvent,PostEventAtLocation,PostEventOutdoors,SetRTPCValue,SetSwitch,SetState,LoadInitBank,StopAllSounds. These let you trigger, modify, and stop existing vanilla events. - What is NOT exposed:
LoadBankByName. The game uses auto-bank loading viaUAkAudioEvent.RequiredBank+ project setting “Load Banks Automatically.” You cannot load additional custom banks without C++. - SID (ShortID) matching: The event’s Short ID and the soundbank’s Short ID must match. Set the soundbank’s hardcoded ID in UE to match the event Short ID. The long GUID strings in FModel appear to be ignored at runtime, only the SIDs matter.
- Audio routing is Wwise-only: The shipped game uses
WwiseOnlyaudio routing set at package time.AudioMixerandAudioLinkare compiled out. ChangingDefaultGame.iniaudio routing at runtime appears to have no effect. A dummySoundClassandSoundCueexist in/Dev/but are empty stubs. - UE native audio (non-Wwise) CAN play:
au.debug.generator 1in console plays a UE native test tone (disable withau.debug.generator 0, don’t do this in any menu). This confirms UE audio is not fully stripped, just disabled for normal use. - BNK injection via BNK Editor: You can inject new events into existing vanilla
.bnkfiles using BNK Editor. Keeps all IDs in the vanilla Wwise project context, so GUIDs match. - The External Sources route: Wwise events with External Sources pre-configured at project-build time accept custom
.wemfiles at runtime without rebuilding soundbanks. Check if any vanilla events have External Sources enabled (rare but possible). - Audiokinetic-provided UE nodes:
IsGameandIsEditornodes work correctly in shipped builds. Use these in BP to guard audio logic. Wwise nodes likePost Event and Wait for Endfunction in editor and fire correctly in-game when triggered byEvent Begin Playon a ModActor. - ModActor Event Begin Play quirk:
Event Begin Playfires on ModActor spawn (typically game load / new game only). It does NOT fire on subsequent save loads if the actor already exists. UseEvent Tickwith a delay gate, or trigger audio from in-game hooks instead of Begin Play for persistent behavior.
The path the community assumed: for a long time a C++ DLL hook wrapping FAkAudioDevice::LoadBank() and FAkAudioDevice::PostEvent() was thought to be the only fully general solution. See dicene/OblivionRE_CppMods for the C++ approach. That is no longer the only way, see the breakthrough below.
The Working Pipeline (condensed)
- Wwise plugin must be exactly
2023.1.8.8601.3258, any other build produces silent bank-version mismatches (bank version X vs runtime 150). - Three Altar source fixes before the project compiles with Wwise: in
VAltarAkPortalComponent.hinclude"AkAcousticPortal.h"; inVMusicPlayer.hswap"EAkCallbackType.h"→"AkGameplayTypes.h"; null-guardFAkAudioDevice::AddDefaultListener(bothin_pListenerandIWwiseSoundEngineAPI::Get()). Build withBuild.bat AltarEditor Win64 Development, not the VS GUI. - Delete the Wwise Motion device from the project (
Default_Motion_Device,Motion Factory Bus, theFactory Motiontree) and save, otherwiseSoundBankGenerationfails entirely (MissingPlugin/WwiseConsole failed with error 1) and banks silently never update. - Keep media in-memory (Stream unchecked) so the audio bakes into the bank’s
DATAchunk. Route to the default Master Audio Bus (FNV-1 hash3803692087, same ID the vanilla master bus uses, so no GUID spoofing needed). Generate banks through the UE Wwise integration, and delete any stale loose banks at theGeneratedSoundBanks\root that shadow the freshWindows\ones. - Two paks, two tools (because OBR uses IoStore): cooked uassets (AkAudioEvents, ModActor) go through retoc
to-zenas a.pak+.utoc+.ucastrio intoLogicMods; loose.bnk/.wemgo through UnrealPak with an explicit filelist into~mods. A lone UnrealPak pak will not mount cooked uassets..bnkpaths are flat inWwiseAudio/;.wemfilenames must be the media SID. - NEVER ship an
Init.bnk/InitBank.uassetand never callLoadInitBank()at runtime, either one silences ALL game audio by overwriting the vanilla bus routing. - Post the event from Lua with
PostEventAtLocation, it is the one exposed post method with a signature UE4SS Lua can call (no blockingPostEventCallbackdelegate). The BPPost Eventnode crashes;PostEvent/PostOnActor/PostOnComponentall hit the delegate wall.
local AkStatics = StaticFindObject("/Script/AkAudio.Default__AkGameplayStatics")
local ev = StaticFindObject("/Game/WwiseAudio/Events/TimeStop/TimeStop.TimeStop")
if not (ev and ev:IsValid()) then LoadAsset("/Game/WwiseAudio/Events/TimeStop/TimeStop"); ev = StaticFindObject("/Game/WwiseAudio/Events/TimeStop/TimeStop.TimeStop") end
local player = UEHelpers.GetPlayer()
local loc = player:K2_GetActorLocation()
local rot = {Pitch = 0, Yaw = 0, Roll = 0}
-- Returns a valid (non-zero) playing ID and plays the custom in-memory media:
local id = AkStatics:PostEventAtLocation(ev, loc, rot, UEHelpers.GetWorld())
Media array on an in-memory AkAudioEvent is normal, the media lives in the bank, not the event asset. And FModel failing to parse your .bnk (IndexOutOfRange) is an FModel parser fragility, not a broken bank, verify via SoundBanksInfo.json or a manual chunk parse instead. Don’t chase the bank; the bank is almost always fine.World & Mapping, What’s Actually Possible visual
.umap files. The CK terrain editor exists in name but has no visible effect at runtime, Unreal’s terrain takes over.Current Status Table (mid-2025 community research)
| Feature | Status | Notes |
|---|---|---|
| New interior cells | Working | Via MagicLoader (Haphestia’s tool) |
| New exterior cells | Not working | nafnaf_95 confirmed, as of mid-2025 |
| New worldspaces | Not working | As of mid-2025 |
| CK terrain editing | No effect | UE terrain takes over; CK data unused |
| UE heightmap editing | No clean workflow | No polished mod tools yet |
| Grass type modification via CS | No effect | Grass is UE-side foliage paint |
| Navmesh editing | Not possible | Baked into UE umaps |
| LOD configuration | Not needed | Nanite handles it automatically |
| .umap direct editing | Works via UAssetGUI | Version-sensitive; breaks on updates |
| Map borders | Can be disabled | Terrain ends at square boundary |
| Custom map loading | Working | Must use MagicLoader for Gamebryo sync |
Grass & Foliage
All visible grass is part of generated UE maps, painted onto landscape materials during UE map generation. TESGrass records in the CS have no in-game effect. Adding custom trees and grass:
- TESSyncMapInjector approach (most ergonomic): link a UE static mesh to a TES mesh record in the CS and place it using standard CS workflows. Demonstrated for water meshes (mods/4874) and trees (mods/4740).
- Blueprint/Lua spawn approach: a custom Blueprint spawns a mesh at dynamic coordinates from Lua. Requires manual coordinate input.
- Mad’s placement system (minuteready): spray mesh instances organically by running around in-game using Mad’s “rapid spell” + “add instance on X” system, writing positions to a text file, then importing them. Labor-intensive but gives natural placement feel.
- Removing existing UE grass: edit each
.umapvia UAssetGUI to remove grass foliage actors (tedious, version-sensitive), or hook the grass Blueprint creation in Lua and destroy instances on spawn (theoretical).
Heightmap
The old Gamebryo heightmap still exists in game files but does NOT drive the visible terrain. The UE side has its own heightmap. No clean extraction or re-injection workflow exists yet.
Using surf / blenderumap3: importing .umap files via surf/blenderumap3 confirms the layer-per-category structure, exporting only the umap for a specific area returns a single tree mesh, not the full scene. The layers need to be loaded together to reconstruct the full environment. Tools: search for BlenderUMap2 and surf in the UE modding community.
The “All of Tamriel” heightmap from Nexus (Skyrim mods/52675) has been discussed as a source for adding adjacent provinces. Key note from grimlock_arts: TES game heightmap data is deceptively low detail, the games rely heavily on placed rock models pushed into the ground to create the impression of complex terrain. The raw heightmap is just a flat base layer.
Heightmap tools referenced by the community:
- TESAnnwyn, CK heightmap import/export for the Gamebryo side (legacy, won’t affect UE terrain)
- Aerialod, heightmap viewer; recommended settings: scale 2.5, adjust lighting for shadow detail
- ue4parse / UnrealStuff landscape tools, for reading UE landscape data
Map Borders & Open Space
Map borders can be disabled. A large open stretch of land runs from roughly 1/3 to 1/2 the size of the main game map, fully walkable and tree-free, extending all the way to the ocean. No water in the way. This is the most viable space for exterior expansion content as of mid-2025.
Navmesh
Navmesh editing is not currently possible from the modder side. NPCs cannot be directed reliably in new areas without navmesh. Workaround: add a small gatehouse cell or confined space to prevent NPCs from wandering beyond intended areas.
Nanite & LODs
OBR uses Nanite for static mesh geometry, which handles LODs automatically. You do not need to create LOD meshes or configure LOD settings. There is no DynDOLOD equivalent. FModel exports only the fallback mesh (low-poly version for Lumen), not the full Nanite detail. For full extraction use c0bra5’s simple-nanite-parser (Python-based, exports to GLTF with vertex colors).
CK-UE Terrain Height Mismatch Workaround
When building outside the vanilla map border, CK terrain height and UE terrain height diverge. Workaround (nafnaf_95): work one cell at a time, build at CK-default height, then select all objects and shift them vertically until they sit correctly on the UE terrain surface. Tedious but workable for exterior static object placement.
Trees & SpeedTree
Trees are handled via SpeedTree assets with built-in wind animation, baked into per-cell UE umap files with foliage instancers. Map file names are hashed making systematic modification very difficult. Adding new trees as individual Blueprint actors works but incurs significant performance cost. The SpeedTree SDK (required for full authoring) has enterprise-only commercial licensing (~$100k+).
Build a New Interior Room or Player Home bridge
New interior cells work via MagicLoader. The tool modifies the CellsToMapPath DataTable with new entries that map Gamebryo cells to UE map files, making the game load the correct UE level when a cell is entered.
- In the Construction Set, load MagicLoader’s example file (
IntTestMod.esp) → Set As Active File. - In Cell view, create a new interior cell. Build the room using existing tileset pieces. Place furniture, containers, lights. Add a door and use Door Teleport to connect it to an exterior location.
- Save the esp. Open in xEdit (all Altar files loaded) and run Haphestia’s Fix and Port Script (
fixmod). - Run the Smart Mapper xEdit script. Copy the resulting
.initoData\SyncMap. - Run MagicPatcher to build the string table patch
.jsonfor room names and door labels. - Run MagicLoader → “Do Magic!” once.
- Enable your esp in
Plugins.txt, aboveAltarDeluxe.esp.
Essential External Guides
MagicLoader Known Bug (Fixed in v1.1)
An early version of the Fix and Port Script overwrote FULL name fields on existing records, causing custom items and locations to lose their names. This was fixed in v1.1: “Fixes clearing IDs on newly added records by mistake.”
Map Marker Shows <MISSING STRING TABLE ENTRY>
If your player home map marker displays <MISSING STRING TABLE ENTRY>, a string table entry is missing from MagicLoader's configuration. Fix: in xEdit, set the map marker's FULL, Name field to the naming scheme LOC_FN_NameOfCell (e.g. LOC_FN_MyHideout). Your MagicLoader .json config file must have a matching entry:
{
"Plugin": "MyMod.esp",
"NewCells": ["ed3"],
"FullNames": {
"LOC_FN_MyHideout": "My Hideout"
}
}
Source: Selene310187's Player Home Problem Solving Guide
Later Changes to Placed Objects Not Taking Effect
If you move an object (e.g. reposition a bed) and the change doesn't appear in-game even after reinstalling the mod: make a clean save without the plugin loaded, then reload with the plugin. This forces the cell to regenerate from the ESP rather than from cached save data. Source: Selene310187.
Missing Walls in New Cells
Statics placed in the original CS editor have no XAAG and XACN records, which the Remaster requires for geometry rendering. Haphestia's Fix and Port Script (fixmod) adds these records automatically. This is why running fixmod is a mandatory step, skipping it causes walls, floors, and other statics to be invisible or missing. Source: Selene310187.
Custom Creatures via Blueprint Duplication bridge
- In FModel, find the base creature blueprint you want to modify. Note which blueprints it references.
- Trace references upward, those BPs reference their own parents. You’ll find a hierarchy of AI, animation, skeleton, etc.
- Duplicate only as many levels as you need. Stop at the level where you want to diverge. To replace mesh and textures while keeping vanilla AI and animations: duplicate only 2-3 levels.
- In your duplicated top-level blueprint, swap in your new mesh, textures, or behavior values.
- Create the ESP form for your new creature in xEdit. SyncMap it via TesSyncMapInjector. Package blueprints and form.
NPCs, Custom Races & Hair Modding bridge
Adding New NPCs
- Create the NPC record in CS or xEdit. Use an existing vanilla race for reliable results, non-vanilla races default to Imperial appearance at runtime if the UE form can’t be found.
- For the UE side: clone an NPC form file from FModel that matches the intended race/gender.
- Edit the cloned form in UAssetGUI to change name references.
- Register in TesSyncMapInjector.
- Place the NPC in a cell via CS.
0000_. At runtime, if UE can’t find the race form, it silently defaults to Imperial.Custom Races
Custom races require:
- An ESP with the new race record.
- A UE race form blueprint at
/Game/Forms/actors/race/CustomRace.uasset. - Entries in the AllRaceModifications data table (edit via JsonAsAsset or retoc + UAssetGUI).
- Phenotype and hair tables updated to include the race.
Adding fully custom playable races with all CharGen sliders is still in research as of mid-2026. Non-playable custom races (for NPCs) work when given a vanilla voice type. The race menu UI reads from UE-side data tables.
Hair Modding
Hair phenotype files live at /Game/Dev/Phenotypes/Hair/ as data tables listing available hairstyles per race.
- Extract the existing
HP_[Race]_HR_[StyleName].uassetfile using retoc. - Inspect with UAssetGUI or JsonAsAsset.
- Create your new hair mesh in UE project.
- Add an entry to the race’s phenotype hair data table.
- Use RMU (Race Menu Utility) for runtime loading without opening ShowRaceMenu:
rmu set hair /Game/Dev/Phenotypes/Hair/HP_RaceName_HR_HairName.HP_RaceName_HR_HairName
Hair texture format: use the _RAUD suffix multi-channel format. Always export as TGA from FModel, the alpha channel contains the opacity/strand mask, critical for proper hair card rendering.
Facial animation compatibility: modifying head meshes (including head-attached hair) can break facial lip-sync if the mesh topology changes significantly. Blinking and face morphs may continue to work even when lip-sync breaks.
Strand hair: true strand (groom) hair via UE5’s Groom system has been partially achieved but the full binding pipeline is not documented. Hair card replacement is the stable workflow.
Dialogue, Voice Lines & Lip Sync bridge
Text / Subtitle Changes
ESP-side text: changing dialogue text in CS/xEdit works for most dialogue. Display names use the LOC_FN prefix system, forward AltarESPMain changes or names break. Localization files: some UI strings are in UE localization tables inside the PAK and require editing the localization .uasset files. Note: only one mod’s localization table wins in case of PAK conflict.
Reusing Existing Voice Lines for New NPCs
- Create the dialogue topic in your ESP.
- Use TesSyncMapInjector to map your new dialogue FormID to an existing dialogue form blueprint path. Format:
TOPICTYPE_espname_FormID. Example:GREETING_MyMod_00000ED6=/Game/Forms/miscellaneous/dialog/GREETING_Someactor_000479e1.GREETING_Someactor_000479e1
- The NPC’s voice type (set in the race ESP record) must match the target dialogue form’s voice set. Using a non-vanilla race? Set the race to a vanilla voice type to prevent crashes.
Subtitle Display Duration Without Voice
Placing .mp3 files in the legacy Sound/Voice/ folder matching the vanilla dialogue filename convention controls subtitle display duration, even though no actual audio plays. The mp3 file’s duration equals the subtitle display time, useful for subtitle-only localization mods.
Lip Sync
Lip sync is handled by UE animation assets linked to dialogue forms. Current workaround: use an existing dialogue form that has entries for multiple races, the game will attempt to use the matching race’s lip sync animation. The lip-sync side is now fully mapped. It is a baked facial AnimSequence referenced from the TESTopicInfo asset, not a Wwise/audio-driven system (the “speech2face” mystery is resolved). See the Dialogue, Voice & Lip Sync deep-dive for the complete pipeline. The remaining blocker is interior Wwise bank residency (custom banks post fine outdoors but return playing_id 0 indoors).
Step 5 of 5
Scripting & Custom Code
Three scripting surfaces mirror the two-engine architecture. The real craft is making them communicate with each other.
The Three Scripting Surfaces
brain 1 · OBScript (Construction Set)
The classic Oblivion scripting language, bread and butter for enchantments, spell script effects, object/quest scripts, and AI packages. The UESP Construction Set wiki is ~20 years of OBScript documentation and is the essential reference. Any function marked as OBSE on that wiki will not work in the Remaster.
brain 2 · OBSE64 (C++ DLL Plugins)
OBSE64 is currently only a DLL/plugin loader, it does NOT extend OBScript with new functions, despite the misleading name. To write C++ DLL plugins, use commonlibob64 for reverse-engineered class headers. OBSE64 is version-locked: any game binary update breaks it until offsets are manually updated. See a working plugin example at cnf13/DeleteSpells.
visual 3 · UE4SS Lua + Blueprint Loaders
Some capabilities exist only on the Unreal side: hiding armor pieces, driving animation, Niagara VFX, spawning actors. Use the Nexus OR-specific UE4SS build (v3.0.1-394-g437a8ff), the generic GitHub experimental build doesn’t hook into UE 5.3 properly and may not recognize Execute Console Command nodes from Blueprints.
The Notification Bridge, OBScript → Lua
Worked out by MadAborModding on top of a notification-parsing method by Dicene. This is the breakthrough that unlocks “impossible” mods like levitation, custom physics casts, and transformations.
OBScript can send on-screen notifications, and Lua can read and instantly hide them. A notification becomes a silent message channel from the Gamebryo brain to the UE face.
Brain side, OBScript attached to a spell, quest, or item
ScriptName madLevitationScript
begin ScriptEffectStart
message "madLevitationScriptStart"
end
begin ScriptEffectFinish
message "madLevitationScriptEnd"
end
Face side, Lua hook that reads, hides, and reacts
RegisterHook("Function /Script/Altar.VHUDSubtitleViewModel:ConsumeNotification",
function(hudVM)
local hudVM = hudVM:get()
local text = hudVM.Notification.Text:ToString()
if text:match("madLevitationScriptStart") then
hudVM.Notification.ShowSeconds = 0.0001 -- hide from player instantly
ToggleFly()
return
end
if text:match("madLevitationScriptEnd") then
hudVM.Notification.ShowSeconds = 0.0001
DispelFly()
return
end
end)
Setting ShowSeconds = 0.0001 effectively hides the notification before it can appear on screen. Source: VHUDSubtitleViewModel.cpp. Live reference implementation: Levitation, UE4SS (mods/3334) version “A”.
console.ExecuteConsole() now requires a fourth true parameter to work correctly. If your console commands silently stopped working after a patch, add true as the fourth argument: Kismet:ExecuteConsoleCommand(pc.player, command, pc, true).Lua → OBScript (The Reverse Direction)
Both OBScript and UE4SS have access to Oblivion global variables. This is the primary reverse channel:
- In Lua, on your event, call:
Kismet:ExecuteConsoleCommand(pc.player, "ObvConsole set MyGlobalFlag to 1", pc, true) - An Oblivion quest script (Begin GameMode) polls that global; when it sees 1, does its thing (can fire a “silent” notification back to Lua), then resets the global to 0.
- Lua reads the return notification and continues.
UE4SS Lua, API, Patterns & Gotchas
Essential Functions
RegisterKeyBind(Key.N, fn), bind a hotkeyRegisterHook("Function /Script/Altar.Class:Method", fn), run on an engine function callNotifyOnNewObject(class, fn), react when a new object of a class spawns; the standard way to grab aVPairedPawnreference instead of pollingFindAllOf("ClassName"), get all live instances of a classStaticFindObject(path)/LoadAsset(path), find or load an assetrequire("UEHelpers"), player, controller, world, math helpersExecuteInGameThread(fn), run on the game thread (required for spawning)LoopAsync(ms, fn), repeating timer; returntrueto stop; save the handle to cancel laterFName.new("E"), construct FNames correctly. Passing a raw string where an FName is expected hard-crashes.
- TArray iteration: use
items:Get(i)and#items, NOTitems[i]. Usevalue:get()inside loops to access the real object. tostring()on a UE object can crash the game.- Post-hook doesn’t see changes a pre-hook made to local values, post-hook sees original values only.
- Debug UE4SS build needs the right
UE4SS-settings.ini, the end-user ini can crash on launch with “no debugging symbols.” - Many UE4SS mods together → CTD: a mod stable alone can crash when many UE4SS mods load together. Problem is timing/order via mods.txt/mods.json. Deferred hook system is the solution (see dotaxis’s snippet in #ue4ss-snippets).
- RefreshAppearance crashes when multiple mods call it simultaneously. Limit to only the aspect you’re changing; call it twice within your own mod.
StaticFindObject('/Script/Engine.Default__KismetSystemLibrary')is the correct way to get KismetSystemLibrary,UClass.Load(...)is not valid in this context.- LiveView can crash if left focused on an Actor too long (confirmed crash on
FakeRootDistanceInterpSpeedFactorCurve). Dump to file instead.
Deferred Hook System
Solves hooks failing when their target function isn’t loaded at Lua startup, waits until FindFirstOf succeeds. Source: #ue4ss-snippets Discord (dotaxis, May 2025). For TypeScript fans: TypeScript-to-Lua compiles TypeScript to UE4SS Lua with better type checking.
Spawning Actors from Oblivion Forms (Blueprint)
Use OblivionActorFactory → Spawn Actor from Form at Location. Pass a live TESForm reference. Spawned actor may not have its pairing set correctly but can receive calls like Jump(). Example: blueprintue.com/blueprint/fo7y5owq/.
Widget & UI Blueprint Tips
- Widget Switchers can replace Scroll Boxes if Scroll Boxes crash in packaged paks.
- Hidden widgets do not tick, put tick/keypress logic on the mod actor instead. Or use opacity 0 +
Not Hit-Testable (Self & All Children)visibility. - To get a ViewModel reference from Blueprints: find it from Lua and pass via console command or custom event.
- References: Custom Logger (Dmgvol) · Config Variables & Key Remapping
V (e.g. VInventoryMenuViewModel, VMisc, VHUDSubtitleViewModel). The central actor class for player and NPCs is AVPairedPawn / AVPairedCharacter, most character data hangs off its components. Standard UE conventions: A = Actor · U = non-actor UObject · F = struct · T = container/template · leading lowercase b = boolean.Finding Hooks
The Kein/Altar SDK repo is the hook directory. Search it on GitHub for a class/function name (e.g. repo:Kein/Altar UVActorBehaviorBase) to find the exact /Script/Altar.Class:Function path and its C++ signature. Also use the UE4SS GUI / LiveView. Use regex search: ^Function.+Cast to find hookable functions efficiently.
Console Commands from Lua (Robust Method)
local UEHelpers = require("UEHelpers")
local pc = UEHelpers.GetPlayerController()
local Kismet = StaticFindObject('/Script/Engine.Default__KismetSystemLibrary')
ExecuteInGameThread(function()
if pc:IsValid() then
Kismet:ExecuteConsoleCommand(pc.player, command, pc, true) -- true required post-June 2025
end
end)
The UE console connects to the Gamebryo console only after the game starts. Console commands work after load, not at startup. get commands via console do NOT return values to Lua, use FindFirstOf("ATMSubsystem"):GetTime() patterns for reading state.
Blueprint Function Multiple Return Values (Lua Gotcha)
BP functions with multiple output parameters return values in inconsistent positions depending on whether any output is a TArray.
-- BP function returning (string1, bool1): -- result: param1.string1, param1.bool1 -- BP function returning (TArray_strings, bool2): -- result: param1 = the TArray directly, param2.bool2 = the bool -- Rule: always regenerate Lua types via the UE4SS property viewer -- (Tools → Generate Lua Types) before calling unfamiliar functions. -- Never assume output positions blind.
Detecting Player vs NPC
actor:IsPlayerCharacter()→ returns true for the player. Alternative: NPCs have.AVPairedPawnAIController; the player doesn’t.- No clean “player death” hook exists. Pattern: hook
DoRagdoll(fires for any ragdoll, including paralyze/KO) and filter withIsPlayerCharacter()plus a real “is dead” check so KO and paralyze don’t false-trigger.
Inventory from Lua, Traps
FindFirstOf("VInventoryMenuViewModel") returns a fake/empty instance. The real one is hidden inside WBPModernPlayer. Reading the fake one gives an empty list and items with .form = 0000. Fetch inventory right after the player loads and BEFORE opening the inventory menu, opening it wipes/reshuffles the exposed list. An AddItem mod can cause an inventory wipe that breaks other mods reading the viewmodel. Struct fields: Name, Price, Weight, WeaponDamage, ArmorRating, Health, Count, Icon, Type, bIsEquiped, bIsFavorite, InventoryIndex, form, StatusFlags.Time & Fast Travel
Get current time: FindFirstOf("ATMSubsystem"):GetTime(). Enum: EATMTimeUpdateSource {FROM_INIT, FROM_TICK, FROM_GAMEPLAY, FROM_DEBUG}. To freeze time across fast travel: store the hour on OnFadeToBlackBeginEventReceived, restore on OnFadeToGameBeginEventReceived. Setting time via the property directly doesn’t persist, use set gamehour to N via console.
Magic / Enchantment Internals (Advanced)
TESSync.DynamicFormsis a TMap (~246 entries) mapping runtime FormIDs → forms (e.g.0xFF005EC7 → TESObjectCLOT /Game/Forms/items/clothing/TESObjectCLOT_2147479875). This is the way to reach dynamically-added forms at runtime.- Enchantment form chain:
UTESEnchantment → UTESMagicItemForm → UTESForm.UTESEffectSettingholdsEnchantEffect,EffectShader,CastingBlueprintClass,ProjectileBlueprintClass,AreaEffectBlueprintClass,HitEffectBlueprintClass, socket names, andGetEnchantEffectID/EffectShaderID/AssociatedItemID(). - Magic VFX bounce: you can get the magic effect that shaped a projectile (e.g.
FIDGfor fire) but not the actual spell that was cast. EVMusicTypeenum contains all music state data. No hooks have been found yet to intercept individual music state transitions reliably.
Inventory Struct Fields (Reference)
The full FOriginalInventoryMenuItemProperties struct: Name, Price, Weight, WeaponDamage, ArmorRating, Health, Count, Icon, Type, bIsEquiped, bIsFavorite, InventoryIndex, form (UTESForm*), StatusFlags, bIsInventoryItem, bIsInContainerMenu.
Built-in Debug Widgets
WBPPairedPawnDebugInfo_C, built-in AI debug widget that ships with the game. UseFindFirstOf("WBPPairedPawnDebugInfo_C")from Lua to get a live reference to NPC AI state without building your own debug overlay.- KismetDebugger, UE4SS includes this tool which works on your own custom Blueprints. You can step through your Blueprint mod logic in the UE4SS GUI while the game is running.
ESL Support
ESL (Light Plugin) format is not yet available in Oblivion Remastered. The 255 plugin limit from classic Oblivion applies (one-byte mod index, FF reserved for the save game). A separate ~512 cap covers file handles (active plugins + BSAs combined). Don’t confuse the two. Plan your mod’s FormID space accordingly.
GitHub Actions for Lua Mod Releases
A GitHub Actions template for auto-releasing UE4SS Lua mods on tag push circulates in the OBR modding Discord. This enables CI/CD: push a git tag, and your .zip is automatically built and attached to a GitHub Release. Ask in #resources for the current template.
Known Unsolved Frontiers
- frontier Spawning Niagara systems from pure Lua, not figured out.
- frontier Adding new audio for shout/Fus-Ro-Dah-style effects, effect works, custom audio doesn’t.
- frontier Chameleon visual bug, can force-remove it but it returns ~1s later 99% of the time. Effect ID:
1280133187. - frontier Multi-effect armor enchantment, backend validation blocks it despite UI hook research.
- frontier Setting weapon enchantment charges,
UVItemDetailsViewModelcan read them but setters are UI-only.
Confirmed Hook Catalog
Hooks people actually got working. Format: /Script/Altar.Class:Function unless noted. Source: community research via Kein/Altar SDK.
| Hook Path | Fires when / use for |
|---|---|
VLevelChangeData:OnFadeToGameBeginEventReceived | World finished loading, best “after load screen” hook |
VLevelChangeData:OnFadeToBlackBeginEventReceived | A fade-out starts (entering load / fast travel) |
VLevelChangeData:OnFadeToBlackOverBeforeFastTravel | Just before a fast travel resolves |
Engine.PlayerController:ClientRestart | Player (re)possessed / load finished, good init hook |
VAltarTelemetrySubsystem:OnSaveStarted | A save is starting |
VPairedPawn:OnCombatHitTaken | The “something got damaged” hook |
VPairedPawn:OnCombatHitDealt | An actor deals a hit |
VPairedPawn:DoRagdoll | ANY ragdoll (death, paralyze, knockdown). Filter with IsPlayerCharacter() |
VPairedPawn:OnWeaponChanged | Equipped weapon changed |
VPairedPawn:OnChangeActionState | Action state changes (⚠ gives a pointer, not a name, can’t filter by string) |
VHitBoxComponent:OnOverlapTriggered / StartHit | Melee hit/impact detection |
VAltarPlayerController:OnJumpPressed | Jump pressed |
VEnhancedAltarPlayerController:ToggleSneak | Sneak toggled (hookable, but calling it to drive sneak does nothing) |
VEnhancedAltarPlayerController:OnAttackRequestPressed | Attack button pressed |
VEnhancedAltarPlayerController:OnLoadFinished | Player controller load finished |
VHUDSubtitleViewModel:ConsumeNotification | The notification bridge (OBScript → Lua) |
VMagicSpellVFX:OnSpellProjectileBounce | Spell projectile bounced |
VAmmunition:OnBounce | Arrow bounced |
VActorValuesPairingComponent:OnAllActorValueChanged | A stat/actor-value changed (⚠ delegate, not plain UFunction, needs different binding method) |
VDoor:OnBeginOverlapPreLoadBox | Door pre-load trigger |
VOblivionPlayerCharacter:RequestPowerAttack | Power attack requested |
BP_OblivionPlayerCharacter_C:ReceiveTick | Per-frame tick on the player (⚠ two variants exist with/without underscore, check your build) |
BP_OblivionPlayerCharacter_C:OnEnterUnderwater | Player enters water |
WBP_LegacyMenu_Main_C:OnConfirmNewGame | New game confirmed |
WBP_Modern_MapWidget_C:OnIconHovered | Map icon hovered |
WBPModernMenuEnchantmentMenu_C:OnEffectClicked | Enchant menu effect clicked (used in multi-enchant research) |
WBP_LockPick_C:OnFocus / BreakLockpick | Lockpicking UI events |
AIModule.AIPerceptionComponent:GetPerceivedHostileActors | AI perception, who an NPC sees as hostile |
VPhysicsControllerComponent:HandleCollisionSoundOnBeginOverlap | Physics collision sounds |
VPairedPawn:OnHitReaction / OnCapsuleHit / OnDeathVFX / SendJump | Hit reactions, capsule hits, death VFX, jump events |
BPCI_StatusEffect_Light_C:OnStartPlayStatic | Light status effect start (used for spawn-light recipe) |
Engine.Actor:K2_GetActorRotation / K2_GetActorLocation / DisableInput | Common actor utilities. Rotation returns Yaw/Pitch/Roll. |
TABP_LookAt_C:EvaluateGraphExposedInputs_ExecuteUbergraph_TABP_LookAt_AnimGraphNode_AdvancedLookAt_A0FAD3894A8BA22925668A80995EC038 | Fires while NPC look-at is active. Hook to interrupt NPC head-tracking. Set K2Node_PropertyAccess to False each frame to disable. Credit: dicene. |
OBScript Reference
Custom Script Effect Names (MagicLoader LOC_FN Fix)
When you create a custom spell with a Script Effect, the name displayed in the magic menu is pulled from a localization tag. There’s a specific quirk with how OBR handles these:
- The script name in the ESP should NOT have the
LOC_FN__prefix. - But in MagicLoader’s JSON
NewStringsblock, you MUST prefix it withLOC_FN_.
Example: if your Script Effect is named RakeMoneyEnchScriptName, your MagicLoader JSON entry looks like:
{
"Plugin": "HeavyRakeRevamped.esp",
"NewStrings": {
"LOC_FN_RakeMoneyEnchScriptName": "Raking in Money!"
}
}
The name registered in xEdit should be RakeMoneyEnchScriptName (no prefix). The JSON key adds the LOC_FN_ prefix. This is the working combination as of mid-2025.
Common Script Block Types
| Block | When it fires |
|---|---|
Begin GameMode | Every quest tick (~5 seconds default). Set fQuestDelayTime to change frequency. |
Begin MenuMode [id] | While a specific menu is open. |
Begin OnEquip [actorID] | When item is equipped. Runs even on broken items; does NOT run on unequip if item breaks. |
Begin OnUnequip [actorID] | When item is unequipped. |
Begin ScriptEffectStart | Once when spell effect begins. |
Begin ScriptEffectUpdate | Fires every frame while the effect is active. For steady timing, accumulate GetSecondsPassed and gate on a threshold (frame-rate-independent). It complements this block, it doesn’t replace it. |
Begin ScriptEffectFinish | Once when spell effect ends. |
Begin OnDeath [actorID] | When scripted actor is killed. Parameter is the killer, not the target. |
Begin OnHit | When scripted actor is hit. Only works on NPCs and Creatures. |
Begin OnActivate [actorID] | When specified actor activates this object. |
Begin OnLoad | When the cell containing the object loads. |
Begin OnAdd [containerID] | When the item is added to an inventory/container. |
Begin OnDrop [actorID] | When the item is dropped from an inventory. |
Begin OnStartCombat | When the actor enters combat. |
Begin OnMagicEffectHit [effectID] | When the actor is hit by a given magic effect. |
Begin OnAlarm [Trespass/Steal/Attack/Pickpocket] | When the actor witnesses the specified crime. |
Begin OnPackageStart / OnPackageDone / OnPackageChange [packageID] | AI package lifecycle events. |
Begin OnReset | When the cell resets (3-day respawn). |
Begin SayToDone | When a forced SayTo dialogue line finishes. |
This is the commonly-used subset, not the full list. There are more block types (and trigger variants like OnTrigger/OnTriggerActor/OnTriggerMine); see the UESP CS wiki Blocktypes category for the complete reference.
OBScript Variables & Types
- Variable types:
short(integer, limited range),long(larger integer),float,ref(object reference). All must be declared before script blocks. reftype stores a reference (and can hold a base form too). It earns its keep when the form is dynamic or unknown at compile time, e.g. capturing a player-created enchantment at runtime, then acting on it later. If you already know the editor ID, skip the indirection and call the function directly:If Player.IsSpellTarget MySpell == 1 / Player.RemoveSpell MySpell / EndIf.player(base object,00000007) andplayerRef(the reference,00000014) both work in most contexts. PreferplayerRefwhen a function specifically needs a reference, or when storing/comparing in arefvar. To test whether a ref is the player, useGetIsReference player(checks the specific reference) rather thanGetIsID(checks the base form).GetItemCountcounts by base ObjectID (editor ID / FormID, or arefvar holding one), not display name. Two different items that share a display name are counted separately; only instances of the same base form stack. Use unique editor IDs for custom items (the real mod-conflict hygiene rule). Display names can collide freely with no effect on counting.
GetStage vs GetStageDone
GetStage QuestName, returns the current active stage number only.GetStageDone QuestName Stage#, returns 1 if that specific stage was ever completed, regardless of current stage. Use this when checking whether a past event happened, not just whether you're currently at a stage. Example: if stages 25 and 35 both lead to 35, but you need to check whether stage 10 was triggered at any point, useGetStageDone.
OBScript AOE Pattern (xMarker + Script Effect)
To apply an effect to all hostile NPCs near the player (since OBScript can't iterate actors directly):
; Add this ability to ALL hostile creatures in the CS.
; The script effect checks if the creature is currently fighting the player.
scn MyAuraCurseApplicatorScript
ref rSelf
Begin ScriptEffectUpdate
set rSelf to GetSelf
if ( rSelf.GetDistance PlayerRef <= 600 ) && ( rSelf.GetCombatTarget == PlayerRef )
if ( rSelf.IsSpellTarget MyDebuffSpell == 0 )
MyXMarkerRef.MoveTo rSelf
MyXMarkerRef.Cast MyDebuffSpell rSelf
endif
else
dispel MyDebuffSpell
endif
End
The xMarker (placed in the world, invisible) acts as the caster. GetCombatTarget == PlayerRef is the clean way to check if an NPC is hostile to the player. Credit: molagbal1187.
Complete Example: A Toggleable Damage Aura (4 scripts)
A full working version of the pattern above, contributed by Alexander Preit. You cast one spell on yourself to toggle it on. While you are in combat it tags every nearby enemy with a damaging ability, and each enemy drops the ability once it leaves your radius. It splits into four small scripts, each with one job.
1. The toggle spell (cast on yourself)
A self-cast spell whose ScriptEffectStart flips a quest on or off. A quest variable (AuraActive) is the on/off latch, so re-casting the spell toggles it.
scn CheatAuraToggleSpellScript
Begin ScriptEffectStart
if CheatAuraQuest.AuraActive == 0
StartQuest CheatAuraQuest
Message "Aura of death is Active"
set CheatAuraQuest.AuraActive to 1
else
StopQuest CheatAuraQuest
Message "Aura of death is dormant"
set CheatAuraQuest.AuraActive to 0
endif
end
2. The quest loop (the heartbeat)
Runs in GameMode on a one-second tick (fquestdelaytime). While you are in combat it moves an xMarker to you and casts the area spell from that marker onto your position. The marker is the caster, you are the anchor point.
scn CheatAuraQuestScript
short AuraActive
float fquestdelaytime
Begin GameMode
set fquestdelaytime to 1
if player.IsInCombat == 1
CheatMarkerRef.moveto Player
CheatMarkerRef.cast CheatAuraSpell Player
Message "Casting aura"
endif
end
3. The aura spell (per-target filter)
This is the area effect. Because it is an area effect, its ScriptEffectUpdate runs on every actor it touches, so self is each nearby actor in turn. It keeps only the ones that are not you (GetIsReference Player == 0), are currently fighting you (GetCombatTarget == Player), and are not already tagged (IsSpellTarget CheatAuraAB == 0), then adds the ability.
scn CheatAuraSpellScript
ref self
Begin ScriptEffectUpdate
set self to getself
if self.GetIsReference Player == 0
if self.GetCombatTarget == Player
if self.IsSpellTarget CheatAuraAB == 0
self.AddSpell CheatAuraAB
endif
endif
endif
end
4. The ability (the effect + self-cleanup)
The actual "aura of death" (drain, damage, whatever you design) lives on the CheatAuraAB ability you attach. Its one housekeeping job here is to remove itself once the target gets more than 1000 units from you, so anything that flees or that you outrun drops the effect on its own.
scn CheatAuraABScript
ref self
Begin ScriptEffectUpdate
set self to getself
if self.GetDistance Player >= 1000
self.RemoveSpell CheatAuraAB
endif
end
MoveTo each tick gives a moving AoE origin centered on the player without the player being the caster. GetCombatTarget == Player hits only actors actually fighting you, not neutral NPCs caught in the radius. GetIsReference Player == 0 excludes yourself (the same identity check from the player vs playerRef note above). IsSpellTarget … == 0 gates the add so the per-frame update does not re-stack the ability. And a self-removing ability (the GetDistance gate) means cleanup is automatic with no central tracking list.CheatAuraSpell has to be an area / target-location magic effect, so the engine runs its script effect on each actor in the radius. A plain single-target self-cast would only ever run on the player, and the filter would find nothing to tag. If an aura like this "does nothing," the effect's area setting is the first thing to check.Cheat Aura example courtesy of Alexander Preit.
OBScript Bugs in the Remaster
Messageformatting (%g,%.0f, etc.) does not work, the Altar interpretation layer does not support dynamic string formatting.ScriptEffectUpdatefires per frame;GetSecondsPassedis the per-frame delta. For frame-rate-independent timing:set elapsed to elapsed + GetSecondsPassed, then act whenelapsed >=your interval. (This is a timing technique, not a fix for the block failing to fire.)SetActorsAI 0called on a ref in another cell crashes the game as of update 1.1.PlayMagicEffectVisualsdoes not apply visual effects on actors.SetPos zon actors is unreliable depending on terrain height.- All OBSE functions (
HasSpell,IsKeyPressed,CloseAllMenus, etc.) do not work, OBSE64 is only a plugin loader. returninside a flatif/endifdoes not work as expected, always nest logic properly.- Quest scripts can sometimes lose their attached script in the CS. Re-attach it if behavior suddenly breaks. (Community-reported from Discord; exact cause unconfirmed.)
- ESPs created in the old CS are missing 2 parameters the Remaster requires, but this only bites if you edit Cells or Worldspaces. Fix the plugin in xEdit before use: Haphestia’s Fix and Port Script (vibrantruin) for the general record fix, or Batch Copy Altar Cell Data for cells your mod edits.
- HTML-style text formatting in books/messages still works, just with a smaller palette than classic Oblivion (see the note below). The old claim that it is fully broken is wrong.
<br> line breaks, <hr> dividers, <div align="left|center|right"> alignment, <font face="N">, and page breaks. The one real limit is fonts: the face values resolve to only about two distinct typefaces, so you can vary style a little but not freely. Correction confirmed by Alexander Preit.Useful OBScript Patterns
Toggle a spell without OBSE
If ( Player.IsSpellTarget MySpell == 0 )
Player.AddSpell MySpell
Else
Player.RemoveSpell MySpell
EndIf
Run code once on game start
scn MyOneTimeScript
Begin GameMode
player.AddItem MyCustomItem 1
StopQuest MyStartupQuest
End
Attach to a quest with Start Game Enabled checked. Quest runs once and stops itself.
ScriptEffectUpdate timer workaround
Begin ScriptEffectUpdate
set elapsed to elapsed + GetSecondsPassed
if elapsed >= 0.016
; your logic here
set elapsed to 0
endif
End
OBScript Pattern: Item Equip with Infamy Check
scn GrayFoxCuirassScript
begin OnEquip player
if ( GetPCInfamy >= 10 ) && ( GetPCFame < GetPCInfamy )
if ( GetPCInfamy < 20 )
player.addspell GrayFoxArmorEnchant01
elseif ( GetPCInfamy >= 20 )
player.addspell GrayFoxArmorEnchant02
endif
endif
end
begin OnUnequip player
player.removespell GrayFoxArmorEnchant01
end
Pattern: Menu After Inventory Closes
Opening ShowEnchantment while inventory is open freezes controls. Use a quest script waiting for GameMode:
; Item script fires on equip:
Begin OnEquip player
StartQuest MySpellMenuQuest
End
; Quest script (only runs when no menus are open):
Begin GameMode
ShowEnchantment
StopQuest MySpellMenuQuest
End
Quest & AI Notes
- Quest stages can only go forward. Enable Allow Repeated Stages for stages that need to fire multiple times.
- Quest scripts die at stage 100 /
StopQuest. Don’t mix stages into existing vanilla quests, breaks saves. FollowPlayerAI packages may not work as expected, buggy in the Remaster.PushActorAwaywith negative values may push instead of pull. Results are inconsistent.- NPCs activate furniture to use it; they don’t just play animations near it.
Console Commands for Debugging
ObvConsole set QuestName.VarName to 1 ; set a quest variable ObvConsole show QuestName.VarName ; read a single variable sqv QuestName ; dump all variables for a quest GetGlobalValue GlobalVarName ; read a global variable ToggleDebugCamera ; free camera (replaces old tfc) Altar.StandOutOblivionAsset 1 ; turn legacy assets pink Altar.Cheat.AllowSetStage 1 ; enable SetStage in console SloMo 0 ; freeze time
A community-compiled data table of all known console commands covering OBR-specific commands, native UE ccmds/cvars, original Oblivion commands, OBSE commands, and SML Dev Resource commands is available in the OBR Modding Discord resources channel. The data table includes both commands that currently work and those that potentially could work. Searching for specific cvars: try r.Lumen, r.Shadow, sg.PostProcessQuality, etc.
[/Script/ClassName] prefix in Engine.ini, this can configure C++ default values without needing Lua code at all.Ready-to-Use Lua Snippets
BP → Lua Communication: PrintToModLoader & RegisterCustomEvent
The BPML_GenericFunctions mod (included with UE4SS, may need enabling in Mods/ folder) bridges Blueprint custom events to Lua. Enable it if Blueprint → Lua communication isn’t working.
-- In your Lua mod: listen for a custom event fired by a Blueprint
RegisterCustomEvent("MyModCustomEvent", function(params)
-- params contains whatever the Blueprint passed
print("[MyMod] Got event from BP!")
end)
-- Docs: https://docs.ue4ss.com/dev/lua-api/global-functions/registercustomevent.html
In your Blueprint (ModActor), add a Custom Event node named MyModCustomEvent and call PrintToModLoader with a string to test the bridge. The PrintToModLoader custom event is provided by BPML_GenericFunctions, call it from BP with any string to print to the UE4SS console.
MCM ↔ Lua via Custom Console Commands
-- Register a custom console command in Lua (not added by MCM, but MCM can call it)
RegisterConsoleCommandHandler("MyMod.Reload", function(full, params)
-- runs when someone types "MyMod.Reload" in console, or MCM fires it
ReloadMyModSettings()
return true -- consume the command
end)
-- MCM can trigger this via its "run on close" command field
-- Reference implementation: tommn's Mad Experience mod
MCM tip from tommn: MCM cannot run code when an individual slider/toggle changes, only when the menu closes. Fire your reload logic as a console command on-close. Also: MCM cannot call Lua functions directly, but it CAN fire UE4SS-registered console commands. Credit: tommn.
Detect Weapon Type & Power Attack
-- pawn is AVPairedPawn reference
local function GetWeaponType(pawn)
local wa = pawn.WeaponsPairingComponent.WeaponActor
if wa and wa:IsValid() then
return wa.WeaponTypeTag.TagName:ToString()
end
return "HandToHand"
end
-- Example output: "Weapon.Type.Sword.One.Hand", "Weapon.Type.Bow", "HandToHand"
local powerTag = { TagName = FName("Actor.Action.Combat.Attacking.Power") }
local function IsPowerAttack(pawn) return pawn:HasGameplayTag(powerTag) end
Detect Spell Cast Type
-- Returns: 0=Self, 1=Touch, 2=Target, 3=Unknown, 4=MAX, -1=Error
local function GetSpellCastType(pawn)
local s = pawn.OblivionActorStatePairingComponent
if not s or not s.SpellCastType then return -1 end
return tonumber(s.SpellCastType)
end
Efficient Proximity Detection (SphereOverlapActors)
-- ~100x faster than iterating all actors and manually calculating distance
local playerLocation = playerPawn:K2_GetActorLocation()
local actorList = {}
UEHelpers.GetKismetSystemLibrary():SphereOverlapActors(
UEHelpers.GetWorldContextObject(), playerLocation,
500, { someClass }, someClass, { }, actorList)
for i = 1, #actorList do
local actor = actorList[i]:get()
-- do something with actor
end
Kill / NPC Death Detection
-- Check if an NPC is dead (lootable = dead in this game)
local function IsNPCDead(pawn)
if not pawn or not pawn:IsValid() then return false end
-- Check death state via actor value
local healthAV = pawn.OblivionActorStatePairingComponent
if healthAV then
return healthAV.bIsDead == true
end
return false
end
-- Detect NPCs in range and check for death (scan approach)
-- For detecting WHO killed an NPC, hook VPairedPawn:OnCombatHitDealt
-- and track the last attacker FormID against DoRagdoll events
-- Note: scanning for all nearby actors has performance cost - use a timer
local function ScanNearbyDeaths(radius)
local actorList = {}
UEHelpers.GetKismetSystemLibrary():SphereOverlapActors(
UEHelpers.GetWorldContextObject(),
UEHelpers.GetPlayer():K2_GetActorLocation(),
radius, {}, nil, {}, actorList)
for i = 1, #actorList do
local actor = actorList[i]:get()
if actor and actor:IsValid() and IsNPCDead(actor) then
-- actor is a dead NPC within range
end
end
end
-- For plants/interactive objects: look in /Game/Dev/InteractibleObjects/
-- Same SphereOverlap approach, just different blueprint class filter
Notes (credit: .yeah.nah.yeah., _weapon_): To verify you were the killer, also hook VPairedPawn:OnCombatHitDealt and track the attacking actor, compare against the ragdolling NPC via VPairedPawn:DoRagdoll. For plants and Nirnroots, use SphereOverlapActors filtering on their specific blueprint class found in /Game/Dev/InteractibleObjects/.
Spawn a Blueprint Actor at Location
local UEHelpers = require("UEHelpers")
local function SpawnBP(path, location, rotation)
local bp_path = path
if bp_path:match("^OblivionRemastered/Content/.+%.uasset$") then
bp_path = bp_path:gsub("OblivionRemastered/Content/", "/Game/")
local fn = bp_path:match("/([a-zA-Z0-9_]+).uasset")
local dir = bp_path:match("^(.+/)[a-zA-Z0-9_]+.uasset")
bp_path = dir .. fn .. "." .. fn .. "_C"
end
LoadAsset(bp_path)
local bp_class = StaticFindObject(bp_path) or CreateInvalidObject()
if not bp_class:IsValid() then return end
local loc = location or UEHelpers.GetPlayer():K2_GetActorLocation()
local rot = rotation or {Pitch=0, Roll=0, Yaw=0}
local spawned = UEHelpers.GetWorld():SpawnActor(bp_class, loc, rot) or CreateInvalidObject()
if not spawned:IsValid() then return end
spawned.Tags[#spawned.Tags + 1] = FName("ManuallySpawned")
spawned:K2_GetRootComponent():SetMobility(2) -- Movable
spawned:SetActorEnableCollision(false)
return spawned
end
RegisterKeyBind(Key.N, function()
ExecuteInGameThread(function()
SpawnBP("/Game/Dev/Creatures/BP_Generic_Flameatronach.BP_Generic_Flameatronach_C")
end)
end)
Dump Player Inventory
-- IMPORTANT: fetch BEFORE opening the inventory menu, not after
local vm = FindAllOf("VInventoryMenuViewModel")[1]
for i, item in ipairs(vm:GetInventory()) do
local d = item.DisplayName
print(d.Name:ToString(), d.Weight, d.Price, d.Count, d.bisEquiped, d.form)
end
Get All Skeletal Mesh Sockets on a Pawn
-- pawn is a valid AVPairedPawn reference
local socketCount = pawn.MainSkeletalMeshComponent.SkeletalMeshAsset:NumSockets()
print("Number of sockets: " .. socketCount)
for i = 0, socketCount - 1 do
local sock = pawn.MainSkeletalMeshComponent.SkeletalMeshAsset:GetSocketByIndex(i)
print("Socket: " .. sock.SocketName:ToString() .. " | Bone: " .. sock.BoneName:ToString())
end
Smooth Tween / Interpolation Helper
Smoothly transition a numeric value over time using an easing function. Requires the easing_functions module. Visual reference: easings.net.
local Math = require("easing_functions") -- provides Math.EasingOptions table
local CurrentTweenTask = nil
local function TweenToValue(GivenVariable, TotalDuration, StartingValue, EndingValue, EasingFunc)
local startTime = os.clock()
GivenVariable = StartingValue
CurrentTweenTask = LoopAsync(1, function()
local progress = (os.clock() - startTime) / TotalDuration
GivenVariable = EasingFunc(StartingValue, EndingValue, progress)
if GivenVariable >= EndingValue then
GivenVariable = EndingValue
return true -- stops the loop
end
return false
end)
end
-- Usage: TweenToValue(myVar, 2.0, 0.0, 100.0, Math.EasingOptions.EaseLinear)
Full Notification Bridge (Complete Pattern)
-- OBScript side: message "MY_SIGNAL" from any script block
-- Lua side: intercept, hide, react
RegisterHook("Function /Script/Altar.VHUDSubtitleViewModel:ConsumeNotification",
function(hudVM)
local vm = hudVM:get()
local text = vm.Notification.Text:ToString()
if text:match("MY_UNIQUE_SIGNAL") then
vm.Notification.ShowSeconds = 0.0001 -- hide from player
-- do whatever you want here
return
end
end)
-- Lua -> OBScript reverse:
-- Kismet:ExecuteConsoleCommand(pc.player, "ObvConsole set MyFlag to 1", pc, true)
-- Quest script checks: if (MyFlag == 1) ... set MyFlag to 0 ... endif
Deferred Hook System (Hook Not Yet Available)
When a hook isn’t available at script startup (actor not yet spawned), use this pattern to defer registration until it exists:
-- Waits until FindFirstOf succeeds, then registers the hook.
-- Prevents scripts failing when their target isn't available at load time.
local function DeferHook(className, hookPath, callback)
local task = LoopAsync(100, function()
local obj = FindFirstOf(className)
if obj and obj:IsValid() then
RegisterHook(hookPath, callback)
return true -- stop polling
end
return false
end)
end
-- Usage:
DeferHook("VMyTargetClass",
"Function /Script/Altar.VMyTargetClass:OnSomeEvent",
function(self) print("hook fired") end)
Spawn a Blueprint Actor via Lua
local UEHelpers = require("UEHelpers")
local function SpawnBP(bpPath, location, enableCollision, simulatePhysics)
-- Convert FModel path to UE internal path
if bpPath:match("^OblivionRemastered/Content/.+%.uasset$") then
bpPath = bpPath:gsub("OblivionRemastered/Content/", "/Game/")
local name = bpPath:match("/([a-zA-Z0-9_]+).uasset")
bpPath = bpPath:match("^(.+/)[a-zA-Z0-9_]+.uasset") .. name .. "." .. name .. "_C"
end
LoadAsset(bpPath)
local bp_class = StaticFindObject(bpPath) or CreateInvalidObject()
if not bp_class:IsValid() then print("Could not find: " .. bpPath) return end
location = location or UEHelpers.GetPlayer():K2_GetActorLocation()
local spawned = UEHelpers.GetWorld():SpawnActor(bp_class, location, {Pitch=0,Roll=0,Yaw=0})
if spawned:IsValid() then
spawned:SetActorEnableCollision(enableCollision or false)
if spawned:K2_GetRootComponent():IsValid() then
spawned:K2_GetRootComponent():SetMobility(2)
end
end
return spawned
end
-- Example: spawn a flame atronach 300 units in front of player
RegisterKeyBind(Key.N, function()
ExecuteInGameThread(function()
local fwd = UEHelpers.GetKismetMathLibrary():GetForwardVector(
UEHelpers.GetPlayer():K2_GetActorRotation())
local loc = UEHelpers.GetKismetMathLibrary():Add_VectorVector(
UEHelpers.GetPlayer():K2_GetActorLocation(),
UEHelpers.GetKismetMathLibrary():Multiply_VectorInt(fwd, 300))
SpawnBP("/Game/Dev/Creatures/BP_Generic_Flameatronach.BP_Generic_Flameatronach_C", loc, true)
end)
end)
Print All Items in Player Inventory
local function DumpInventory()
local vms = FindAllOf("VInventoryMenuViewModel")
if #vms == 0 then print("Open inventory first"); return end
local items = vms[1]:GetInventory()
for i, item in ipairs(items) do
local p = item.DisplayName
if p then
print(string.format("[%d] %s | Weight:%.1f | Value:%d | Count:%d",
i, p.Name:ToString(), p.Weight, p.Price, p.Count))
end
end
end
-- Must be called while inventory menu is open
Combat, Damage & Mounted-State Patterns
Hard-won UE4SS patterns from building real native damage and mounted combat (the first-of-its-kind mod). Full narrative in How They Did It → Mounted Combat; the reusable facts are here.
UGameplayStatics::ApplyDamage is a complete no-op (fires clean, does nothing). Actor-value setters by guessed name (SetHealth, DamageHealth…) do not exist. OnCombatHitTaken / OnCombatHitDealt take a PairedOblivionHitEvent struct that UE4SS cannot marshal a Lua table into, not invocable.Working Damage: SendMeleeHitOnPairedPawn
Discovered by walking the pawn’s class hierarchy with reflection (ForEachFunction). It is the game’s own paired-pawn melee entry and routes through native combat, real damage, hit reaction, death, and loot, no struct to fabricate. Call it on the rider (BP_OblivionPlayerCharacter_C), not the horse.
-- third arg = knockdown/ragdoll flag
-- pcall does NOT catch native SEH (EXCEPTION_ACCESS_VIOLATION).
-- The target can be GC'd between your check and the call, so re-validate
-- BOTH objects immediately before ProcessEvent fires:
if IsValidObj(rider) and IsValidObj(target) then
rider:SendMeleeHitOnPairedPawn(target, DAMAGE_AMOUNT, false)
end
Direct Health via Actor Value (fallback / guaranteed kill)
Health is actor value index 10 on this build, on ActorValuesPairingComponent. Use as a fallback or to guarantee the kill if native damage is resisted/scaled. The working setter here is SetFloatModifiedActorValue (probe order if unsure: SetFloatModifiedActorValue → ModifyActorValue → SetActorValue → ForceSetActorValue → SetBaseActorValue).
local avc = pawn.ActorValuesPairingComponent local hp = avc:GetFloatModifiedActorValue(10) -- 10 = Health avc:SetFloatModifiedActorValue(10, math.max(0, hp - DAMAGE_AMOUNT))
pawn.bIsDead returns a number (0 or 1), so if pawn.bIsDead == false then is always false even when the pawn is alive. Use == 0 for alive / == 1 for dead, or drop the alive filter and guard with IsValidObj instead.Stable Mounted-State Detection
Do not use VLocomotionHorseRiderAnimInstance for mount state, it is created/destroyed every few ticks, producing 100+ phantom mount/dismount events per minute. The stable signal is the horse’s rider reference: if any BP_Generic_Horse_C has a rider that passes IsPlayerCharacter(), you’re mounted. Remember: when mounted, player.Pawn is the horse; the rider is a separate BP_OblivionPlayerCharacter_C pawn.
local function IsPlayerMounted()
local ok, horses = pcall(function() return FindAllOf("BP_Generic_Horse_C") end)
if not ok or not horses then return false end
for _, h in ipairs(horses) do
if IsValidObj(h) then
for _, prop in ipairs({ "Rider", "RiderPawn", "RiderActor", "MountedActor", "Driver" }) do
local rok, rider = pcall(function() return h[prop] end)
if rok and IsValidObj(rider) then
local pok, isP = pcall(function() return rider:IsPlayerCharacter() end)
if pok and isP then return true end
end
end
end
end
return false
end
Weapon Attach: SnapToTarget, Not KeepWorld
When re-attaching a weapon (WeaponsPairingComponent.WeaponActor) to a socket, KeepWorld (rule 2,2,2) freezes whatever world offset exists at attach time, so the weapon floats off the hand during swings. SnapToTarget (rule 0,0,0) adopts the socket transform exactly and rides the hand bone every frame. Hand socket: Weapon_Socket; one-handers sheathe to SideWeapon_Socket, two-handers to BackWeapon_Socket.
weaponActor:K2_AttachToComponent(riderMesh, FName.new("Weapon_Socket"), 0, 0, 0, false)
Key.OEM_PERIOD (.), Key.OEM_COMMA (,), Key.OEM_SEMICOLON (;). Using Key.COMMA passes nil to RegisterKeyBind (“no overload found”). Also: Lua closures inside ExecuteWithDelay/LoopAsync can hold stale UObject references after a delay, always re-fetch the pawn/animInst inside the delayed callback rather than relying on the captured value.0 in mods.txt does not prevent its Lua scripts from loading and hooking, they still execute. To stop a crashing mod you must delete its folder entirely. (A “disabled” PlayerCamera mod calling IsVisible on a null object caused phantom per-attack crashes throughout mounted-combat development.)Useful Classes & Structs Reference
| Class | Purpose / Key Methods |
|---|---|
VPairedPawn | Base pawn for all characters. WeaponsPairingComponent, OnCombatHitTaken, DoRagdoll, IsPlayerCharacter() |
VPairedCharacter | Character subclass. Has VHumanoidHeadComponent, VHumanoidHeadAnimBP |
VActorValuesPairingComponent | Attribute values (Strength, Health, etc.). OnAllActorValueChanged delegate. |
VHumanoidHeadComponent | Facial expressions/emotion. Search “Emotion” in class dump. |
VEnhancedAltarPlayerController | Player controller. OnLoadStarted, OnLoadFinished, OnAttackRequestPressed, ToggleSneak. |
VLevelChangeData | Level transitions. OnFadeToGameBeginEventReceived, the primary “load screen done” hook. |
VAltarUISubSystem | UI subsystem. GetInventoryHoveredActor, menu access. |
VInventoryMenuViewModel | Inventory menu data. Contains item arrays. See gotchas for fake-instance trap. |
VHUDSubtitleViewModel | HUD message notifications. The notification bridge key hook. |
UTESMagicItemForm | Magic item / spell form. FullName, EffectSettings TArray. |
VOblivionGameInstanceSubSystem | Game instance subsystem. Persistent across level loads. |
UVTESObjectRefComponent | Object reference component. FormIDInstance (uint32), TESForm*, GetHexFormRefID(). |
UE Config Variables (Engine.ini Blueprint Settings)
You can configure Blueprint variables without UE4SS by editing Engine.ini. This is an alternative to lua polling, set defaults that are read at startup with zero runtime cost. Useful for modder-facing config files.
File location: C:\Users\USERNAME\Documents\My Games\Oblivion Remastered\Saved\Config\Windows\Engine.ini
Format for a Blueprint in the base game folder:
[/Game/PathToYourBlueprint/BP_BlueprintName.BP_BlueprintName_C] Variable=Value
Format for a Blueprint in a content-only plugin (Alpakit mod):
[/PluginName/PathToYourBlueprint/BP_BlueprintName.BP_BlueprintName_C] Variable=Value
/Game/ for game folder or /PluginName/ for plugin. Example for a debug toggle:[/Game/Forms/items/ammo/BP_Daedric_BoundArrow_Equipped.BP_Daedric_BoundArrow_Equipped_C]
bGlobalDebug=TrueStructs can also be set this way:
[/Game/PathToYourBlueprint/BP_BlueprintName.BP_BlueprintName_C] MyStruct=(MyStructProperty1=Value1,MyStructProperty2=Value2)
Arrays of structs are also supported, useful for storing per-actor data accessible by your Blueprint without polling. The [/Script/ prefix is specifically for C++ classes.
Object Browsers & Reference Resources
Mod How-Tos
How They Did It
First-person build logs from mod developers, the working architecture and the dead ends, so the next person doesn’t spend a day rediscovering them. Each entry is a real shipped feature, documented by the person who actually built it.
- Mounted Combat in Oblivion Remastered, by CosmicBoogaloo / DreamEater
- Custom Standalone Wwise Audio (ZA WARUDO), by CosmicBoogaloo / DreamEater
- Better Horse Control, by CosmicBoogaloo / DreamEater
- CosmicMCM: One Settings Menu for Every Mod, by CosmicBoogaloo / DreamEater
Mounted Combat in Oblivion Remastered
A first-of-its-kind feature build. By CosmicBoogaloo / DreamEater · shipping version v10.10.8 (with the SafeMeleeHit C++ crash-guard companion, Lessons 9-10).
Oblivion Remastered ships with no mounted combat, you can’t attack from horseback. Adding it isn’t a content problem, it’s an animation-graph and asset-loading problem, and almost every “obvious” approach fails for a non-obvious reason. This is the path that actually worked.
SendMeleeHitOnPairedPawn) is undocumented, found by reflecting over the pawn’s class. Lesson 7 below has the full answer so nobody has to brute-force it.Why It’s Hard: The Mounted Animation Graph
When you mount, the player mesh (BP_OblivionPlayerCharacter_C, running ABP_Reverse_ThirdPerson_Humanoid) links in the horse-riding layer, TABP_HorseRiding_Ground via the ALI_HorseRiding interface. The critical fact:
The four slots you’d normally play montages on, UpperBody, LowerBody, Sequencer, FullBody, live in TABP_ReverseCharacter (the template). And here’s the trap:
UpperBodyslot feeds the maskedLowerAndUpperBodyLayer, which lives in the CombatPose branch, the branch the riding layer bypasses. So a montage onUpperBodywhile mounted plays successfully but is never evaluated, you see nothing.PlaySlotAnimationAsDynamicMontagereturns no error, which is what makes this so confusing to debug.FullBodyslot (bAlwaysUpdateSourcePose = true) feeds the masterFullBodyPosecache and wraps the final assembled body, including the riding output. So it is visible while mounted, but a normal standing attack played here carries an absolute standing pelvis, which replaces the seated pelvis and launches the rider into a standing pose above the saddle (~66-unit pop).
The spine-mask LayeredBlendPerBone that normally keeps attacks upper-body-only is inside that bypassed CombatPose branch, so slot-based masking does not work while mounted. The only slot that’s visible mounted is full-body and unmasked.
The Animation Solution: Additive, Leg-Stripped, on FullBody
The way out is to stop relying on the slot/mask and use additive animation math instead:
- Strip the lower-body tracks (pelvis + legs) from your attack clips.
- Set
Additive Anim Type = AAT_RotationOffsetMeshSpace(mesh space, won’t twist when the horse turns). - Set Base Pose Type to a standing one-hand idle frame, not Skeleton Reference Pose. Ref pose bakes the arms-down-from-T-pose offset into the delta and distorts the swing on a seated body. A standing idle base makes the delta the actual swing motion. Clips with extreme torso lean (e.g. axe power attacks) may need the seated riding-idle sample as the base instead.
- Play on the
FullBodyslot viaPlaySlotAnimationAsDynamicMontage.
Why this works: an additive montage on a slot adds its per-bone delta to the source pose instead of replacing it. The riding layer seats the legs; the stripped clip has zero leg keys → zero leg delta → legs stay seated. The upper body has keys → the swing plays. The bypassed mask is irrelevant because you’re not using it. This is the same mechanism the game uses for hit-reactions and aim-offsets over locomotion.
The Real Wall: Loading New-Path Assets
This is where the time goes. Your custom animations live at a new path (/Game/Mods/MountedCombat_P/...) that nothing in the game references, and that breaks loading in ways specific to the Altar UE4SS build:
LoadAssetis registry-gated. It resolves through the asset registry, so it silently does nothing for unregistered new-path mod assets, runs clean, loads nothing.StaticLoadObjectdoes not exist in the Altar UE4SS build. Calling it at the main menu, before a world exists, hard-crashes with nothing logged.- Your standalone item mods load fine because TesSyncMapInjector maps a FormID → asset and the game loads it on use. AnimSequences have no FormID, so TesSyncMap can’t load them directly.
The loader Blueprint (the fix)
Make a Blueprint that BPModLoaderMod spawns, and give it hard references to your animations. When the BP loads, the refs are in memory.
- The actor must be named
ModActor, that’s what BPModLoaderMod spawns. - Add object-reference variables (
Anim1,Anim2, …), type Anim Sequence (object reference, not soft), and set each default value to one of your sequences. - Verify with Reference Viewer that ModActor points at every clip. A BP that merely exists anchors nothing, the references are the entire point.
BPModLoaderMod Conventions (the costliest gotchas)
| Rule | Symptom if violated |
|---|---|
The pak file name must match the content subfolder holding ModActor. BPModLoaderMod looks for /Game/Mods/<PAK_NAME>/ModActor. | ModClass for <name> is not valid, ModActor never spawns. (Pak named MountedCombat, folder MountedCombat_P → it looks in the wrong folder.) |
| Hard refs go on ModActor itself, not a separate loader BP. BPModLoaderMod only spawns ModActor. | A separate BP_*Loader is never touched; its refs never fire. |
| Put ModActor + the anims in ONE pak in LogicMods. | Splitting BP→LogicMods and anims→~mods creates a cross-pak dependency that IO Store fails to resolve silently, ModActor spawns, but the anim refs in the other pak never come with it. |
SK_HumanoidFull as a resource to anchor the skeleton chain).Build-specific quirk: read refs OFF the actor, not by path
On the Altar build, two things bit: hard-ref variable defaults did not reliably auto-pull dependencies the way standard UE cooking would, and GetName() returns blank on object references read back from BP variables. The robust pattern: don’t search for the anims by path at all, read them straight off the spawned ModActor’s variables. If ModActor holds the reference, the object is in memory by definition. Identify each clip by its full name (GetFullName), since GetName is blank, with a positional fallback.
local function HarvestFromLoader()
local actor = FindFirstOf("ModActor_C")
if not actor or not actor:IsValid() then return end
for i = 1, 8 do
local v
pcall(function() v = actor["Anim" .. i] end)
if v and v:IsValid() then
local fn = v:GetFullName() -- GetName() is blank on this build
for role, shortName in pairs(SEQ) do
if fn:find(shortName, 1, true) then seqCache[role] = v end
end
end
end
end
Cooking & Packing
- Cook with Cook Content for Windows (UE 5.3.2), not Package Project. Cooked output:
Saved\Cooked\Windows\OblivionRemastered\Content\Mods\MountedCombat_P\. - Repack with retoc from the
OblivionRemasteredroot (not deeper) so internal paths stay intact:retoc.exe to-zen --version=UE5_3 "...\OblivionRemastered" "MountedCombat_P.utoc" - Place the trio (
.pak+.ucas+.utoc, all three, same base name) in...\Content\Paks\LogicMods\, named to match the content folder.
The Lua Side: Playback, Isolation, Targeting
Detect mounted state, and this is one that changed late. The obvious signal, the rider anim instance, turned out to be unusable:
FindFirstOf(...):IsValid() sees mounted → not mounted → mounted several times a second. That produced 100+ spurious mount/dismount events in a few minutes and the horse-exclusion cache never settled.The stable signal is the horse’s rider reference: if any BP_Generic_Horse_C has a rider property that IsPlayerCharacter(), you’re mounted. (Same rider-prop scan the HorseSpeedBoost mod uses.)
local function IsPlayerMounted()
local ok, horses = pcall(function() return FindAllOf("BP_Generic_Horse_C") end)
if not ok or not horses then return false end
for _, h in ipairs(horses) do
if IsValidObj(h) then
for _, prop in ipairs({ "Rider", "RiderPawn", "RiderActor", "MountedActor", "Driver" }) do
local rok, rider = pcall(function() return h[prop] end)
if rok and IsValidObj(rider) then
local pok, isP = pcall(function() return rider:IsPlayerCharacter() end)
if pok and isP then return true end
end
end
end
end
return false
end
Target the rider, not the horse. When mounted, player.Pawn is the horse. The rider is BP_OblivionPlayerCharacter_C; play montages on its mesh’s anim instance.
Weapon grip: attach with SnapToTarget, not KeepWorld. The weapon (WeaponsPairingComponent.WeaponActor) lagged/floated off the hand during swings. Cause: K2_AttachToComponent with the KeepWorld rule (2,2,2) froze whatever world offset the weapon had at attach time. Switching to SnapToTarget (0,0,0) makes it adopt the socket transform exactly and ride the hand bone every frame:
wa:K2_AttachToComponent(mesh, FName.new("Weapon_Socket"), 0, 0, 0, false)
The skeleton’s Weapon_Socket sits under a Weapon_Anchor bone under hand_r; SnapToTarget resolved the float without touching the anchor chain. Sheathe to the right socket by weapon type: one-handers to SideWeapon_Socket, two-handers to BackWeapon_Socket (detect via WeaponActor.bIsTwoHanded / WeaponType == 2, with a weapon-name keyword fallback). On dismount, stop the FullBody slot so a mounted swing can’t bleed into ground animation:
animInst:StopSlotAnimation(0.15, FName.new("FullBody"))
Hit Detection & Damage, The Real Answer
Because attacks are custom montages played outside the normal combat action state, the game’s own melee detection doesn’t run, so it’s done in two layers.
Layer 1, sound/impact (reuse the game). Add notify states to the attack clips at the contact frame: VAnimNotifyState_ImpactSystem (impact FX + sound) and VAnim_ActionMeleeHitWindow (the game’s damage window). These normally fire inside the combat action state; outside it they’re best-effort. Damage does not depend on them.
Layer 2, extended-range detection (Lua, full control). Mounted, the player is elevated and the game’s melee reach overshoots ground enemies. Run a proximity check at the swing’s contact moment (~260ms after the swing starts) with a radius you control. Two details that mattered:
- Measure from the rider mesh, not the rider actor. When mounted, the rider actor’s location is stale (it’s attached to the horse). Use
mesh:K2_GetComponentLocation()as the sphere origin. - The check is omnidirectional, pure distance, no facing cone. Anything within
HIT_RADIUS(450u) gets hit regardless of camera direction.HIT_RADIUSis the mounted hitbox size.
Exclusion is belt-and-suspenders: skip the player, the loader, the specific horse you’re riding (cached by full name on each mount event), and any horse at all if the cache hasn’t resolved yet, so you never damage your own mount.
Damage application, the real answer
This was the single hardest part, and every standard path is a dead end in OBR. What was ruled out, so nobody repeats it:
| Tried | Result |
|---|---|
UGameplayStatics::ApplyDamage(target, amt, pc, causer, nil) | No-op. Fires clean, does nothing. OBR pawns don’t route through UE’s TakeDamage (confirmed by hitting the player with it every swing for 30s, never died). |
Actor-value setters by guessed name (GetHealth, SetHealth, DamageHealth…) | Don’t exist. Reading returns a UObject wrapper but calling them errors. |
OnCombatHitTaken / OnCombatHitDealt with a Lua-table struct | Not callable. Take one PairedOblivionHitEvent{Attacker, Target} struct; UE4SS won’t marshal a Lua table into it, and even a captured live struct replayed returns false, they’re engine notifications, not invocable functions. |
DoRagdoll() | Callable but no visible effect; not a damage path. |
The function that actually works, found by enumerating the pawn’s class with UE4SS reflection (ForEachFunction walking the class hierarchy), is:
rider:SendMeleeHitOnPairedPawn(targetPawn, damage, false), the game’s own paired-pawn melee entry. Called on the rider (BP_OblivionPlayerCharacter_C) with the target pawn, it routes through native combat: real damage, hit reaction, death, and loot, no struct to fabricate.Backup HP write via the actor-value component. As a fallback (and to guarantee the kill if native damage is resisted/scaled), read and reduce Health directly. Actor-value index 10 = Health on this build:
local avc = pawn.ActorValuesPairingComponent local hp = avc:GetFloatModifiedActorValue(10) -- 10 = Health avc:SetFloatModifiedActorValue(10, math.max(0, hp - DAMAGE_AMOUNT))
The shipping code probes for the correct setter once (SetFloatModifiedActorValue → ModifyActorValue → SetActorValue → ForceSetActorValue → SetBaseActorValue), caches whichever succeeds, and reuses it. SetFloatModifiedActorValue is the one that works here.
Crash hardening (learned the hard way)
SendMeleeHitOnPairedPawn runs through ProcessEvent, and pcall does not catch native SEH exceptions. An EXCEPTION_ACCESS_VIOLATION writing 0x0 came from the target pawn being garbage-collected between the safety check and the actual UFunction call. The fix is a redundant IsValidObj() on both rider and target immediately before the call:
if not IsValidObj(rider) or not IsValidObj(pawn) then return end rider:SendMeleeHitOnPairedPawn(pawn, DAMAGE_AMOUNT, false)
Also: a hitThisSwing table keyed by pawn dedupes so one swing applies damage once per target, and a separate unrelated mod (PlayerCamera) was crashing on every attack input via IsVisible on a null object, fully remove that folder, disabling it in mods.txt isn’t enough since its scripts still load and hook attack input.
Failure → Cause → Fix (the whole journey at a glance)
| Symptom | Real cause | Fix |
|---|---|---|
| UpperBody montage plays, nothing visible while mounted | Riding layer bypasses the CombatPose branch the UpperBody slot lives in | Use FullBody slot instead |
| Rider stands up / pops ~66u on attack | Non-additive full-body clip overrides seated pelvis | Make clips additive + strip legs |
| Swing distorted/offset | Additive base pose = Skeleton Reference Pose (T-pose) | Base pose = standing one-hand idle frame |
LoadAsset runs, asset never in memory | New-path asset unreferenced; LoadAsset is registry-gated | Hard-reference it from a loader BP |
| Boot crash, log ends mid-load | StaticLoadObject called at menu (and it’s absent in this build) | Remove it; load via loader BP, read off the actor |
ModClass not valid, ModActor never spawns | Pak name ≠ content folder name | Rename pak to match /Game/Mods/<folder>/ |
| ModActor spawns, anims still missing | Refs on a separate BP, or split across paks | Refs on ModActor; bundle everything in one LogicMods pak |
| ModActor refs read as valid but unidentified | GetName() blank on this build | Match by GetFullName, positional fallback |
| Hits don’t register from horseback | Custom montage skips the game’s melee detection; reach overshoots | Lua proximity detection at a tuned radius, measured from the rider mesh |
| Damage never applies (ApplyDamage, AV setters, OnCombatHit*) | OBR uses none of the standard UE damage paths | rider:SendMeleeHitOnPairedPawn(target, dmg, false) + AV index-10 HP write |
| Weapon floats off the hand during swings | K2_AttachToComponent used KeepWorld (froze a bad offset) | Attach with SnapToTarget (0,0,0) |
| 100+ phantom mount/dismount events; horse cache never settles | VLocomotionHorseRiderAnimInstance is created/destroyed every few ticks | Detect via horse’s rider ref + IsPlayerCharacter() |
EXCEPTION_ACCESS_VIOLATION writing 0x0 on hit | Target GC’d between safety check and ProcessEvent; pcall can’t catch native SEH | Redundant IsValidObj() on both objects immediately before the call |
| Game crashes on every attack press | Unrelated PlayerCamera mod calls IsVisible on null | Delete the PlayerCamera folder (disabling in mods.txt isn’t enough) |
Key takeaways
- The mounted graph bypasses the combat slots and the spine mask, additive-on-FullBody is the only path to seated upper-body attacks.
- New-path animations need a hard-reference anchor (a
ModActorloader BP) to load at all;LoadAssetwon’t andStaticLoadObjectdoesn’t exist here. - BPModLoaderMod’s contract is strict: pak name = content folder name, refs on ModActor, one bundled pak.
- When the engine won’t resolve assets by path, read them off the spawned actor’s variables instead.
- Damage in OBR is
SendMeleeHitOnPairedPawnon the rider, not any UE damage path, with actor-value index 10 = Health as the direct fallback. Found by reflecting over the pawn’s class, not by guessing. - Don’t trust the rider anim instance for mount state, it flaps. Use the horse’s rider reference.
pcallcan’t catch native SEH crashes, re-validate UObjects immediately before anyProcessEvent-routed call.
Tools relied on: UE4SS + BPModLoaderMod, retoc, UAssetGUI, the Altar SDK (UE 5.3.2). Built on community research documented throughout this wiki., CosmicBoogaloo / DreamEater Studio
The Last 1%: A C++ Crash-Guard for the Native Call
pcall can’t catch a native SEH · MC_SafeCall wraps it in __try/__exceptLesson 7 ends on an honest admission: even after skipping dead targets and re-validating components, the native SendMeleeHitOnPairedPawn can still fault on a same-frame death race (a component reachable from the target is freed mid-call). That fault is a CPU access violation (inc dword ptr [rax] on a garbage pointer), and Lua’s pcall cannot catch it. pcall catches Lua errors; a native SEH unwinds past Lua entirely. The only complete fix is a tiny C++ UE4SS mod that wraps the call in an OS structured-exception handler.
pcall Is the Wrong Tool for a Native FaultA Lua pcall only traps errors raised by Lua. An access violation from native game code is a hardware/OS exception (SEH) that blows straight through the Lua VM. No amount of Lua validation closes the window completely. You have to catch it in C++ with __try/__except.SafeMeleeHit is that companion: a minimal C++ UE4SS mod (built from the CppUserModBase template). On Lua start it registers exactly one global Lua function, MC_SafeCall(fn), which invokes the passed Lua function inside a native __try { fn() } __except(…) { }. If fn triggers an access violation, the __except swallows it: the swing’s hit fizzles (no damage that frame) instead of taking the whole process down.
How the Lua side calls it (looked up fresh, so load order doesn’t matter)
local SafeCall = rawget(_G, "MC_SafeCall") -- nil if the C++ mod isn't installed
local function fireMelee()
rider:SendMeleeHitOnPairedPawn(pawn, DAMAGE_AMOUNT, false)
end
local ok
if SafeCall then ok = SafeCall(fireMelee) -- SEH-protected by the C++ companion
else ok = pcall(fireMelee) -- Lua-only fallback, can still CTD on the race
end
if not ok then
print("[MC] melee call faulted and was contained (no damage this swing)")
return
end
It’s a separate, optional mod. If present, MC_SafeCall exists and Mounted Combat routes the dangerous call through it; if absent, it falls back to pcall and the load line prints companion: not found. That’s why it’s fetched with rawget(_G, "MC_SafeCall") on every call, neither the presence nor the load order of the two mods is ever assumed.
pcall can never provide.The v10.10.x Reliability Pass: Detection, Friendly Fire, Two-Call Damage
Three bugs surfaced once other players (not just the dev’s own test horse) ran the mod. All three trace back to the same discipline: read class names and properties (safe pointer reads) and never call GetName/GetFullName/UFunctions on world NPC pawns (those SEH-crash).
1. Mount detection that works on any horse
The old detector scanned one hard-coded BP_Generic_Horse_C class, then probed five guessed rider-reference property names (Rider, RiderPawn, RiderActor, MountedActor, Driver), none ever confirmed to exist. It worked on the dev’s test horse and failed for everyone on any other horse. The rewrite uses the method already proven in Better Horse Control: while mounted, GetPlayer() (and the controller’s Pawn) is the horse pawn, and its PairedPawnMovementComponent class name contains "HorseMovement" for any horse, vanilla, unique, reskinned, or modded. No class guessing, no property probing.
local ok2, cls = pcall(function() return mc:GetClass():GetFName():ToString() end)
if ok2 and cls and cls:find("HorseMovement") then return pawn end -- it's the ridden horse
2. Stop damaging your own horse
You sit on the horse, horses are VPairedPawn subclasses, so the ridden horse shows up in the FindAllOf("VPairedPawn") swing scan as the closest target, and was taking the hit (the infamous “damage only hit the horse” report). Fix: exclude by class name containing "horse" (safe to read; class names don’t SEH like NPC GetFullName), applied both in target selection and as a hard final guard in SafeToHit right before the native call. Identity checks via GetAddress() are kept as belt-and-suspenders. Bonus: damaging the ridden horse, whose mounted paired-pawn state is itself unstable (it flaps ~2×/sec), was also a likely crash. One fix closed two bugs.
3. Damage needs BOTH calls, together
Neither call alone lands damage. SendMeleeHitOnPairedPawn registers the hit event; the actor-value write (SetFloatModifiedActorValue(10, hp)) sets the new HP, but the write only persists because the melee call created the hit event first. A mid-cycle build (v10.10.1) dropped the AVC write on a wrong “it’s read-only” assumption and damage silently vanished; v10.10.4 restored the pairing. Health is actor value index 10 on the ActorValuesPairingComponent.
:IsValid() / GetClass(), safe on any thread and on stale wrappers), and never call GetName/GetFullName or arbitrary UFunctions on a world NPC pawn. The authoritative mount flag g_mounted is a plain Lua bool written only on the game thread, so keybind and async contexts read it without ever touching a UObject, which is what killed the off-thread ProcessEvent crashes the old generation-counter machinery kept fighting.Custom Standalone Wwise Audio (ZA WARUDO)
Press , to freeze time + play a brand-new custom sound, press again to resume. By CosmicBoogaloo / DreamEater · Altar build, UE 5.3.2 · Wwise 2023.1.8.8601.3258.
This documents getting a brand-new Wwise sound (not a vanilla replacement) to play in the packaged game. The community wiki called this a “research wall.” It is solvable. The bank was never the hard part, the post method was.
PostEvent/PostOnActor/PostOnComponent hit a delegate wall). The winner: UAkGameplayStatics.PostEventAtLocation(event, location, rotation, world), the one exposed method with a signature UE4SS Lua can actually call (no blocking delegate). It returns a valid playing ID and plays the custom bank’s in-memory media. If you only read one thing, read that.Wwise Integration From Scratch
bank version X vs runtime 150. Install via the Audiokinetic Launcher.Integrate into the Altar project
- In the Altar project, delete the existing
WwiseandWwiseNiagaraplugin folders. - Audiokinetic Launcher → Integrate Wwise into Project.
- Fix engine source compile errors (Altar stubs reference outdated Wwise class names):
Source/Altar/Public/VAltarAkPortalComponent.h→ change include to"AkAcousticPortal.h", parent classUAkPortalComponent.Source/Altar/Public/VMusicPlayer.h→ change include from"EAkCallbackType.h"to"AkGameplayTypes.h"(whereenum class EAkCallbackTypelives in 2023.1.8).
- Null-guard the listener crash in
Plugins/Wwise/Source/AkAudio/Private/AkAudioDevice.cpp,AddDefaultListener()crashes writingIsListener=trueon a null listener. At the top of the function add:if (in_pListener == nullptr) return; auto* SoundEngineCheck = IWwiseSoundEngineAPI::Get(); if (SoundEngineCheck == nullptr) return;
Also guardSetSpatialAudioListenerwith aSpatialAudioCheck. - Build from the command line (the VS GUI threw “invalid pointer”):
Build.bat AltarEditor Win64 Development -project="...\OblivionRemastered.uproject" -waitmutex
Target name:AltarEditor; uproject:OblivionRemastered.uproject.
Authoring the Sound in Wwise
Project setup
- Wwise project migrated to 2023.1.8. Two SFX sources (
timestart,timestop) in the Actor-Mixer Hierarchy underDefault Work Unit. - Two user-defined SoundBanks (
TimeStart,TimeStop), plus the auto-definedInitbank. - Both sounds route to the default Master Audio Bus.
MissingPlugin: Bank 'Init' includes a 'Wwise Motion' Audio Device ... not installed. WwiseConsole failed with error 1. Because generation failed, banks never updated, every “regeneration” silently produced nothing usable, which is why the event’s media never populated.Fix: delete all Motion references and save the project: Audio Devices → Default Work Unit → delete Default_Motion_Device; Master-Mixer Hierarchy → delete Motion Factory Bus; Actor-Mixer Hierarchy → delete the Factory Motion tree if present. The unsaved-changes asterisk means generation still reads the old on-disk state. After removal, generation completes: Process completed successfully.
In-memory media (self-contained banks)
Keep media in-memory (Stream unchecked). The bank then bakes the audio directly into its DATA chunk, no separate streamed .wem dependency at runtime. A correct bank parses like this:
BKHD Bank Version: 150 Bank ID: 3886797186 DIDX media id=32147691 size=478616 DATA 478616 bytes of embedded media HIRC Event -> Action -> Sound -> (media 32147691, bus 3803692087)
3803692087 is the FNV-1 hash of “Master Audio Bus”, the standard ID the vanilla game’s master bus also uses, so the sound routes correctly with no GUID spoofing needed.
Output paths (Wwise + UE must agree)
Set the Wwise Root Output Path and the UE Wwise Integration Root Output Path to the same root. Delete stale bank copies: old banks at the GeneratedSoundBanks\ root (v132) shadowed the fresh ones in GeneratedSoundBanks\Windows\ (v150), so UE read the stale copies → bank version 132 vs runtime 150 and Could not find suitable platform. Delete the loose root copies; keep the Windows\ subfolder.
Cooking & Packaging, Two Paks, Two Tools
OBR uses IoStore, which forces a split:
| Content | Tool | Output | Why |
|---|---|---|---|
| Cooked uassets (ModActor, AkAudioEvents, InitBank.uasset) | retoc to-zen | .pak + .utoc + .ucas trio | IoStore, mounts like vanilla mods |
Loose .bnk + .wem | UnrealPak | single .pak | retoc ignores loose files |
ModActor present: false, 127 vanilla events load but yours load 0). Your working reference is your own MountedCombat mod, it’s a .pak+.utoc+.ucas trio. Match it.retoc for the trio (cooked uassets)
Input directory = the folder containing OblivionRemastered so the mount path keeps the OblivionRemastered/Content/... prefix. Engine version UE5_3. Produces TimeStop.pak + .utoc + .ucas. retoc packs cooked uassets only (each .uasset needs its .uexp twin).
UnrealPak for the loose audio
Use a response-file (filelist) with explicit mount targets. Folder-based -create sets the mount to the wrong common root and your files end up not under OblivionRemastered/. The filelist forces correct relative paths:
"H:\...\Content\WwiseAudio\TimeStop.bnk" "../../../OblivionRemastered/Content/WwiseAudio/TimeStop.bnk" "H:\...\Content\WwiseAudio\TimeStart.bnk" "../../../OblivionRemastered/Content/WwiseAudio/TimeStart.bnk" "H:\...\Content\WwiseAudio\Media\32147691.wem" "../../../OblivionRemastered/Content/WwiseAudio/Media/32147691.wem" "H:\...\Content\WwiseAudio\Media\510089961.wem" "../../../OblivionRemastered/Content/WwiseAudio/Media/510089961.wem"
UnrealPak.exe "H:\...\TimeStopAudio_P.pak" -create="H:\...\audio_filelist.txt" -compress
.bnk paths are flat in WwiseAudio/ (matching SoundBanksInfo.json → "Path": "TimeStop.bnk"), NOT in an Event/ subfolder. And .wem filenames must be the media ID from the metadata (510089961.wem, 32147691.wem), source wems live in ...\.cache\Windows\SFX\ and the hex in the cache name is NOT the media ID, so rename them.Init.bnk (or InitBank.uasset) silences ALL game audio, your minimal init bank overwrites the vanilla one, wiping the game’s bus/routing config. The vanilla game already has its init bank loaded; your event routes through the vanilla Master Audio Bus. Remove Init.bnk from the audio pak and InitBank.uasset/.uexp from the trio. (Same mechanism is why calling LoadInitBank() from Lua at runtime kills all audio, never do it.)Pak naming & placement
Name the trio without _P: TimeStop.pak/.utoc/.ucas. BPModLoader derives the mod folder from the pak name; TimeStop_P made it look for /Game/Mods/TimeStop_P/ModActor (wrong). Trio → LogicMods; audio pak → ~mods; no duplicate/stale paks anywhere.
Playing It From Lua, The Breakthrough
Why not the Blueprint / standard posts
- The BP
Post Eventnode crashes at runtime in the packaged build. UAkGameplayStatics.PostEvent,AkAudioEvent.PostOnActor/PostOnComponent,AkComponent.PostAkEventall require aPostEventCallbackDelegateProperty, which UE4SS Lua cannot pass (Parameter 'Delegate' of type 'DelegateProperty' not supported).- There is no
LoadBank/LoadBankByNamein the exposed API, onlyLoadInitBank/UnloadInitBank/ClearSoundBanksAndMedia. The custom bank must auto-load via the event’sRequiredBank.
Discovering the working method
Dump every callable UFunction by scanning Function objects (ForEachFunction was unavailable in this build):
for _, fn in ipairs(FindAllOf("Function") or {}) do
local full = fn:GetFullName()
if full:find("AkGameplayStatics") then print(full) end
end
This revealed PostEventAtLocation, which takes (event, location, rotation, world) and no delegate. UE4SS can call it.
The working call
local function PlayEvent(path)
local ev = StaticFindObject(path)
if not (ev and ev:IsValid()) then LoadAsset(path); ev = StaticFindObject(path) end
local player = UEHelpers.GetPlayer()
local AkStatics = StaticFindObject("/Script/AkAudio.Default__AkGameplayStatics")
local loc = player:K2_GetActorLocation()
local rot = {Pitch = 0, Yaw = 0, Roll = 0}
return AkStatics:PostEventAtLocation(ev, loc, rot, UEHelpers.GetWorld())
end
Returns a valid playing ID and plays the custom in-memory media. No BP, no ModActor needed for audio. The bank does not need an explicit load call, if the .bnk/.wem are correctly packed and the AkAudioEvent references them by SID, the Wwise runtime loads them automatically when the event is posted.
Final Working main.lua + What Ships
local UEHelpers = require("UEHelpers")
local Kismet = StaticFindObject('/Script/Engine.Default__KismetSystemLibrary')
local frozen = false
local EVENT_FREEZE = "/Game/WwiseAudio/Events/TimeStop/TimeStop.TimeStop"
local EVENT_RESUME = "/Game/WwiseAudio/Events/TimeStop/TimeStart.TimeStart"
local function console(cmd)
ExecuteInGameThread(function()
local pc = UEHelpers.GetPlayerController()
if pc and pc:IsValid() then
Kismet:ExecuteConsoleCommand(pc.Player, cmd, pc, true)
end
end)
end
local function GetEvent(path)
local ev = StaticFindObject(path)
if not (ev and ev:IsValid()) then LoadAsset(path); ev = StaticFindObject(path) end
return ev
end
local function PlayEvent(path)
local ev = GetEvent(path)
if not (ev and ev:IsValid()) then print("[Timestop] event not found: " .. path); return false end
local player = UEHelpers.GetPlayer()
if not (player and player:IsValid()) then return false end
local AkStatics = StaticFindObject("/Script/AkAudio.Default__AkGameplayStatics")
if not (AkStatics and AkStatics:IsValid()) then return false end
local loc = player:K2_GetActorLocation()
local rot = {Pitch = 0, Yaw = 0, Roll = 0}
local ok, id = pcall(function()
return AkStatics:PostEventAtLocation(ev, loc, rot, UEHelpers.GetWorld())
end)
if ok then print("[Timestop] played (id=" .. tostring(id) .. ")"); return true end
print("[Timestop] post failed: " .. tostring(id)); return false
end
ExecuteInGameThread(function()
GetEvent(EVENT_FREEZE); GetEvent(EVENT_RESUME)
print("[Timestop] ready - press , to ZA WARUDO")
end)
RegisterKeyBind(Key.OEM_COMMA, function()
ExecuteInGameThread(function()
if not frozen then
PlayEvent(EVENT_FREEZE); console("SloMo 0"); frozen = true
else
PlayEvent(EVENT_RESUME); console("SloMo 1"); frozen = false
end
end)
end)
Key.OEM_COMMA is the , key in UE4SS (OEM_PERIOD is .). Using Key.COMMA passes nil to RegisterKeyBind.What ships in the final mod
LogicMods/
TimeStop.pak / .utoc / .ucas <- retoc trio: AkAudioEvent uassets (+ ModActor, optional)
NO InitBank.uasset
~mods/
TimeStopAudio_P.pak <- UnrealPak: TimeStop.bnk, TimeStart.bnk (v150, flat),
Media/32147691.wem, Media/510089961.wem
NO Init.bnk
ue4ss/Mods/TimestopZaWarudo/
scripts/main.lua <- the script above
enabled.txt
The AkAudioEvent uassets (in the trio) + the loose banks/wems (in the audio pak) are both required: the event auto-loads its RequiredBank from the loose .bnk, and PostEventAtLocation plays it.
Debugging Checklist & Expensive Lessons
| Symptom | Cause | Fix |
|---|---|---|
WwiseConsole failed with error 1 on generation | Motion device referenced, plugin not installed | Delete Motion device/bus, save project |
bank version 132 vs runtime 150 | Stale banks at GeneratedSoundBanks\ root shadowing Windows\ | Delete loose root copies |
Could not find suitable platform for Windows | UE reading stale/rootless banks; platform GUID mismatch | Align output paths, delete stale banks |
Mod invisible, ModActor present: false, your events = 0 of N | Lone UnrealPak .pak doesn’t mount cooked uassets in IoStore | Use retoc trio for uassets |
Content mounts at bare Content/ not OblivionRemastered/Content/ | Wrong pak root / missing mount targets | Point input one level up; filelist with explicit ../../../OblivionRemastered/... targets |
ModClass for 'X_P' is not valid | BPModLoader derives folder from pak name incl. _P | Drop _P; pak name = content folder name |
| ALL game audio silent when pak present | Shipping your own Init bank overrides vanilla routing | Remove Init.bnk + InitBank.uasset |
| Event plays in-editor, crashes/silent in game | Posting via BP node or delegate-requiring API | Post via PostEventAtLocation from Lua |
Parameter 'Delegate' ... not supported | UE4SS can’t pass DelegateProperty | Use PostEventAtLocation (no delegate) |
| AkAudioEvent “Contains Media” checked but Media array empty | Normal for in-memory banks, media lives in the bank, not the event | Not a bug; ignore |
FModel won’t parse your .bnk (IndexOutOfRange) | FModel’s .bnk parser is version-fragile | Not a bug; verify via SoundBanksInfo.json instead |
Key realizations (the expensive lessons)
- The bank was correct the entire time. Parsing the raw
.bnkproved valid v150 + embedded media + correct bus routing. Don’t trust FModel’s parse failure, trustSoundBanksInfo.jsonand a manual chunk parse. - The empty Media array is normal for in-memory audio, the media lives in the bank, not the event asset.
- The real blocker was the post method, not the bank. UE4SS can’t pass delegates, the BP node crashes, and there’s no
LoadBank.PostEventAtLocationis the escape hatch. - Never ship an Init bank and never call
LoadInitBank()at runtime, both nuke all game audio. - Two paks, two tools: retoc trio for cooked uassets (IoStore), UnrealPak for loose banks/wems.
- Dump the real API instead of guessing function names, scanning
Functionobjects revealedPostEventAtLocation.
Custom standalone Wwise audio in Oblivion Remastered: solved. Press comma. ZA WARUDO., CosmicBoogaloo / DreamEater LLC
Better Horse Control
Live-tuning the ridden horse’s movement component from an in-game menu. By CosmicBoogaloo / DreamEater · evolved from HorseSpeedBoost.
OBR horses ride at one fixed feel. Better Horse Control writes the ridden horse’s VHorseMovementComponent live, speed, turn rate, acceleration, grip, braking, even an experimental snap-to-camera steering mode, and keeps those values applied as you swap between horses. No ESP, no asset work; it’s pure UE4SS Lua property writes.
Finding the Ridden Horse (the reliable way)
GetPlayer() IS the horse · filter on the movement component class nameThis is the discovery that later fixed Mounted Combat too. While mounted, GetPlayer() (and the controller’s Pawn) returns the horse pawn itself, whose PairedPawnMovementComponent is the VHorseMovementComponent. On foot, GetPlayer() is the human, whose movement component is a plain VPairedPawnMovementComponent, so you filter on the class name containing "HorseMovement". Works for any horse, any number loaded, with zero rider/mount guessing.
local function horseMCfromPawn(pawn)
if not SafeIsValid(pawn) then return nil end
local ok, mc = pcall(function() return pawn.PairedPawnMovementComponent end)
if not ok or not SafeIsValid(mc) then return nil end
local ok2, cls = pcall(function() return mc:GetClass():GetFName():ToString() end)
if ok2 and cls and cls:find("HorseMovement") then return mc end -- the ridden horse's move comp
return nil
end
Primary lookup is GetPlayer(); the fallback is the controller’s possessed Pawn (also the horse when mounted).
The Vanilla Values and the “walk” Trap
MoveWalkMax alone does nothingAll confirmed by in-game probe (these are the restore-to-vanilla targets):
| Property | Vanilla | Controls |
|---|---|---|
MoveWalkMax | 300 | walk gait base speed |
TrotMultiplier | 4.5 | trot gait (the felt “walk”) |
MoveRunMult | 7.5 | run gait |
MoveSprintBaseMult | 4.4 | sprint gait |
MaxAcceleration | 2048 | how fast it reaches target speed |
GroundFriction | 8 | grip / cornering bite |
BrakingDecelerationWalking | 2048 | stopping force |
RotationRate.Yaw | 90 | turn rate (deg/sec) |
MoveWalkMax alone did nothing visible, the speed you feel at low gait is the trot. So the slider WalkMult scales walk and trot together, and GallopMult scales run and sprint together, each from the vanilla base. Scaling all four gaits from the baseline (the HorseSpeedBoost approach) is what actually changes the feel.Two Property Traps: Struct Write-Back & Owner-Side Steering
RotationRate is a struct copy · bUseControllerRotationYaw lives on the pawnRotationRate is an FRotator struct. Setting mc.RotationRate.Yaw = x directly modifies a temporary copy that’s immediately discarded, the change never lands. You must read the struct, mutate it, and write the whole thing back:
local rr = mc.RotationRate rr.Yaw = yaw mc.RotationRate = rr -- write the whole struct back, or the change is lost
Direct steering lives on the pawn, not the movement component. bUseControllerRotationYaw is set on mc:GetOwner(). Off (default) = vanilla velocity-orient turning, where Turn Rate applies. On = the horse yaw snaps straight to the camera every frame, instant, zero drag, by setting the owner’s bUseControllerRotationYaw=true and disabling bOrientRotationToMovement.
Making Settings Stick Across Horse Swaps
The apply is deliberately not gated behind “is this a new horse?” anymore. Every poll it re-writes the current config to whatever horse is currently ridden. The old key-diff gate could misfire on a second horse and skip the apply, so your settings “fell off” after a swap. Change-detection is now used for one thing only: resetting the horse you just left back to vanilla, so you never leave modded values on a dismounted horse.
local function Tick() local mc = GetRiddenHorseMC() local key = MCKey(mc) local changed = (key ~= activeMCKey) if changed then if SafeIsValid(activeMC) then ResetControl(activeMC) end -- restore the one we left activeMC, activeMCKey = mc, key end if SafeIsValid(mc) then ApplyControl(mc) end -- always (re)apply to current end
It runs on a throttled LoopAsync, plus a ClientRestart hook for an instant re-apply on the mount/dismount possession handoff.
Wiring It to the Menu (console-command bridge)
Settings come from CosmicMCM. Because UE4SS runs every mod in its own isolated Lua state, the menu can’t call into this mod directly, it fires a console command, and Better Horse Control catches it:
RegisterConsoleCommandHandler("BetterHorseControlSetMCM", function(fullCmd, params)
if #params < 2 then return true end
local key, val = params[1], params[2]
if config[key] == nil then return true end -- ignore unknown keys
config[key] = tonumber(val) or config[key]
if SafeIsValid(activeMC) then ApplyControl(activeMC) end -- apply live to the ridden horse
return true
end)
CosmicMCM sends BetterHorseControlSetMCM <key> <value> each time you nudge a slider; the mod updates its live config table and immediately re-applies to the horse you’re on. See the CosmicMCM writeup below for the other half of this bridge.
Better Horse Control: every gait, the turn rate, and the grip, tuned live on any horse from one menu. CosmicBoogaloo / DreamEater
CosmicMCM: One Settings Menu for Every Mod
A from-scratch in-game configuration menu (F10) that live-configures every DreamEater mod. By CosmicBoogaloo / DreamEater.
OBR has no mod-config framework, and the obvious way to build one, a real UMG menu with a widget per setting, hard-crashes on open. CosmicMCM is the workaround that actually ships: a single F10 menu with sliders, toggles and rebindable keys that configures multiple mods at once, persists to per-mod .ini files, and is built from three UMG widgets total.
.ini, pushed to each mod over a console-command bridge, and re-broadcast on startup, with almost no crash surface.Why the Whole Menu Is One TextBlock
StaticConstructObject reliably crashes on menu open, a Slate/GC interaction. The more widgets you new up at runtime, the bigger the crash surface.So the entire menu is one multi-line TextBlock inside a Border inside a UserWidget n/a three widgets, full stop. The menu is a formatted string (rows, a >> cursor, ON/OFF, slider values); navigating just rebuilds that string and calls SetText. Nothing is constructed or destroyed as you move around, so there’s nothing left to crash.
The Cross-Mod Bridge: Console Commands
UE4SS runs every Lua mod in its own Lua state. Mods can’t share globals or call each other’s functions. The one reliable channel between them is the console: CosmicMCM executes <cmdPrefix> <key> <value> via KismetSystemLibrary, and the target mod catches it with RegisterConsoleCommandHandler.
local function sendValue(mi, key, value)
local mod = MODS[mi]
local cmd = string.format("%s %s %s", mod.cmdPrefix, key, tostring(value))
ExecuteInGameThread(function() executeConsole(cmd) end)
end
Each managed mod is just a table: a cmdPrefix (e.g. BetterHorseControlSetMCM), its settings (headers, sliders, toggles, keybinds with min/max/step), and default values. Adding a mod to the menu is adding one entry to MODS and handling its command on the mod side.
Reading Keys Without a Focused Widget
A single TextBlock can’t take keyboard focus, so CosmicMCM reads input by hooking the player controller’s AnyKey input event (BP_AltarPlayerController…InpActEvt_AnyKey) and only acting while the menu is open. UP/DOWN navigate, LEFT/RIGHT adjust sliders, ENTER toggles / opens number-entry / starts a rebind, digits type an exact value, F11 switches mod, F10 opens/closes.
Persistence & the Startup Broadcast
.ini per mod · push every saved value once the world existsEach mod gets one .ini in CosmicMCM/config/ ([settings], key=value), with the folder resolved from the running script path. UE4SS’s working directory is the game’s Binaries dir, not the mod folder, so you can’t use a relative path. Values save on every change.
The catch: at load time there’s no player yet, so ExecuteConsoleCommand has nothing to run against. CosmicMCM waits for the world via NotifyOnNewObject on the player character, then broadcasts every saved value to every managed mod, so settings (including a rebound Timestop key) apply on startup, not only when you next touch a slider.
NotifyOnNewObject("/Script/Altar.VOblivionPlayerCharacter", function()
-- ... register the AnyKey hook ...
ExecuteWithDelay(1500, function() broadcastAll() end) -- push saved config to all mods
end)
CosmicMCM: a whole settings framework from three widgets and a console channel. CosmicBoogaloo / DreamEater
Reference
Fixing Problems & Glossary
CTD index, status board, field notes, resource directory, and full glossary. Always open in another tab when something breaks.
Debugging Workflow
Opening the In-Game Console
The in-game console key is ` (backtick/tilde). This opens the Oblivion console. UE console commands are also available. The UE4SS GUI console window is separate, it opens alongside the game window when UE4SS is active with GuiConsoleVisible = 1 in UE4SS-settings.ini.
Rapid Testing Commands
tgm ; god mode for safe testing tdetect ; disable NPC detection so you can work undisturbed set timescale to 0 ; freeze game time coc "TestingHall" ; jump to a vanilla test cell (or any cell EditorID) coc "ICMarketDistrict" ; jump to a known working exterior player.moveto 00000014 ; teleport to player start ToggleDebugCamera ; free-fly camera for scene inspection
Reading UE4SS Crash Logs
UE4SS logs are at: [GameDir]\Binaries\Win64\ue4ss\Logs\UE4SS.log
Key things to look for in crash logs:
Unhandled exception+ stack trace, the actual crash locationLogScript: Errorentries, OBScript evaluation errorsLogBlueprintUserMessages, Blueprint print nodes output hereModuleManagererrors, a DLL plugin failed to load
UE crash reports go to: C:\Users\YOU\AppData\Local\CrashReportClient\
Mod Compatibility & Conflicts
ESP Record Conflicts
- When two mods modify the same record, the last one in load order wins. Neither mod’s change is truly merged, one overwrites the other.
- To merge changes: open both ESPs in xEdit, copy the conflicting record into a new patch ESP, and manually combine both sets of changes. This is a compatibility patch.
- xEdit shows conflicts in the right-click context menu and flags records with colored backgrounds. Red = conflict you must resolve.
PAK Conflicts
- In
~mods, PAK files load in alphabetical order by filename with later entries overwriting earlier ones. - For your mod to win over another: prefix with a lower letter (
000_) or instruct users to rename. - PAK conflicts are silent, no error, the later file just wins entirely for each asset.
- SyncMap
.inifiles from multiple TSMI mods do NOT conflict, they are additive. Multiple INI files coexist fine.
Dirty Edits & ITM Records
When you open a record in xEdit and accidentally touch it without changing anything, xEdit may mark it as modified. These “Identical To Master” (ITM) records bloat your ESP and can cause unnecessary conflicts. Before releasing any mod: in xEdit, right-click your plugin → Other → Check for Errors and Other → Apply Filter for Cleaning. Remove ITM records.
Save Safety
| Mod Type | Safe to Add Mid-Playthrough? | Safe to Remove? |
|---|---|---|
| Texture/sound replacers (PAK only) | Yes | Yes, reverts to vanilla on removal |
| ESP stat edits (existing records) | Yes | Mostly yes, existing items revert to vanilla stats |
| ESP new items (new FormIDs) | Yes | Risky, missing FormIDs in save cause issues |
| New interior cells (MagicLoader) | Yes | No, removing breaks saves that visited the cell |
| OBScript changes | Careful, running scripts may have already modified save state | Depends, permanent ModAV changes stay in save |
| UE4SS Lua mods | Yes | Yes, no save state touched |
ModActorValue in OBScript makes permanent changes to actor values stored in the save file. A player’s Health modified with ModAV cannot be “healed back” by normal means. Use SetActorValue for temporary changes or enchantment Fortify effects instead.Distribution Checklist
Before Releasing a Mod
- Clean your ESP in xEdit (remove ITM records, check for errors).
- Test on a clean install without your development tools installed, users don’t have your UE project or xEdit scripts.
- Document what files go where in your mod description: pak in ~mods, ESP in Data, SyncMap ini in Data\SyncMap, etc.
- State your dependencies: OBSE64, UE4SS, TesSyncMapInjector, Address Library, any loader your mod needs.
- State game version: OBSE64 and compiled Blueprint paks are version-locked. Mention which game build you tested against.
- Include a folder structure diagram for anything beyond a simple pak replacement. Users get confused by nested install paths.
- Test removal: verify removing your mod on an existing save doesn’t corrupt it, or warn users clearly if it does.
What Files Users Actually Need
| If your mod is | Users need |
|---|---|
| Texture/sound replacer | ModName_P.pak + .ucas + .utoc in ~mods |
| New item (existing model) | ESP in Data + SyncMap .ini in Data\SyncMap |
| New item (custom model) | ESP + SyncMap .ini + PAK in ~mods |
| New interior cell | ESP + SyncMap .ini + MagicLoader must be installed |
| Blueprint behavior mod | PAK in LogicMods\ModName + UE4SS installed |
| Lua scripting mod | Lua mod folder in ue4ss\Mods\ + UE4SS installed |
Performance Considerations
- Draw calls matter more than polygon count. One complex merged mesh is better than 50 simple separate meshes in the same cell.
- Cloth simulation is CPU-bound and scales with complexity. Use PhysicsProxy low-poly meshes to drive simulation and interpolate to the high-poly result.
- Blueprint Tick cost:
Event Tickin a Blueprint runs every frame. Use event-driven patterns (RegisterHook,NotifyOnNewObject) wherever possible instead of polling on tick. - ESP record count: practically unlimited, the vanilla game has thousands. Not a real bottleneck.
- Lua mod load timing: many UE4SS mods loading simultaneously can cause CTDs due to race conditions. Use the deferred hook pattern and
mods.txt/mods.jsonload ordering to sequence initialization. - PAK file size: no hard limit but very large PAKs (>1GB) can increase load time noticeably. Split by content type if needed.
Game Version & Patch Stability
- Keep a backup of your working game version before any game update. Steam can be set to not auto-update under Properties → Updates.
- OBSE64 breaks on every game binary update until offsets are manually updated by the OBSE team. Check the Nexus page changelog after patches.
- Compiled Blueprint paks may become incompatible after patches that change class layouts. The game will usually crash silently on load or produce broken behavior rather than a useful error.
- UAssetGUI edits are binary-specific, changes made to assets from one game version may not work on another. Always extract fresh assets from the current game version.
- Check the Nexus early modding overview and Discord
#announcementsafter every patch for community-reported breakage.
CTD & Issue Index
| Symptom | Likely Cause | Fix |
|---|---|---|
| New object invisible / CTD entering new interior | Missing SyncMap entry or wrong load order | Run Smart Mapper; place plugin above AltarDeluxe.esp |
| New item named [nl]Something | Altar localization artifact | Install NL-Tag Remover (mods/473) |
| Won’t launch after adding a mod | Scrambled load order or bad DLL | Restore clean Plugins.txt; remove last-added mod; reinstall one at a time |
| OBSE “version not detected” | Wrong build / wrong folder / base exe launched | Match build; dll+exe in Binaries\Win64; always launch via OBSE loader |
| UE4SS not loading / startup crash | OBSE conflict on first launch | Launch once WITHOUT OBSE first so UE4SS configures itself |
| Cooked assets won’t load in-game | Wrong UE version used to cook | Cook in UE 5.3.2 specifically, not 5.5 or any other version |
| UAssetGUI “Failed to Parse X Exports” | Referenced dependency files not extracted alongside | Extract all referenced assets too in the same directory tree, then retry |
| .pak ignored by game | Missing _P suffix or wrong load priority | Add _P in ~mods; or prefix with 000_ for Paks folder alphabetical priority |
| BODY SETUP 0: BAD NAME INDEX crash | Collision Presets = Block All, or wrong material slot names | Set Collision Presets to Custom; match slot names from vanilla FModel JSON |
| Whole world renders as UE grey grid | Static switch changed, or base material packed | Never edit static switches; pak only the material instance, never the parent |
| Unversioned properties crash (hard to diagnose) | Mod cooked with Shipping instead of Development | Always cook in Development configuration |
| BP_ form files crash after patch 1.2 | Old blueprint forms incompatible with new game version | Extract fresh vanilla BP_ forms from 1.2 PAKs; rebuild. Gauntlets and greaves most affected. |
| NPCs / creatures frozen in new cell | No navmesh data in new interiors | Known limitation; no fix. Design rooms to not need NPC movement. |
| First-person weapon / glove / sleeve clipping | Wrong material parent for first-person gear | Use FPSClippingFix material parent |
| Boots hide feet (bitmask = 0) | 0 means inherit from parent, not “show all” | Use a non-zero value with relevant bits cleared |
| Inventory icon missing | Missing T_ prefix in UE, or wrong xEdit path | Prefix PNG with T_ in UE only; keep .dds extension in xEdit Icon Image path |
| Textures blurry in-game | ubulk split issue | Set “Never Stream” on texture in UE; update retoc (issue #22 fixed) |
| Cloth mesh doubled (static + dynamic) | Cloth mesh is the Root Skeletal Mesh Component | Cloth mesh must be a child of root, never root itself |
| Custom armor invisible when enchanted | Dynamic FormID not in TSMI SyncMap | Known bug; don’t change load order after enchanting; re-enchant if needed |
| Lua ExecuteConsole silently broken after patch | API signature changed | Add fourth true parameter |
| JAA imports missing MIC layers | JAA doesn’t import the MIC Layers tab | Edit that material in UAssetGUI instead |
| Material un-assigns from chunk on project reopen | Allow ChunkID Assignments not enabled | Enable in Editor Preferences + set Cook Rule to Always Cook |
| Animation looks fine in editor, broken in-game | Wrong bone index order from PSK skeleton | Import SKEL_HumanoidSkeleton via JAA, not from PSK export |
| Notifies fire at wrong time in-game | Wrong framerate (Blender default 24fps) | Set Blender to 30fps; import UE at custom sample rate 60fps |
| RefreshAppearance crash with multiple mods | Multiple mods calling it simultaneously | Limit calls to only the aspect you’re changing |
| Enchantment VFX missing on custom weapon mesh | bAllowCPUAccess not set on the static mesh | Set Allow CPU Access = True in UAssetGUI on your SM_ asset |
What Works, Status Board (mid-2026)
Field Notes
1.2 Update Mesh Fix Checklist
- Set Collision Presets to Custom on all static meshes (primary crash source: BODY SETUP 0: BAD NAME INDEX).
- Match Material Slot Names to vanilla values. Extract vanilla mesh properties JSON from FModel, search
"MaterialSlotName". Mismatch = BAD NAME INDEX. - Uncheck left checkbox on all Static Switch Parameters in every MIC, checked switches cause grey world or crash.
- For blueprint/form crashes after 1.2: extract fresh vanilla BP_ forms from 1.2 PAKs and rebuild. Gauntlets and greaves are most commonly affected; boots often fine; helmets sometimes affected.
- Use the 1.2 Updater Tool (mods/5281) to automate collision and slot name fixing.
Saved\Cooked\Windows\OblivionRemastered\, copy only the assets you changed into a matching folder structure, and repack with Retoc. This gives you a single pak file containing both art and forms, is faster for iterative testing, and avoids the overhead of cooking everything.Material Rules That Save Hours
- Never change a static switch, even to the same value. Causes grey world-grid rendering.
- Don’t pak the base material. Make a material instance of the base and pak only the instance.
- All non-static parameters are fair game to override.
- FPSClippingFix is the correct parent for any first-person-visible gear (weapons, gloves, cuirass sleeves).
Blender Socket Naming, The Double-Prefix Rule
When Blender exports a bone named Sockets_Something to UE, UE strips the Sockets_ prefix on import, so Sockets_Arrow becomes Arrow. To get a socket that arrives in UE as Socket_Arrow, name it Socket_Socket_Arrow in Blender. This applies to all weapon attachment sockets, quiver arrow sockets, and equipment slots. Credit: deathwrench.
pakchunk0, Safe to Delete
When packaging a Blueprint mod, UE often generates an unwanted pakchunk0.pak alongside your actual mod pak. This contains anything present in the UE project but not explicitly assigned to a chunk: the default map, base materials meant for instancing rather than overwriting, engine .ini files, etc. It is safe to delete as long as all your actual assets are correctly assigned to a named chunk. Alternatively, configure packaging settings so UE doesn’t generate it. Credit: cnnrduncan.
Gendered Mesh Array Slots (0-7)
Armor blueprints that support different meshes per race/gender use an array of 8 skeletal mesh slots. The index mapping confirmed by .yeah.nah.yeah.:
- 0 = Human/Elf Male 1 = Human/Elf Female
- 2 = Argonian Male 3 = Argonian Female
- 4 = Khajiit Male 5 = Khajiit Female
- 6 = Orc Male 7 = Orc Female
Even numbers = male, odd numbers = female. The vanilla function that automatically selects the correct mesh by race was broken in patch 1.2, see the Cloth callout in Lesson 9. Using the same mesh for male/female (or just index 0) is the practical workaround until a fix lands.
Custom Interior Map Loading (de_tylmarande, Aug 2025)
Standard interior cell creation via MagicLoader works well, but has a quirk: custom .umap files need to be validated by the game’s asset check stage or the game crashes before the loading screen finishes. Two approaches:
- VerticalSlice folder: Place your
.umapatOblivionRemastered/Content/Maps/VerticalSlice/L_YourCellName.umap. The game searches this path before checkingST_CellsToMapPath, so it loads automatically. Caveat: all plugin-created cell locations also land here at runtime. - Asset validation patch (C++ UE4SS DLL): Patch the asset validation subroutine so custom maps pass through as if they were shipped. Described by de_tylmarande; once applied, any custom umap loads like a native one regardless of location. The patch is available alongside de_tylmarande’s map cloning tool (check #resources channel).
MagicLoader v2’s CellsToMapPath DataTable link remains required for the Gamebryo cell → UE level mapping regardless of which approach you use.
Exterior World Partition Architecture
The exterior world uses a combination of systems (researched by .yeah.nah.yeah. and gourmet_games_dev_84729, Dec 2025). VLevelStreaming handles level streaming for sub-levels. VWorldPartition and related classes handle the exterior world. The world partition streaming policy is a custom Virtuous class (VWorldStreamingPolicy) that is stubbed out in the Altar SDK and requires building from source to fix. For reference: extract L_Tamriel’s JSON from FModel, it contains a complete picture of the exterior streaming setup. This is why new exterior cells/worldspaces remain blocked: the custom streaming policy can’t be replicated without source access.
Debug Console Quick Reference
ToggleDebugCamera ; free camera (replaces old tfc) SloMo 0 ; freeze time completely Altar.StandOutOblivionAsset 1 ; turn legacy assets pink (dev tool) Altar.Cheat.AllowSetStage 1 ; enable SetStage in console player.moveto 00000014 ; teleport to player start ATM.SetWeatherSetup clear ; set clear weather set timescale to 1 ; restore normal time tgm ; toggle god mode tai ; toggle AI tdetect ; toggle enemy detection
Reducing Material Instance Count (UV Atlas / SimpleBake)
Having many separate material instances in one mod increases pak size and draw calls. To consolidate, bake all textures into a single UV atlas in Blender:
- Manual method: In Blender UV Editor, put all UV islands on the same 0-1 UV space at reduced scale (e.g. 0.5 scale = 4 islands on one sheet). Background the target texture and position islands manually.
- Automated method: Use the SimpleBake Blender add-on to automate multi-material baking onto a single atlas. A community-shared compiled build exists, check the Discord resources channel.
After baking: one texture sheet, one material instance. Credit: low.je, deathwrench.
Rebuilding Widget Blueprints (WBP), dicene’s WBP Reader
Widget blueprints can’t be imported from FModel directly, they must be rebuilt in UE. To make this tractable, dicene wrote a Python tool that parses the WBP’s JSON export and outputs a readable summary of all functions, properties, and nodes:
github.com/dicene/UETools, WBP_Reader-NoGui.py
Workflow: export WBP from FModel as JSON → run through WBP_Reader → use the output to systematically recreate the graph in UE. You can also feed the raw JSON to an AI assistant to walk through node-by-node reconstruction. You only need to recreate functions and properties that your mod actually interacts with, not the entire WBP. Names must match exactly; the game resolves them at runtime. Credit: dicene.
GhostEffect Shader on New NPCs
The NDAbGhostNPC ability spell applies a ghost/translucency effect shader. It works correctly on vanilla NPCs and items. On new SyncMap-created NPCs and creatures, applying it makes them fully invisible while alive and only correctly ghostly after death. Workaround: use a vanilla Effect Shader from an existing NPC record rather than the GhostNPC ability directly. Some creature types (Wraiths, which are already semi-translucent) partially work. Root cause is likely an extra implicit SyncMap dependency for effect shaders on new actors. Credit: urikslargda.devakm.
Deluxe Edition Content is UE-Side, Not in CS
The Deluxe Edition items and content are handled entirely on the UE side. They do not appear in the Construction Set under their expected names/IDs from the Steam store page. They are present in xEdit under different IDs. If you can’t find a DLC item in CS, check xEdit with the DLC’s ESP loaded. Credit: yofigi, masterlordflame.
Development-Only Nodes in Blueprint Mods
Some UE Blueprint nodes are marked “Development Only”, they execute in editor but are silently compiled out in packaged/shipped builds. If a branch of your BP logic never fires in-game but works fine in the editor, check all nodes for a “Development Only” badge. This is a common gotcha when adding debug prints or diagnostic logic. Credit: deathwrench.
Engine.ini Blueprint Variable Overrides
Set Blueprint variables without Lua by editing: C:\Users\YOU\Documents\My Games\Oblivion Remastered\Saved\Config\Windows\Engine.ini
[/Game/PathToBlueprint/BP_Name.BP_Name_C] MyVariable=Value MyStruct=(Prop1=Val1,Prop2=Val2)
Never include Content in the path. Use /Game/ or /PluginName/. [/Script/ is reserved for C++ classes.
Glossary
Reference
Every Tool & Folder
The complete modding workbench: where every file goes, how load order works, and every tool with its purpose.
The File Buckets
Every file you install or create belongs to exactly one destination. Learn to answer “which bucket?” by hand even when a mod manager handles it automatically.
| Bucket | File Types | Location | Layer |
|---|---|---|---|
| Data | .esp .esm | ...\Content\Dev\ObvData\Data\ | brain |
| SyncMap | .ini (TSMI mappings) | ...\ObvData\Data\SyncMap\ | bridge |
| Paks ~mods | .pak + .ucas + .utoc | ...\Content\Paks\~mods\ | visual |
| LogicMods | Blueprint paks | ...\Content\Paks\LogicMods\<ModName>\ | visual |
| UE4SS Mods (Lua) | main.lua in Scripts\ subfolder | ...\Binaries\Win64\ue4ss\Mods\<ModName>\ | visual |
| UE4SS BPModLoaderMod | Blueprint paks (alternative path) | ...\Binaries\Win64\ue4ss\Mods\LogicMods\ | visual |
| OBSE / ASI Plugins | .dll / .asi | ...\Binaries\Win64\ | extension |
| GameSettings | .ini GMST overrides | ...\Binaries\Win64\GameSettings\ | brain |
PAK Naming & Load Rules
_Psuffix in~mods: all three files must end in_P(e.g.MyMod_P.pak,MyMod_P.ucas,MyMod_P.utoc).- Alphabetical priority in
Paksfolder: placed directly in Paks, a mod sorts alphabetically and must sort belowOblivion.pakto win conflicts, use a prefix like000_MyMod.pak. - Blueprint/logic mods go in the
LogicModssubfolder.
ESP Load Order, Canonical Template
Oblivion.esm DLCBattlehornCastle.esp · DLCFrostcrag.esp · DLCHorseArmor.esp DLCMehrunesRazor.esp · DLCOrrery.esp · DLCShiveringIsles.esp DLCSpellTomes.esp · DLCThievesDen.esp · DLCVileLair.esp Knights.esp AltarESPMain.esp ← YOUR NEW-OBJECT / NEW-CELL PLUGINS GO HERE AltarDeluxe.esp AltarESPLocal.esp
AltarDeluxe.esp. Below it, new objects render invisible and entering a new interior crashes, a TesSyncMapInjector ordering requirement.Key Content Paths in the Game Files
/Game/Art/Character/{Race}/ ← Race textures and meshes
/Game/Art/Equipment/armor/{type}/ ← Armor skeletal meshes
/Game/Art/Equipment/weapons/ ← Weapon static meshes
/Game/Forms/items/armor/ ← Armor form blueprints (BP_BDP_*)
/Game/Forms/items/weapons/ ← Weapon form blueprints
/Game/Forms/actors/npc/ ← NPC blueprints
/Game/Forms/actors/race/ ← Race blueprints
/Game/Dev/Phenotypes/Hair/ ← Hair phenotype data tables
/Game/Materials/ ← Master materials
/Game/Art/Animation/Humanoid/ ← Character animations
/Game/Art/Animation/Humanoid/Facial ← Per-voice-line face animations
/Game/Art/UI/Icons/Dynamic_Icons/ ← Inventory icons
Loaders & Bridge Tools
| Tool | Role | Notes |
|---|---|---|
| OBSE64 | DLL plugin loader (not script extender yet) | Steam only; match game build; dll+exe to Binaries\Win64 |
| Address Library (mods/4475) | Version-independent OBSE plugins | Required by almost all OBSE plugins |
| UE4SS (mods/32, OR build v3.0.1) | Lua scripting + Blueprint loader for UE5 side | Launch once without OBSE to configure; use OR build not GitHub generic |
| TesSyncMapInjector (mods/1272) | Links ESP FormIDs to UE5 assets at runtime | INIs in ObvData\Data\SyncMap; unzip to game root |
| Smart Mapper | xEdit script that auto-generates TSMI INI files | Output lands in xEdit\SyncMap; copy to game Data\SyncMap |
| MagicLoader (mods/1966) | Enables new interior cells via CellsToMapPath DataTable | Also requires MagicPatcher for string table entries |
| OBRConsole (mods/2205) | Lets UE4SS Lua run Oblivion console commands | ⚠ Crashes if you tab out of the game while active |
| SML / Simple BP Mod Loader (mods/1172) | Alternative Blueprint loader; better input action docs | Also provides SML Dev Resources for console output, UObject storage |
ESP / Data Authoring
| Tool | Role | Notes |
|---|---|---|
| xEdit 4.1.5n+ (TES4R build) | Primary ESP authoring and conflict detection | Get from xEdit Discord #xedit-builds; launch with -TES4R flag |
| Construction Set (2006) + CSE | Visual editor for cells, containers, world objects | Altar ESPs need manual dependency loading via xEdit first to open in CS; saved esp in ObvData\Data\Data (move up one level) |
| LOOT | Automatic load-order sorting | Sort, then hand-fix the special cases above |
| NL-Tag Remover (mods/473) | Strips [nl] artifact from new item names | Required for any mod that adds new named items |
Haphestia’s Fix and Port Script (fixmod; vibrantruin on Nexus) | Fixes the 2 missing parameters CS doesn’t add for Remaster | Run in xEdit with Altar files loaded |
| Game Settings Loader (mods/833) | Load GMST overrides from config file | Alternative to setGS console command each session |
| Runtime EditorIDs (mods/1331) | Surfaces EditorIDs in the in-game console | QoL for testing; lets you spawn items by EditorID |
UE5 Asset Pipeline
| Tool | Role | Notes |
|---|---|---|
| Blender + PSK/PSA plugin | Model and animation authoring | Rename armature to “Armature”; if PSK breaks try Befzz plugin |
| Unreal Engine 5.3.2 | Cook art assets into paks | Use 5.3.2 specifically. Install to C:/ not Program Files. |
| FModel + .usmap (mods/47) | Browse and extract game assets | Set to GAME_UE5_3; regenerate .usmap after patches via UE4SS Ctrl+Numpad6 |
| OR Mod Tools (mods/3918) | retoc + UAssetGUI + oo2core bundle | Use Nexus UAssetGUI version (PackageName bug fix) |
| retoc (github.com/trumank/retoc) | Extract and repack IO Store paks | to-legacy to extract; to-zen to repack; update for ubulk fix |
| UAssetGUI (Nexus version) | Edit cooked .uasset/.uexp blueprint and form files | Set version to 5.3; needs both .uasset and .uexp in same folder |
| JsonAsAsset + j0.dev | Import materials from FModel JSON into UE project | Use C0bra5+Tectors fork; enable Stubs checkbox; MICs need manual parenting |
| C.A.F.E. (mods/4891) | CosmicBoogaloo’s armor/weapon form path tool | Form name must match asset path name exactly |
| NNRM Merge/Split Tool (mods/3051) | Split and recombine NNRM channel-packed textures | Also use c0bra5’s ffmpeg scripts for channel-precise splitting |
| OR SDK (Kein/Altar) | UE project stub with Oblivion superclasses | Compile with VS2022 MSVC v143 v14.38.33130; no engine-from-source needed |
| simple-nanite-parser (c0bra5) | Extract full-detail Nanite meshes | FModel only exports fallback mesh; this gets the real thing. Python, exports to GLTF. |
| Alpakit for UE 5.3.2 | One-click auto-deploy of Blueprint mods | OBR-compatible fork by Wikt0r1us |
| sound2wem | Convert audio files to Wwise .wem format | Required for any audio replacement workflow |
| wwiser | Browse Wwise .bnk soundbanks to find .wem IDs | Find CAkSound node SOURCE IDs for specific voice lines |
| Visual Studio 2022 (direct link) | Compile Altar SDK project and OBSE64 C++ plugins | Game Dev C++ workload + MSVC v143 v14.38.33130 component |
| 1.2 Updater Tool (mods/5281) | Automates collision + material slot name fixing for 1.2 | Run on any mesh mod made before patch 1.2 |
| Body Part Chart (mods/2583) | nyyxn’s authoritative body-part bitmask reference | Required reference for MaleBodySectionHidden values |
| Community DNA Files (mods/2592) | MetaHuman DNA files for all base races and named NPCs | Required for custom head facial animation |
Reference
Every Link & Resource
Every tool, Nexus mod, GitHub repo, guide, and community resource referenced in this codex, organized in one place.
Essential Tools (Install These First)
Loaders & Bridge (Required for Modding)
Mod Managers
ESP / Data Authoring
UE5 Asset Pipeline
Audio Tools
Scripting & Code References
Community Hubs
This codex synthesizes work from hundreds of community researchers. Key contributors by area:
Materials & Modeling: c0bra5 (nanite parser, material reverse-engineering), deathwrench (practical workflow, collision, standalone items), jack_la (morphs, vertex colors), qunai (bitmasks, BP form structure), .astralus (texture pipeline), exicide (beast race compatibility), dotaxis (packaging workflow)
Cloth Physics: tenebrisequitem (cloth physics deep-dive), izedev_55749 (MetaHuman DNA research)
Animation: ryanhank, krasuepisac, michaelpstanich, lunemods, strikshaw, kei7855, jakealaimo, .yeah.nah.yeah.
Mapping: nafnaf_95, khameli0n, narm_, minuteready, dicene, cnnrduncan, .yeah.nah.yeah., wxmichael, agentlefox (heightmap tools research), grimlock_arts (heightmap artist)
Scripting: MadAborModding (levitation / notification bridge), dicene (ConsumeNotification method), dotaxis, zarrastro, randombombombom, veter_, ubawesome, faeriemushroom, crimson, PuddlePumpkin
Reference mods: tommn (Bloodlust, Custom Summoning), yerawizardharreh (Shield on Back), kei7855 (Shortsword animations)
Animation & Modding Examples
Texture & Heightmap Tools
Community Starter Packs (Google Drive)
Unreal Engine Documentation
Video Tutorials
Community Guides & Forum Resources
Additional 3D & Rigging Tools
Community
Credits & Contributors
This wiki exists because of hundreds of people who spent countless hours reverse-engineering a game with no official documentation, sharing everything they found for free, and building tools for everyone to use. This page is for them.
The Community Hub
Every discovery in this wiki came from the OBR Modding Discord. If you’re making mods, you need to be here. The #research-general, #research-modeling, #research-scripting-ue4ss, #research-animation, and #research-mapping channels are where the real work happens.
Research Contributors
These are the people who actually figured this stuff out. Every name in these lists did something real, wrote a tool, reversed a system, tested a workflow, documented a finding, or shared a crucial insight that saved everyone else hours of work.
Modeling, Textures & Asset Pipeline
Animation, Audio & ABP System
World Editing & Mapping
fixmod), as vibrantruin on Nexus. mods/1132Scripting, Lua & Cross-System
Tools & Frameworks
= .yeah.nah.yeah.Pre-compiled Altar project and Materials Pack, removed the VS build requirement for most Blueprint modders. (“Tripster” is the same person as “.yeah.nah.yeah.”, credited above for Wwise audio, world-partition, and cloth physics research.)
The Compiler & Curator
CosmicBoogaloo, DreamEater
The author of this wiki. I didn’t discover most of what’s on these pages, this community did. My job was to read every Discord export, every research channel, every tool readme, and every pinned message, then organize it into something a new modder can actually use without drowning in 9,655 messages of raw Discord history.
I also made C.A.F.E. (the armor/weapon form path tool), published 60+ mods with 15,000+ downloads, got covered by PC Gamer, GameSpot, and The Gamer, and received a personal compliment from Markus Persson. I’m building a full indie game engine and studio under the DreamEater name. Oblivion Remastered was the spark that started all of it.
Get In Touch
Found an error? Have new research to add? Want to be credited or update your info? Use the form below.
Contact CosmicBoogaloo
Engine Internals
The Bridge & DataTables
How Oblivion Remastered fuses Gamebryo logic with a UE5 visual layer, and the Altar DataTables (SyncMap, CellsToMapPath) that TSMI and MagicLoader patch at runtime. The map of every content directory worth knowing.
The Three Layers
Every mod decision maps to one of three layers. The CK must be aware of all three simultaneously.
| Layer | Role |
|---|---|
| Gamebryo brain | Game logic. ESP/ESM records, OBScript, AI packages, quests, items, dialogue text, FormIDs. Same format as 2006. Location: Content/Dev/ObvData/Data/ |
| The Bridge bridge | Altar's translation layer. DataTables (CellsToMapPath, SyncMap, AllRaceModifications), FormID→UE asset mappings, VPairedPawn architecture, TESTopicInfo assets. This is what TSMI and MagicLoader patch at runtime. |
| Unreal Engine 5 visual | Rendering, audio, animation, Blueprint logic. PAK files, uassets, Wwise banks, AnimSequences, StateMachines. Location: Content/ tree. |
Why TSMI and MagicLoader Exist (And Why They're Fragile)
Both tools are runtime DataTable patchers, they inject entries into Altar's bridge DataTables after the game loads. They exist because nobody has write access to those DataTables at cook time.
- TSMI patches the
SyncMapDataTable: FormID → UE Blueprint/mesh asset path. When Gamebryo wants to render a form, it looks up this table to find the UE asset. TSMI injects new entries for mod-added forms. - MagicLoader patches the
CellsToMapPathDataTable: Gamebryo cell EditorID → UE.umappath. When the player enters a cell, Gamebryo tells UE which map to load via this table. MagicLoader injects new interior cell entries.
The FormID Bridge
Every Gamebryo form that has a visual representation owns a corresponding UE asset. The connection is the FormID. A form with ID 0x000479E1 maps to a UE Blueprint at a known path like /Game/Forms/actors/npc/SomeNPC.SomeNPC. The SyncMap DataTable holds these mappings. New mods add new FormIDs and need new SyncMap entries, that's TSMI's job.
Key Bridge DataTables (Confirmed)
| DataTable | Key | Value | Tool that patches it |
|---|---|---|---|
SyncMap | FormID string | UE Blueprint asset path | TSMI |
CellsToMapPath | Cell EditorID | UE .umap path | MagicLoader |
AllRaceModifications | Race asset path | Body/head mesh paths + phenotype | Manual / CK target |
SyncMap & DataTable Architecture bridge
SyncMapMain. The Master FormID→Blueprint Registry
SyncMapMain at /Game/Forms/SyncMapMain is type TESSync, the primary DataTable that maps every Gamebryo FormID to its UE Blueprint. When a mod adds a new item, NPC, or weapon, it needs an entry here.
/Game/Forms/SyncMapMain.SyncMapMain and /Game/Forms/SyncMapLocal.SyncMapLocal.
TSMI patches SyncMapMain. SyncMapLocal is likely for DLC/local additions. CosmicBridge should patch both.
VAltarBlueprintTable. The Alternative DataTable
DT_EquippedObjectsBlueprints and DT_UIBlueprints are VAltarBlueprintTable assets, a different DataTable type specifically for equipped objects and UI elements. Structure:
VAltarBlueprintTable {
BlueprintsMap: [ {Key: "FormID_string", Value: {AssetPathName}} ]
Rows: {
"RowName": {
FormID: int,
Blueprint: {AssetPathName: "..."}
}
}
}
Both BlueprintsMap (the indexed fast-lookup) and Rows (the DataTable rows) must be populated for new entries to work. The row name can be anything (e.g. "NewRow_0", "NewRow").
DataTable Injection. CosmicBridge Implementation
CosmicBridge replaces TSMI and MagicLoader by reading config files that mods deposit and injecting into these DataTables at runtime:
-- CosmicBridge startup sequence local function InjectSyncMap(configPath) -- Read mod's syncmap.json -- Find SyncMapMain DataTable in memory -- For each entry: inject row with FormID → Blueprint path end local function InjectCells(configPath) -- Read mod's cells.json -- Find CellsToMapPath DataTable -- For each cell: inject row with EditorID → umap path end local function InjectEquippedObjects(configPath) -- Find DT_EquippedObjectsBlueprints -- Inject entries for weapons/items that need equipped Blueprint end
Config File Formats (CosmicBridge Standard)
-- syncmap.json (replaces TSMI .ini files)
{
"entries": [
{
"formId": 12345,
"blueprint": "/Game/Forms/items/weapons/BP_MyWeapon.BP_MyWeapon",
"blueprintClass": "/Game/Forms/items/weapons/BP_MyWeapon.BP_MyWeapon_C"
}
]
}
-- cells.json (replaces MagicLoader)
{
"cells": [
{
"editorId": "MyCustomCell",
"mapPath": "/Game/Maps/MyMod/L_MyCell.L_MyCell"
}
]
}
-- equipped.json (replaces DT_EquippedObjectsBlueprints manual editing)
{
"entries": [
{
"formId": 12345,
"blueprint": "/Game/Forms/items/weapons/BP_MyWeapon.BP_MyWeapon"
}
]
}
Placeholder Blueprints. The TSMI Workaround
From the weapons folder: BP_WeaponBladeWithScabbardPlaceholder, BP_WeaponMeleePlaceholder, BP_WeaponBowPlaceholder, BP_WeaponQuiverPlaceholder, BP_WeaponShieldPlaceholder, BP_WeaponStaffPlaceholder. These are the empty Blueprint stubs that TSMI maps custom weapons to when no custom Blueprint exists, the weapon gets the correct weapon type behavior without a custom 3D mesh.
FModel Asset Map visual
Reference for every significant directory in the OBR content tree. Updated from live FModel session 2026-06-12.
Content/Dev/. Altar Development Assets
| Path | Contents | Mod relevance |
|---|---|---|
Dev/AI/ | DetectionLighting (BlueprintLightProfile, SkylightIntensity), Navlinks | AI behavior, reference for custom NPC AI |
Dev/Animation/ | All ABP_, TABP_, blendspaces, interfaces | Animation replacers, custom ABPs |
Dev/InteractibleObjects/ | All BP_VChest, BP_VDoor, BP_Flora variants | Cell object placement |
Dev/LevelSelectDoors/ | BP_LevelEntryDoorObv and variants | Cell transition wiring |
Dev/NPCs/ | BP_Generic_NPC, BP_NPC_SOUL, BPI_NPC_CustomFadeOut | NPC templates |
Dev/ObvData/Data/ | ESPs, ESMs, Plugins.txt, SyncMap/ | Gamebryo data, primary mod target |
Dev/PairingActors/ | BP_ReferenceHolder, BPC_BoundEffect, BPC_StatusEffect | Actor pairing system |
Dev/Phenotypes/ | PhenotypePreset_BASE, MorphSources, SkinParameterCollections | Character appearance system |
Dev/StateMachine/ | ASM_, AST_, PSM_, COND_ assets (~100+ files) | All hookable state events |
Dev/weapons/ | Weapon Blueprints and physics assets | Custom weapon authoring |
Dev/Creatures/ | Creature Blueprints (Panther method templates) | Custom creature base classes |
Content/Forms/. Bridge Form Assets
| Path | Contents | Notes |
|---|---|---|
Forms/actors/creature/ | 1,297 creature form assets | TSMI targets for creature FormIDs |
Forms/actors/leveledcreature/ | 705 leveled creature forms | |
Forms/actors/npc/ | 5,921 NPC form assets | TSMI targets for NPC FormIDs |
Forms/actors/race/ | 15 race form assets | AllRaceModifications DataTable targets |
Forms/miscellaneous/dialog/ | TESTopicInfo assets, all dialogue responses | TSMI dialogue mapping targets; template for custom dialogue |
Forms/items/ | All item form assets by type | TSMI targets for item FormIDs |
Content/WwiseAudio/. Audio Assets
| Path | Contents | Notes |
|---|---|---|
WwiseAudio/Events/Voice/oblivion/[race]/[m|f]/ | All voice line AkAudioEvent assets | Template for custom voice events; naming convention here |
WwiseAudio/Event/English(US)/ | 1,331 .bnk soundbank files | One per voice event, loose files in Audio pak |
WwiseAudio/Media/English(US)/ | 94,453 .wem media files | Named by MediaId (numeric hash), loose files in Audio pak |
WwiseAudio/Character/ | Foley, Footstep, Vocal banks | Character sound effects |
WwiseAudio/Bus/ | Bus hierarchy | Route custom audio to correct bus |
Content/Maps/. Level Files
| Path | Contents | Notes |
|---|---|---|
Maps/World/ | All exterior world umaps (6 variants per area) | Naming: L_[AreaName]World[_Del/_Env/_Li/_SD/_VFX].umap |
Maps/VerticalSlice/ | Interior cell umaps | MagicLoader targets; template for custom interiors |
Content/Art/. Visual Assets
| Path | Contents | Notes |
|---|---|---|
Art/Animation/Humanoid/Facial/ | All facial AnimSequences for lip sync | Named A_[questid]_[topic]_[formid]_[index]; one per voice line per race |
Art/Animation/Humanoid/ | Full body animations | Replacer targets |
Engine Internals
NPCs, Races, Faces & Hair
The full character pipeline: the TESNPC form, the bridge component stack on every humanoid, the 54-axis face morph system, race forms and FaceGen math, the dual hair asset trees, clothing/armor body-part slots, and the aging/vampire (senescence) system.
NPC Architecture bridge
Component Hierarchy (Confirmed)
Every humanoid NPC Blueprint (e.g. BP_Generic_NPC, BP_NPC_SOUL, specific named NPCs) inherits from VPairedCharacter → VPairedPawn → ACharacter. The standard component set:
| Component Name | Class | Role | Attachment |
|---|---|---|---|
| AudioComponent | VAltarAkComponent | Voice audio, only AkComponent per NPC | CharacterMesh0 |
| HeadComponent | VHumanoidHeadComponent | Head mesh, hair, facial pose, speech2face (native) | CharacterMesh0 |
| SoundPairingComponent | VPawnSoundPairingComponent | Dialogue bank lifecycle management | Root |
| CharacterMesh0 | USkeletalMeshComponent | Body mesh | Root |
VPairedPawn. The Bridge Pawn
All characters (player and NPCs) are VPairedPawn subclasses. Key facts:
- When mounted,
UEHelpers.GetPlayer()returns the horse, not the rider - The rider is accessible as
BP_OblivionPlayerCharacter_C VPairedPawn:TryLinkConversationIdle, fires when NPC enters conversation idle state (registered but timing unreliable in tests)VPairedCharacter:GetVoiceTypeandSetVoiceType, readable/writable voice type enum
BPI_NPC_CustomFadeOut Interface
NPCs implement BPI_NPC_CustomFadeOut_C with one function: CustomFadeOut(). This is the interface for triggering NPC fade-out/disappear sequences. Callable from Lua on any NPC that implements it.
UE4SS Access Patterns (Confirmed Safe vs Unsafe)
| Operation | Status | Notes |
|---|---|---|
FindAllOf("BP_Generic_NPC_C") | safe | Returns array of NPC actors |
actor:IsValid() | safe | Use before any call |
actor:K2_GetActorLocation() | safe | Confirmed safe in all contexts |
actor:GetName() | crashes | SEHs through pcall, never call on NPC actors |
actor:GetFullName() | crashes | Same. SEH through pcall |
actor.AudioComponent | invalid ptr | IsValid=false despite property existing |
GetComponentByClass(class) | invalid ptr | Returns object but GetName() SEHs |
BPF_HasActiveEvents() | safe | On retrieved AkComponent, safe call |
NPC Form Deep Dive bridge
TESNPC Asset Structure
Every NPC in Forms/actors/npc/ is a TESNPC UAsset containing four sub-objects:
| Sub-object | Type | Contains |
|---|---|---|
| BaseData | TESActorBaseData | Level, ActorBaseFlag, faction data |
| [EditorID] | TESNPC | Race, sex, name, Blueprint path, hair/eyes, FaceGen data, FormID |
| Phenotype | VCharacterPhenotypeData | Custom head mesh + hair (unique NPCs only; generic = empty) |
| Enchant | VEnchantSaveData | Enchantment state (usually empty) |
Two NPC Tiers. Named vs Generic
There are two fundamentally different NPC types, differing in how they handle appearance:
Unique Named NPCs (Jauffre, AlessiaCaro, Anguilon)
bUseDefaultRaceAndSexPreset: false, overrides race default appearancebUseProceduralHead: false, uses a custom sculpted head SkeletalMesh- Phenotype sub-object contains
FaceBaseMeshpointing to a uniqueSK_[Name]_Headasset - Phenotype contains explicit
Hairpointing to aVCharacterHairPiece_Hairasset - Example. Jauffre:
SK_Jauffre_Headat/Game/Art/Character/Jauffre/, hairHP_Nord_HR_BaldPony
Generic / Procedural NPCs (Guards, Foresters, TESTImperial)
bUseDefaultRaceAndSexPresetnot set (defaults to true), uses race's default phenotype presetbUseProceduralHeadnot set (defaults to true), head generated fromOblivionFaceGenDataOffsetmorph data- Phenotype sub-object is empty, appearance driven entirely by FaceGen float arrays
- Hair/Eyes set in main TESNPC object, not Phenotype
- The
OblivionFaceGenDataOffsetcontains three float arrays:SymmetricalGeometryData(50 values),AsymmetricalGeometryData(30 values),TextureData(50 values)
TESNPC Key Fields Reference
| Field | Type | Example / Notes |
|---|---|---|
| InheritedRace | TESRace ref | TESRace'Imperial' → /Game/Forms/actors/race/Imperial.0 |
| Sex | ECharacterSex enum | Omit for Male (default), set ECharacterSex::FEMALE explicitly |
| FullName | String | Display name, shown in dialogue, crosshair |
| m_formID | Int (decimal) | Decimal FormID, e.g. Jauffre = 145817 = 0x000239D9 |
| m_formEditorID | String | EditorID, must be unique, no spaces |
| m_formType | Enum | Always "FormID::NPC__ID" for NPCs |
| Blueprint | Asset soft ref | /Game/Forms/actors/npc/BP_[EditorID].BP_[EditorID] |
| BlueprintClass | Asset soft ref | /Game/Forms/actors/npc/BP_[EditorID].BP_[EditorID]_C |
| Hair | Asset soft ref | /Game/Forms/miscellaneous/hair/[HairName].[HairName] |
| Eyes | Asset soft ref | /Game/Forms/miscellaneous/eyes/[EyeName].[EyeName] |
| BSXFlags | Int | Always 7. Bethesda flags, don't change |
| Mass | Float | Always 27.21554 for humanoids |
| ActorBaseFlag | Int (bitmask) | 0 = no flags, 41106 = unique, 1048728 = respawning guard/generic |
Complete NPC Blueprint Component List
Confirmed identical across all 4 NPC Blueprints, this is the canonical component set for every humanoid NPC:
| Property Name | Component Class | Internal Name |
|---|---|---|
| MainSkeletalMeshComponent / Mesh | SkeletalMeshComponentBudgeted | CharacterMesh0 |
| AkAudioComponent | VAltarAkComponent | AudioComponent |
| HumanoidHeadComponent | VHumanoidHeadComponent | Head Component |
| PawnSoundPairingComponent | VPawnSoundPairingComponent | Pawn Sound Pairing Component |
| CharacterBodyPairingComponent | VCharacterBodyPairingComponent | Character Body Pairing Component |
| CharacterAppearancePairingComponent | VCharacterAppearancePairingComponent | Appearance |
| AnimationPairingComponent | VAnimationPairingComponent | Animation Pairing Component |
| TransformPairingComponent | VTransformPairingComponent | TransformPairingComponent |
| WeaponsPairingComponent | VWeaponsPairingComponent | Weapons Pairing Component |
| OblivionActorStatePairingComponent | VCharacterStatePairingComponent | Oblivion Actor State Pairing Component |
| ActorValuesPairingComponent | VActorValuesPairingComponent | ActorValuesPairingComponent |
| ActiveEffectsPairingComponent | VActiveEffectsPairingComponent | ActiveEffectsPairingComponent |
| DockingPairingComponent | VDockingPairingComponent | Docking Pairing Component |
| StateMachineComponent | VPairedPawnStateMachineComponent | State Machine |
| PairedPawnMovementComponent / CharacterMovement | VPairedPawnMovementComponent | CharMoveComp |
| MergedMeshComponent | VMergedSkeletalMeshComponent | Merged Mesh Component |
| PhysicsControllerComponent | VPhysicsControllerComponent | PhysicsControllerComponent |
| PhysicalAnimationComponent | VPhysicalAnimationComponent | PhysicalAnimationComponent |
| HumanoidMotionWarpingComponent | MotionWarpingComponent | Motion Warping Component |
| TESRefComponent | VTESObjectRefComponent | TESRefComponent |
| CharacterFadeInOutComponent | VCharacterFadeInOutComponent | Fade In/Out component |
| PhenotypeData | VCharacterPhenotypeData | Phenotype |
| CapsuleComponent / RootComponent | CapsuleComponent | CollisionCylinder |
| PhysicsBodyCollider | CapsuleComponent | PhysicsBodyCollider |
| WorldLimitDetectionBox | BoxComponent | Border Region Collider |
| FakeRoot | SceneComponent | FakeRootComp |
Phenotype System visual
Face Morph Axes. Complete List
The FaceMorphsSource_Human asset defines 54 bilateral morph axes. Each axis has a negative morph target name and a positive one. The OblivionFaceGenDataOffset float arrays index into these. This is what drives procedural face generation for generic NPCs.
| Axis Label | Negative Morph | Positive Morph |
|---|---|---|
| Brow Ridge Low / High | BrowRidgeLow | BrowRidgeHigh |
| Brow Ridge Inner Down / Up | BrowRidgeInnerD | BrowRidgeInnerU |
| Brow Ridge Outer Down / Up | BrowRidgeOuterD | BrowRidgeOuterU |
| Cheekbones Shallow / Pronounced | CheekbonesShall | CheekbonesProno |
| Cheekbones Thin / Wide | CheekbonesThin | CheekbonesWide |
| Cheekbones Low / High | CheekbonesLow | CheekbonesHigh |
| Cheeks Concave / Convex | CheeksConcave | CheeksConvex |
| Cheeks Gaunt / Round | CheeksGaunt | CheeksRound |
| Chin Backward / Forward | ChinBackward | ChinForward |
| Chin Recessed / Pronounced | ChinRecessed | ChinPronounced |
| Chin Retracted / Jutting | ChinRetracted | ChinJutting |
| Chin Shallow / Deep | ChinShallow | ChinDeep |
| Chin Small / Large | ChinSmall | ChinLarge |
| Chin Short / Tall | ChinShort | ChinTall |
| Chin Thin / Wide | ChinThin | ChinWide |
| Eyes Down / Up | EyeDown | EyeUp |
| Eyes Small / Large | EyeSmall | EyeLarge |
| Eyes Inward / Outward | EyeTiltInward | EyeTiltOutward |
| Eyes Apart / Together | EyeApart | EyeTogether |
| Face Light / Heavy | FaceLight | FaceHeavy |
| Face Gaunt / Round | FaceGaunt | FaceRound |
| Face Thin / Wide | FaceThin | FaceWide |
| Forehead Small / Large | ForeheadSmall | ForeheadLarge |
| Forehead Short / Tall | ForeheadShort | ForeheadTall |
| Forehead Back / Forward | ForeheadTiltB | ForeheadTiltF |
| Jaw Retracted / Jutting | JawRetracted | JawJutting |
| Jaw Thin / Wide | JawThin | JawWide |
| Jaw Neck Slope Low / High | JawNeckSlopeL | JawNeckSlopeH |
| Jaw Concave / Convex | JawlineConcave | JawlineConvex |
| Mouth Drawn / Pursed | MouthDrawn | MouthPursed |
| Mouth Sad / Happy | MouthSad | MouthHappy |
| Mouth Low / High | MouthLow | MouthHigh |
| Mouth Deflated / Inflated | MouthDeflated | MouthInflated |
| Mouth Small / Large | MouthSmall | MouthLarge |
| Mouth Retracted / Puckered | MouthRetracted | MouthPuckered |
| Mouth Retracted / Protruding | MouthRetract | MouthProtruding |
| Mouth Tilt Down / Up | MouthTiltDown | MouthTiltUp |
| Mouth Underbite / Overbite | MouthUnderbite | MouthOverbite |
| Nose Bridge Shallow / Deep | NoseBridgeShall | NoseBridgeDeep |
| Nose Bridge Short / Long | NoseBridgeShort | NoseBridgeLong |
| Nose Down / Up | NoseBridgeDown | NoseBridgeUp |
| Nose Flat / Pointed | NoseFlat | NosePointed |
| Nose Short / Long | NoseShort | NoseLong |
| Nose Tilt Down / Up | NoseTiltDown | NoseTiltUp |
| Vampire | Vampire | Vampire |
| Hair Length | HairLengthShort | HairLengthLong |
Skin Parameter Slots (Confirmed)
The SkinParameterCollection_Humans asset defines material parameter slots by primitive index. Key ones for custom NPC skin/hair authoring:
| Parameter | Slot | Type | Notes |
|---|---|---|---|
| Complexion | 0 | Simple | Face + body |
| Skin Tone | 1-3 | Color (3 slots) | Face + body |
| Eyeliner Tone | 4-6 | Color | |
| Eye socket Bruised/Bright | 7 | Simple | |
| Eyebrows Color Intensity | 9 | Simple | |
| Eyebrows Redness Intensity | 10 | Simple | |
| Cheek Blush | 11 | Simple | |
| Freckles | 17 | Simple | |
| Salt And Pepper (hair) | 18 | Simple | Hair greying |
| Root DyeColor | 21-23 | Color | Default #0F0400 |
| Tip DyeColor | 24-26 | Color | Default #0F0602 |
| Lipstick Color | 27-29 | Color | Default #560319 |
| Khajiit Pattern Number | 30 | Simple | Has Vampire/Sick modifiers |
| Root Color | 31-33 | Simple | Hair root |
| Tip Color | 33 | Simple | Hair tip |
| Eyelids Pale/Red | 35 | Simple |
Face Material Slots
| Slot Name | Index |
|---|---|
| Eye | 0 |
| FaceSkin | 1 |
Race Form Architecture bridge
TESRace Asset Structure
Race assets live at /Game/Forms/actors/race/[RaceName].[RaceName]. Each contains one TESRace object.
TESRace Key Fields
| Field | Imperial example | Nord example | Notes |
|---|---|---|---|
| m_formID | 2311 (0x00000907) | 140541 (0x000224FD) | Gamebryo FormID |
| m_formType | "FormID::RACE_ID" | Always this for races | |
| Data.Height [M, F] | [1.0, 1.0] | [1.06, 1.06] | Scale relative to Imperial baseline |
| MaleFullBodies[0].FullBodySkeletalMesh | SK_Imperial_Body_m | SK_Nord_Body_m | /Game/Art/Character/[Race]/ |
| FemaleFullBodies[0].FullBodySkeletalMesh | SK_Imperial_Body_f | SK_Nord_Body_f | |
| MaleFullBodies[0].PhenotypePreset | PhenotypePreset_Imperial_m | PhenotypePreset_Nord_m | /Game/Dev/Phenotypes/ |
| FemaleFullBodies[0].PhenotypePreset | PhenotypePreset_Imperial_f | PhenotypePreset_Nord_f | |
| Senescence DataTable | DT_Senescence_Imperial_m/f | DT_Senescence_Nord_m/f | /Game/Dev/Phenotypes/Senescence/[Race]/ |
MeanOblivionFaceGenData. The Race Average Face
Each race has a MeanOblivionFaceGenData that defines the "average" face for that race. Individual NPC OblivionFaceGenDataOffset values are offsets from this mean.
- Imperial mean: SymmetricalGeometryData = all zeros (perfectly average), TextureData = 50 non-zero values (specific skin texture biases)
- Nord mean: SymmetricalGeometryData = 50 non-zero values (Nords have distinct facial geometry vs baseline), TextureData = 50 non-zero values
Senescence / Aging System
Each race+sex combination has a DT_Senescence_[Race]_[m|f] DataTable at /Game/Dev/Phenotypes/Senescence/[Race]/. This controls age-related appearance changes. The EVSenescenceModifiers enum:
| Value | Effect |
|---|---|
| Healthy | Normal healthy appearance |
| Sick | Yellow sclera, veins visible (eye params: ScleraTintU=1, VeinsPower=1.72) |
| Vampire_01/02/03 | Override eye material to vampire eyes (MIC_[Race]_Eyes_Vampire) |
| EVSenescenceModifiers_MAX | Final vampire stage |
Nord only has Sick modifier defined, no vampire eye variants. Imperial has all four vampire stages pointing to MIC_Imperial_Eyes_Vampire.
All Playable Races. Asset Path Reference
| Race | Form path | Art path | Height |
|---|---|---|---|
| Imperial | /Game/Forms/actors/race/Imperial | /Game/Art/Character/Imperial/ | 1.0 (baseline) |
| Nord | /Game/Forms/actors/race/Nord | /Game/Art/Character/Nord/ | 1.06 |
| Breton | /Game/Forms/actors/race/Breton | /Game/Art/Character/Breton/ (inferred) | TBD |
| Redguard | /Game/Forms/actors/race/Redguard | /Game/Art/Character/Redguard/ (inferred) | TBD |
| HighElf | /Game/Forms/actors/race/HighElf | /Game/Art/Character/Elf/ (inferred) | TBD |
| WoodElf | /Game/Forms/actors/race/WoodElf | /Game/Art/Character/Elf/ (inferred) | TBD |
| DarkElf | /Game/Forms/actors/race/DarkElf | /Game/Art/Character/Elf/ (inferred) | TBD |
| Argonian | /Game/Forms/actors/race/Argonian | /Game/Art/Character/Argonian/ (inferred) | TBD |
| Khajiit | /Game/Forms/actors/race/Khajiit | /Game/Art/Character/Khajiit/ (inferred) | TBD |
| Orc | /Game/Forms/actors/race/Orc | /Game/Art/Character/Orc/ (inferred) | TBD |
Hair System visual
VCharacterHairPiece_Hair Architecture
Hair assets are VCharacterHairPiece_Hair objects at /Game/Dev/Phenotypes/Hair/. Each defines a hair style that works across multiple races and sexes. The system has two levels of mesh selection:
- Default mesh (
HairSkeletalMeshes.MeshComponent), used for any race/sex combination not explicitly listed - Race+Sex overrides (
RaceAndSexSpecificHairMeshes[]), specific meshes for specific race/sex combos
Example: HP_Human_HR_Short
Default mesh: SK_Imperial_HR_Martin (male Imperial base) → Used for all male humans not in override list Female override (Imperial/Breton/Redguard/Nord/HighElf/WoodElf/DarkElf): → SK_Imperial_HR_Martin_f (female-shaped version) Male Elf override (HighElf/WoodElf/DarkElf): → SK_Elf_HR_Martin (elf-shaped male version)
Complete Hair Asset Catalog (Confirmed from screenshot)
All assets at /Game/Dev/Phenotypes/Hair/:
Argonian Hair/Head Pieces
HP_Argonian_HR_BroadWebbedEars, HP_Argonian_HR_CentralSpineCluster_Jeweled, HP_Argonian_HR_CentralSpineCluster, HP_Argonian_HR_CentralSpineCrest_Jeweled, HP_Argonian_HR_CentralSpineCrest, HP_Argonian_HR_Curved, HP_Argonian_HR_DualForeheadHorns_Jeweled, HP_Argonian_HR_DualForeheadHorns, HP_Argonian_HR_Feathers, HP_Argonian_HR_Fins, HP_Argonian_HR_FinsJeweled, HP_Argonian_HR_Ridge, HP_Argonian_HR_SpikePaerialArray_Jeweled, HP_Argonian_HR_SpikePaerialArray, HP_Argonian_HR_Spikes_Jeweled, HP_Argonian_HR_Spikes, HP_Argonian_HR_SpikesDecorated, HP_Argonian_HR_Spines_Jeweled, HP_Argonian_HR_Spines, HP_Argonian_HR_Straigth, HP_Argonian_HR_WebbedSpikeCrest
Race-Specific Hair
HP_Breton_HR_Tonsure, HP_DarkElf_HR_Fringe, HP_DarkElf_HR_Mane, HP_DarkElf_HR_Mohawk, HP_Dremora_HR_Female, HP_Dremora_HR_Hair, HP_Dremora_HR_HairB, HP_Dremora_HR_HairLord, HP_Elf_HR_Braid, HP_Elf_HR_PonyTail, HP_HighElf_HR_Bun, HP_HighElf_HR_Classic, HP_HighElf_HR_Cone, HP_HighElf_HR_Peak, HP_HighElf_HR_Pony
General Human Hair (HP_HR_ prefix = race-agnostic)
HP_HR_Blindfold, HP_HR_Cropped, HP_HR_Loose, HP_HR_MediumLength, HP_HR_Ponytail, HP_HR_PonytailTwist
Human Hair (HP_Human_HR_ prefix)
HP_Human_HR_Fringes, HP_Human_HR_MediumMohawk, HP_Human_HR_MessyBalding, HP_Human_HR_Short, HP_Human_HR_ShortPulledBack, HP_Human_HR_ShortSlick
Imperial-Specific
HP_Imperial_HR_Bald, HP_Imperial_HR_Coil, HP_Imperial_HR_Headband
Khajiit
HP_Khajiit_HR_Braids, HP_Khajiit_HR_Common, HP_Khajiit_HR_Dreds, HP_Khajiit_HR_EarRings, HP_Khajiit_HR_Feathers, HP_Khajiit_HR_HeadBand, HP_Khajiit_HR_Jeweled, HP_Khajiit_HR_Mane, HP_Khajiit_HR_Wisps
New/DLC Hair (HP_New_HR_ prefix)
HP_New_HR_Bob, HP_New_HR_Buns, HP_New_HR_Casual, HP_New_HR_Long, HP_New_HR_LongMohawk, HP_New_HR_Messy, HP_New_HR_Receding, HP_New_HR_ShortPony, HP_New_HR_Stylish
Special
HP_Bald, the bald/shaved head option
Hair Form ESP Assets brain
The Two Hair Asset Systems
Hair in OBR has two parallel asset trees that must correspond:
| System | Path | Type | Used in |
|---|---|---|---|
| Gamebryo hair forms | /Game/Forms/miscellaneous/Hair/ | TESHair form | TESNPC.Hair field (ESP side) |
| UE hair pieces | /Game/Dev/Phenotypes/Hair/ | VCharacterHairPiece_Hair | VCharacterPhenotypeData.Hair (UE side) |
The TESNPC.Hair form references the Gamebryo hair record. The VCharacterPhenotypeData.Hair references the UE mesh asset. They map to each other, changing one without the other gives mismatched results.
Complete Hair Form Catalog (Forms/miscellaneous/Hair/)
Argonian
ArgonianDecoratedSpikes, ArgonianFins, ArgonianJeweledFins, ArgonianRidge, ArgonianSpikes, ArgonianSpines
General / Multi-race
Blindfold, Loose, MediumLength, Ponytail, PonytailTwist
Race-Specific
BretonTonsure, Cropped, DarkElfFringe, DarkElfMane, DarkElfMohawk, DarkElfTopknot, dremoraHair, DremoraHairB, DremoraHairLord, ElfBraid, ElfPonytail, HighElfBun, HighElfClassic, HighElfCone, HighElfPeak, HighElfpony, HumanFringes, ImperialBald, ImperialHeadband
Khajiit
KhajiitBraids, KhajiitCommon, KhajiitDreds, KhajiitEarrings, KhajiitFeathers, KhajiitHeadBand, KhajiitJeweled, KhajiitMane, KhajiitWisps
Nord
NordBaldPony
Orc
OrcBraids, OrcBun, OrcHeadband, OrcOneBraid, OrcPlaits, OrcRomantic, OrcStubs, OrcTopknot, OrcTwoBraids, OrcUpdo
Redguard
RedguardClassic, RedguardCoil, RedguardCornrows, RedguardDredz
Wood Elf
WoodElfFringes, WoodElfPony, WoodElfSpiky
Custom NPC Hair. Both Sides Required
When assigning custom hair to an NPC:
- Set
TESNPC.Hairin the ESP to one of the Form paths above (e.g./Game/Forms/miscellaneous/Hair/MediumLength.MediumLength) - Set
VCharacterPhenotypeData.Hairin the Blueprint to the corresponding HP_ asset (e.g.HP_HR_MediumLength), only needed for unique named NPCs with bUseProceduralHead=false - Generic NPCs (procedural head) use the Hair form reference only, the HP_ is selected automatically by the race/sex system
Clothing System visual
Body Part Definition Blueprints (BP_Generic_BDP_)
Located at /Game/Dev/clothing/. These define the body part slots that clothing and armor occupy:
| Asset | Body part | Notes |
|---|---|---|
| BP_Generic_BDP_Amulet | Neck/amulet slot | Used for cape-via-amulet workaround |
| BP_Generic_BDP_Feet | Boots/feet | |
| BP_Generic_BDP_Hands | Gloves/gauntlets | |
| BP_Generic_BDP_LowerBody | Pants/greaves | |
| BP_Generic_BDP_Ring | Ring slot | |
| BP_Generic_BDP_Robe | Full robe (overrides upper+lower) | |
| BP_Generic_BDP_SkeletalHelmet | Helmet with bones (animated) | |
| BP_Generic_BDP_StaticHelmet | Rigid helmet | |
| BP_Generic_BDP_UpperBody | Cuirass/chest | |
| BP_Generic_Static_Cloth | Static cloth item | |
| BP_Generic_Activable_Cloth | Cloth with interaction |
Head Morph Caping (Helmet Fit)
Two special Blueprint assets handle how helmets deform to fit different head shapes:
HeadMorphCaping_Generic_BDP_SkeletalHood, morphs skeletal (animated) hoods to fit the wearer's head morphHeadMorphCaping_Generic_BDP_StaticHelmet, morphs rigid helmets to fit
Clothing System Components
| Asset | Type | Purpose |
|---|---|---|
| BPC_Cloth_Scalabilty | Blueprint Component | LOD/scalability for cloth physics |
| BPE_ClothAssetPriority | Blueprint Enum | Priority when multiple cloth items overlap |
| BPE_ClothingInstanceType | Blueprint Enum | Type of clothing instance (static, skeletal, etc.) |
| BPI_ClothPhysicsControl | Blueprint Interface | Interface for controlling cloth physics at runtime |
| BPS_ClothAssetAndMaterialSections | Blueprint Struct | Struct grouping cloth mesh with its material sections |
Senescence System (Aging & Vampire) visual
Architecture. Three Asset Types
The aging/senescence system uses a three-tier asset hierarchy per race+sex:
TESRace.MaleFullBodies[0].Senescence → DT_Senescence_[Race]_[m|f] (DataTable, 8 rows) Row 1..8 → DA_Senescence_[Race]_[m]_01..08 (VSenescenceLevel) DA_Senescence_[Race]_[M]_Body_01/08_00 (body textures)
DT_Senescence_Imperial_m. Confirmed Structure
8 rows, each representing an aging stage. Each row has:
| Field | Content |
|---|---|
| HeadSenescences | Array of {Key, VSenescenceLevel asset path}, one head texture set per stage |
| BodySenescences | Array of {Key, VSenescenceLevel asset path}, body texture variant |
| HairSenescences | Always empty in Imperial, hair aging not implemented |
Rows 1-5 use body variant DA_Senescence_Imperial_M_Body_01_00. Rows 6-8 switch to DA_Senescence_Imperial_M_Body_08_00, the older body texture set. 8 aging stages total for Imperial male.
VSenescenceLevel (DA_). Per-Stage Textures
Each DA_Senescence_[Race]_[m]_XX asset defines what textures to use for each health/vampire state at that aging stage:
| State | Contents in DA_01 |
|---|---|
| HealthyTexture | Empty, uses race default skin textures |
| SickTexture | BaseColor Map: T_Imperial_Head_M_01_D, NNR Map: T_Imperial_Head_M_01_NNRS |
| VampireTexture[0] (Stage 1) | T_WhiteHumanoid_Vampire_Head_M_01_Stage01_D, no phenotype override |
| VampireTexture[1] (Stage 2) | T_WhiteHumanoid_Vampire_Head_M_01_Stage01_D, no phenotype override |
| VampireTexture[2] (Stage 3) | T_WhiteHumanoid_Vampire_Head_M_01_Stage02_D, phenotype override → PhenotypePreset_Vampire |
PhenotypePreset_Vampire at /Game/Dev/Phenotypes/PhenotypePreset_Vampire. This is a separate phenotype preset that applies vampire-specific face morphs (gaunt cheeks, prominent brow etc). This asset exists and is referenced, worth dumping for the full vampire appearance pipeline.
Texture Naming Convention
Head textures: T_[Race]_Head_[M|F]_[Stage]_[D|NNRS] Body textures: T_[Race]_Body_[M|F]_[Variant]_[Stage]_[suffix] Vampire textures: T_WhiteHumanoid_Vampire_Head_[M|F]_[Stage]_[D] Location: /Game/Art/Character/[Race]/
CK Implication: Custom Race Aging
For a custom race that reuses an existing race's body+head meshes, the simplest approach is to point its Senescence DataTable at an existing race's DA_ assets. The textures will be wrong (wrong race skin) but the system will function. For fully custom aging: create 8 DA_ assets per gender, each with the appropriate skin texture paths for your custom head mesh.
Engine Internals
Dialogue, Voice & Lip Sync
The dialogue system end to end: TESTopicInfo as the Rosetta Stone between Gamebryo dialogue and UE audio/animation, the working PostEventAtLocation audio path, and the resolution of the speech2face mystery (lip sync is a baked AnimSequence, not audio analysis).
Dialogue System Internals bridge
The TESTopicInfo Asset. The Rosetta Stone
Every dialogue response in Oblivion Remastered is backed by a TESTopicInfo UE asset. This asset is the bridge between Gamebryo's dialogue records and UE's audio/animation systems. It lives in /Game/Forms/miscellaneous/dialog/.
A single response entry contains three things simultaneously:
{
"ResponseID": 1,
"Text": "I can manage with a weapon or with spells...",
"AkAudioEvents": [ // ← Wwise event, keyed by Race+Sex+VoiceType
{
"Key": { "Sex": "MALE", "Race": "/Game/Forms/actors/race/Nord.Nord",
"VoiceType": "EVVoiceType::LEGACY" },
"Value": "/Game/WwiseAudio/Events/Voice/oblivion/nord/m/Play_nord_m_[topic]_[formid]_1"
}
],
"Animations": [ // ← Facial AnimSequence for lip sync
{
"Key": { "Sex": "MALE", "Race": "/Game/Forms/actors/race/Nord.Nord" },
"Value": "/Game/Art/Animation/Humanoid/Facial/Iver/A_[topic]_[formid]_1"
}
]
}
AnimSequence stored in /Game/Art/Animation/Humanoid/Facial/, referenced directly from the TESTopicInfo asset. For custom NPCs: generate the AnimSequence via Audio2Face (L11 of the modding wiki), point the TESTopicInfo at it, done.
Custom Dialogue Pipeline. Complete
- Author dialogue topic in xEdit / CK GUI → get FormID (e.g.
XX000001) - Record/generate voice line WAV → cook to Wwise event → get event UE asset path
- Run WAV through Audio2Face → export facial AnimSequence → cook to UE asset
- Create
TESTopicInfoUAsset with text + Wwise event path + AnimSequence path - Register via TSMI:
GREETING_MyMod_XX000001=/Game/Forms/miscellaneous/dialog/MyTopic.MyTopic - NPC's race + voice type determines which AkAudioEvent and Animation entry the game picks
Runtime Hook Architecture (CosmicBridge DialogueAPI)
For mods that intercept existing NPC dialogue rather than adding new topics, the hook chain is:
- 1. SetVDialogueMenuViewModel. UI binds (speaker/subtitle EMPTY at this point)
- 2. AST_Dialogue:OnEntered, camera state transitions to dialogue
- 3. AST_Dialogue:OnStateUpdate, fires every frame during dialogue
- 4. VDialogueMenuViewModel:GetSubtitle, fires when each line updates (non-empty check required)
- 5. AST_Dialogue:OnExited, dialogue ends
Readable VM Properties (All Confirmed Working)
| Function | Returns | Notes |
|---|---|---|
GetSpeakerName():ToString() | String e.g. "Jauffre" | FText, requires :ToString() |
GetSubtitle():ToString() | Current line text | Empty until line loads (~1 frame after OnEntered) |
GetResponses() | Table of response options | Player dialogue choices |
IsSubtitleVisible() | Bool | Reflects user subtitle setting |
Bank Residency. The Indoor/Outdoor Difference
Custom Wwise banks are not resident in interior cells. PostEventAtLocation returns playing_id=0 indoors consistently. Outdoors (tested with Chorrol Guard), playing_id is non-zero and audio fires correctly. Root cause: pak mounting differs between interior and exterior level loads. Fix pending, investigate pak mount triggers per level type.
speech2face. Confirmed Not Audio-Driven
speech2face lives in VHumanoidHeadComponent as pure native C++. It has zero exposed UFunctions for speech/lip sync control. It does not respond to PostEventAtLocation audio regardless of content. For custom dialogue, use the TESTopicInfo AnimSequence path instead, that's the intended system and it works.
Audio Architecture visual
The NPC Voice Component
Every humanoid NPC has exactly one voice component: VAltarAkComponent named AudioComponent, attached to CharacterMesh0. This is a custom Altar subclass of UAkComponent with only two additional UFunctions:
| Function | Notes |
|---|---|
BPF_HasActiveEvents() | Returns bool, whether audio is currently playing on this component. Safe to call. |
ForceUpdateGameObjectPosition() | Forces Wwise to update the game object's 3D position. |
PostEventAtLocation. The Working Method
local AkStatics = StaticFindObject("/Script/AkAudio.Default__AkGameplayStatics")
local ev = StaticFindObject("/Game/WwiseAudio/Events/YourEvent/YourEvent.YourEvent")
local loc = actor:K2_GetActorLocation()
local id = AkStatics:PostEventAtLocation(ev, loc, {Pitch=0,Yaw=0,Roll=0}, world)
-- id > 0 = success, id = 0 = bank not resident
Voice Event Asset Structure (Confirmed from FModel)
Each voice line gets its own dedicated bank. Not a shared bank, one bank per event.
| Field | Value / Pattern |
|---|---|
| Event naming | Play_[race]_[gender]_[questid]_[topic]_[formid]_[index] |
| Bank location | Event/English(US)/[EventName].bnk |
| Media location | Media/English(US)/[MediaId].wem |
| bContainsMedia | false, media is always separate |
| bStreaming | false |
| MaxAttenuationRadius | 3000.0 |
| UE asset path | /Game/WwiseAudio/Events/Voice/oblivion/[race]/[m|f]/[EventName] |
Two-Pak Structure
| Pak | Tool | Contains |
|---|---|---|
MyMod_P.pak | retoc | Cooked .uasset + .ubulk for AkAudioEvent assets |
MyMod_Audio_P.pak | UnrealPak | Loose .bnk and .wem files at their full content paths |
AkGameplayStatics. Full UFunction Surface
64 functions confirmed loaded at runtime. Key ones for mod use:
| Function | Status | Notes |
|---|---|---|
PostEventAtLocation | working | Primary audio post method from Lua |
GetAkComponent | broken | Has Out-param bool (ComponentCreated). UE4SS can't satisfy |
GetOrCreateAkComponent | broken | Same Out-param issue |
SpawnAkComponentAtLocation | untested | Expects 8 params, signature not yet confirmed |
SetRTPCValue | untested | Could set game parameters, useful for music/state |
SetState | untested | Wwise states, music layer switching |
StopActor | untested | Stop all audio on an actor, potential voice mute method |
ABP_HumanoidHead. The Head Animation Blueprint visual
Class Hierarchy
ABP_HumanoidHead_C extends: VHumanoidHeadCharacterAnimInstance extends: AnimInstance (UE native)
AnimGraph Node Structure. Confirmed
The AnimGraph uses named cached poses to layer animations in a specific order:
AnimGraph Output └── LayeredBoneBlend (BranchFilter mode) ├── BasePose: "BodyPose" (cached, copied from body skeletal mesh) └── BlendPose[0]: BlendListByBool ├── [False] ApplyAdditive │ ├── Base: "FacialAndEyes Animation" (cached) │ └── Additive: LocalRefPose └── [True] "EmotionPose" (cached) "BodyPose" = AnimGraphNode_CopyPoseFromMesh (bUseAttachedParent=True) "Facial Animation" = AnimGraphNode_Slot (SlotName='DefaultSlot') → sequence player "FacialAndEyes Animation" = Facial Animation + BS_eyes_movement blendspace "EmotionPose" = StateMachine (7 states, emotion transitions)
The Three Facial Layers
| Layer | Cache name | Source | Purpose |
|---|---|---|---|
| Body sync | BodyPose | CopyPoseFromMesh (parent body) | Keeps head attached to body skeleton |
| Lip sync | Facial Animation | AnimSequence played in DefaultSlot | THIS is the lip sync, plays the A_ AnimSequence from TESTopicInfo |
| Eye movement | FacialAndEyes Animation | Facial + BS_eyes_movement blendspace | Adds eye tracking on top of facial anim |
| Emotion | EmotionPose | StateMachine (7 emotion states) | Ambient emotional expressions when not talking |
Emotion StateMachine, 7 States
The emotion StateMachine drives ambient facial expressions. It has 7 sequence player states (AnimGraphNode_SequencePlayer through _6), each playing a different emotion AnimSequence. The Emotions array on the CDO contains the first confirmed entry:
Emotions[0]: AnimSequence'A_EmperorCut_Rig_02_neutral' → /Game/Art/Animation/Humanoid/Facial/Emotions/...
7 emotion states suggests: Neutral, Happy, Sad, Angry, Afraid, Disgusted, Surprised (standard FACS set) or similar.
The DefaultSlot. Lip Sync Entry Point
AnimGraphNode_Slot with SlotName='DefaultSlot' is the exact mechanism that plays facial AnimSequences from TESTopicInfo. When the dialogue system fires a voice line, it plays the corresponding A_[topic]_[formid]_1 AnimSequence into DefaultSlot on the head ABP. This is purely AnimMontage/Slot-based, no Wwise RTPC, no audio analysis, no speech2face native function involved in the mouth movement.
Eye Tracking
AimTarget is a Vector3 property on the ABP CDO (default 0,0,0). EyesBoneName: FACIAL_C_FacialRoot is the bone that gets aim-offset. EyeHeadingMultiplier: 0.5 controls how much the eyes follow targets. The BS_eyes_movement blendspace at /Game/Art/Animation/Humanoid/Eyes/ drives the eye directional movement.
UpdateBodyMesh Function
The ABP has an OnBodyMeshUpdated callback (taking NewBodyMesh: SkeletalMeshComponent) and an UpdateBodyMesh(BodyMesh) function. This is how the head ABP stays synced when the body mesh changes (e.g. armor swaps affecting the neck). Relevant for custom races with non-standard body meshes.
Key Functions (All Blueprint-callable)
| Function | Params | Purpose |
|---|---|---|
| AimHead | (none confirmed) | Updates head aim toward AimTarget |
| UpdateBodyMesh | BodyMesh: SkeletalMeshComponent | Relinks head to new body mesh |
| OnBodyMeshUpdated | NewBodyMesh: SkeletalMeshComponent | Event callback when body changes |
Engine Internals
State Machine, Combat, Input, Effects & Mounts
The character state machine (every hookable AST_/COND_ asset), the player and AI controllers, the visual effects components, the Enhanced Input action map, and how mounts differ from NPCs.
Character State Machine Map visual
Content/Dev/StateMachine/.
Top-Level Structure
ASM_Character is the root. Its initial state is PSM_CharacterMain, tagged State.Character. PSM_CharacterMain runs two parallel sub-machines simultaneously:
- ASM_CharacterLocomotion, all movement states
- ASM_CharacterAction, all combat/interaction states
Action States (All Hookable via RegisterHook)
Each AST_ is hookable with OnEntered, OnExited, OnStateUpdate. Pattern: /Game/Dev/StateMachine/AST_[Name].AST_[Name]_C:[Function]
| State Asset | Trigger | CosmicBridge Event |
|---|---|---|
| AST_CharacterActionLightAttack | Light attack begins | Combat.onLightAttack |
| AST_CharacterActionPowerAttack | Power attack begins | Combat.onPowerAttack |
| AST_CharacterActionBlock | Block raised | Combat.onBlock |
| AST_CharacterActionShieldBash | Shield bash | Combat.onShieldBash |
| AST_CharacterActionBowDrawShoot | Bow drawn + shot | Combat.onBowShoot |
| AST_CharacterActionSpellCasting | Spell cast | Combat.onSpellCast |
| AST_CharacterActionDodge | Dodge | Combat.onDodge |
| AST_CharacterActionGrab | Grab object | Combat.onGrab |
| AST_CharacterDead | Death | Actor.onDeath |
| AST_CharacterKnockDown | Knocked down | Combat.onKnockDown |
| AST_CharacterStaggered | Staggered | Combat.onStagger |
| AST_CharacterParalyze | Paralyzed | Combat.onParalyze |
| AST_CharacterStunned | Stunned | Combat.onStun |
| AST_CharacterVampireFeed | Vampire feeding | Combat.onVampireFeed |
| AST_CharacterDocking | Mounting/docking begins | Actor.onMountBegin |
| AST_CharacterDocked | Mounted/docked | Actor.onMounted |
| AST_CharacterUndocking | Dismounting | Actor.onDismount |
| AST_CharacterResurrect | Resurrection | Actor.onResurrect |
| AST_CharacterGetUp | Getting up from ground | Combat.onGetUp |
| AST_Dialogue (OnEntered) | Dialogue starts | Dialogue.onStart |
| AST_Dialogue (OnExited) | Dialogue ends | Dialogue.onEnd |
Condition Assets (State Query Flags)
COND_ assets are transition condition evaluators. They can be used to query current character state:
| Asset | Tests |
|---|---|
| COND_IsDead | Character is dead |
| COND_IsRidden | Character is being ridden (mounted) |
| COND_IsWeaponDrawn | Weapon is out |
| COND_IsCrouch | Character is sneaking |
| COND_IsPlayer | This is the player character |
| COND_IsInAir | Airborne |
| COND_IsRunning | Running |
| COND_IsSprinting | Sprinting |
| COND_IsSwimming | Swimming |
| COND_IsUnconscious | Unconscious |
| COND_IsDocked | Mounted/docked to a pawn |
| COND_IsInCustomizationMenu | In character creator |
| COND_HasShieldEquipped | Shield in offhand |
| COND_HasBowEquiped | Bow equipped |
| COND_HasFatigueLeft | Has stamina remaining |
| COND_IsOverEncumbered | Over carry weight |
| COND_ShouldPowerAttack | Power attack threshold met |
| COND_ShouldSneak | Sneak mode active |
Controllers & AI Architecture visual
BP_AltarPlayerController
Extends VEnhancedAltarPlayerController. This is the player controller, owns all input mapping contexts and references the player camera manager.
Key Input Actions (All hookable via RegisterHook)
| Action | Asset path fragment | Context |
|---|---|---|
| Attack | IA_Game_Combat_Attack | IMC_Game_Combat |
| Block | IA_Game_Combat_Block | IMC_Game_Combat |
| Cast spell | IA_Game_Combat_Cast | IMC_Game_Combat |
| Grab | IA_Game_Actions_Grab | IMC_Game_Actions |
| Activate | IA_Game_Actions_Activate | IMC_Game_Actions |
| Toggle POV | IA_Game_Actions_TogglePOV | IMC_Game_Actions |
| Jump | IA_Game_Movement_Jump | IMC_Game_Movement |
| Crouch | IA_Game_Movement_Crouch | IMC_Game_Movement |
| Sprint | IA_Game_Movement_Sprint | IMC_Game_Movement |
| Horse gallop | IA_Game_Movement_Gallop | IMC_Game_Movement |
| Telekinesis Push | IA_Game_Telekinesis_Push | IMC_Game_Telekinesis |
| Telekinesis Pull | IA_Game_Telekinesis_Pull | IMC_Game_Telekinesis |
| Quick Save | IA_Game_Default_QuickSave | IMC_Game_Default |
Key Tunable Properties (Engine.ini overrideable)
| Property | Default | Notes |
|---|---|---|
| PowerAttackInputTime | 0.35s | Hold duration for power attack trigger |
| ViewSensitivity | 0.4 | Base camera sensitivity |
| GamepadSensitivityScale | 300.0 | |
| FirstPersonCameraVerticalSensitivityScale | 1.4 | |
| ThirdPersonCameraVerticalSensitivityScale | 1.4 | |
| CameraTrackingBaseSpeed | 90.0 | |
| PlayerCameraManagerClass | BP_AltarPlayerCameraManager_C | Replaceable for custom camera |
| CheatClass | AltarCheatManager | /Script/Altar native class |
ControlledPlayerCharacter
The property ControlledPlayerCharacter (Edit | BlueprintVisible) holds a direct reference to the player character. This is accessible from Lua if the controller object can be retrieved, potentially more reliable than UEHelpers.GetPlayer() in edge cases.
BP_PairedPawnAIController
Extends VPairedPawnAIController. Controls all NPC AI. Key architecture:
VSteeringBehaviorsComponent, handles NPC movement/steering AIVAltarPathFollowingComponent, custom path following- Navigation filter classes:
NQF_Default_C,NQF_CannotSwim_C,NQF_CanOnlySwim_C,NQF_FollowPlayerCharacter_C GetProcedureInfos(). Blueprint callable, returns AI procedure debug info
/Game/Dev/Navigation/ determine which navmesh areas an NPC can traverse. Custom NPC AI packages should reference the appropriate filter, most humanoids use NQF_Default_C.
Effects System visual
BPC_BoundEffect. Invisibility & Fade
Component that handles NPC fade-in/out for bound/summoned creatures and invisibility effects. Extends ActorComponent.
| Function | Notes |
|---|---|
| FadeIn() | Fades the actor in using Curve_BoundFadeIn over 0.3s |
| FadeFinished() | Callback when fade completes |
Key properties: PrimitiveComponents[], StaticMeshComponents[], FadeInCurve (CurveFloat at /Game/Dev/PairingActors/Curve_BoundFadeIn), FadeInDuration (default 0.3s).
BPC_StatusEffect. Magic Effect Visuals
The primary component for applying visual status effects (poison, fire, frost, etc.) to characters. Extends ActorComponent. Very complex, 38 child functions, 46 properties.
| Key Function | Signature | Purpose |
|---|---|---|
| Apply Status Effect on Component | (form, VFXColors[], MeshIgnoreList[], StatusEffectValue) | Apply visual effect to a mesh component |
| Remove Status Effect on Component | (form, VFXColors[], MeshIgnoreList[], StatusEffectValue) | Remove visual effect |
| Set Fade Value On All Material Switch MIDs | (Value float) | Control fade of material overlays |
| Create Invisibility Data For Component | (MeshComponent) | Set up invisibility effect on mesh |
| DisableBloodSplatterComponent | (Disable bool) | Toggle blood decal system |
Key properties: Form (the effect form), PawnOwner, OverlayMaterialInterface, OverlayFadeDuration, Overlay_MinimumLifeTime (1.0s default), ApplyMaterialSwitch.
Bridge.Effects.applyTo(actor, effectForm, intensity). The component exists on all VPairedPawn actors. This is also how custom magic visual effects would work for new spell types.
Wwise Aux Bus. EXT_IC_General
Confirmed bus asset from WwiseAudio/Bus/. EXT_IC_General is an AkAuxBus with ID 1726809084. This is an environmental/reverb aux bus (IC = Interior/Exterior, General). Route custom audio to this bus for correct interior acoustic treatment. MaxAttenuationRadius: 0 = global/ambient routing, not spatialized.
Input Action Asset Map visual
All input actions confirmed from BP_AltarPlayerController CDO. Assets located at /Game/Dev/Input/GamePlay/InputActions/.
Input Mapping Contexts
| Context | Asset | Active when |
|---|---|---|
| IMC_Game_Default | IMC_Game_Default | Always |
| IMC_Game_Movement | IMC_Game_Movement | In-world movement |
| IMC_Game_Combat | IMC_Game_Combat | During combat readiness |
| IMC_Game_Actions | IMC_Game_Actions | World interaction |
| IMC_Game_Telekinesis | IMC_Game_Telekinesis | Telekinesis active |
| IMC_Game_QuickKeys | IMC_Game_QuickKeys | Quick item slots |
| IMC_Game_Debug | IMC_Game_Debug | Debug builds |
Hookable Input Actions (CosmicBridge InputAPI. Future)
These IA_ assets can be hooked via UE4SS's Enhanced Input system hooks. Each can fire callbacks on press/release for custom mod behaviors without overriding game controls.
-- Future CosmicBridge InputAPI
Bridge.Input.onAction("IA_Game_Combat_Attack", function(pressed)
if pressed then
-- Custom attack hook
end
end)
Mount Architecture visual
Horse Blueprint Hierarchy
BP_Horse_Black_C extends: BP_Generic_HorseBlack_C (VModdableBlueprintGeneratedClass) extends: VPairedPawn (native, same as NPCs)
Key Differences vs NPC Blueprints
| Component | NPC | Horse |
|---|---|---|
| CharacterMovement | VPairedPawnMovementComponent | VHorseMovementComponent |
| HumanoidHeadComponent | Yes | No |
| MountCameraSpringArmComponent | No | Yes, camera arm for mounted view |
| Replication | Default | bReplicates=false, RemoteRole=ROLE_None |
The horse shares the same pairing component architecture as NPCs: VTransformPairingComponent, VAnimationPairingComponent, VActorValuesPairingComponent, VActiveEffectsPairingComponent, VWeaponsPairingComponent, VPawnSoundPairingComponent. This means horses have health (actor value index 10), can be affected by magic, and have weapon pairing (for the rider's weapon during mounted combat, confirmed from your mounted combat mod work).
Custom Mount Creation
For a custom mount, duplicate BP_Generic_HorseBlack_C (or whichever horse variant fits) and override the skeletal mesh. The VHorseMovementComponent handles all riding physics, no custom movement code needed for basic mounts.
Engine Internals
Weapons & Spells
The weapon Blueprint hierarchy (VModdableBlueprintGeneratedClass, type tags, hitboxes, physics), the TESObjectWEAP form, and the spell system (TESSpell + EffectSettings, and why custom magic routes through SEFF Script Effects).
Weapon System bridge
The Critical Discovery: VModdableBlueprintGeneratedClass
All weapon Blueprints use VModdableBlueprintGeneratedClass instead of the standard BlueprintGeneratedClass. This is Altar's moddable Blueprint class, it supports runtime modification and is the class type TSMI patches. This is why weapons are modifiable at runtime in ways other assets aren't. Every weapon Blueprint you create must use this class.
Weapon Class Hierarchy. Complete
Three tiers: native C++ base → generic Blueprint template → specific weapon type child → individual weapon form
C++ Native (in /Script/Altar): VWeapon ← base for all weapons ├── VWeapon_Blade ← all blade weapons ├── VWeapon_Blunt ← blunt weapons ├── VWeapon_Bow ← bows ├── VWeapon_Staff ← staves └── VShield ← shields VQuiver ← quiver/ammo holder Generic Templates (Dev/weapons/): BP_Weap_GenericBlade → extends VWeapon_Blade BP_Weap_GenericBlunt → extends VWeapon_Blunt BP_Weap_GenericBow → extends VWeapon_Bow BP_Weap_GenericStaff → extends VWeapon_Staff BP_Weap_GenericShield → extends VShield BP_Weap_GenericQuiver → extends VQuiver Variants with scabbards: BP_Weap_GenericBlade_Scabbard → blade + scabbard mesh Specific Type Children (Dev/weapons/GenericChild/): BP_Weap_GenericLongSword → extends BP_Weap_GenericBlade BP_Weap_GenericClaymore → extends BP_Weap_GenericBlade BP_Weap_GenericDagger → extends BP_Weap_GenericBlade BP_Weap_GenericShortSword → extends BP_Weap_GenericBlade BP_Weap_GenericAxe → extends BP_Weap_GenericBlade (likely) BP_Weap_GenericBattleAxe → extends BP_Weap_GenericBlade BP_Weap_GenericWarAxe → extends BP_Weap_GenericBlade BP_Weap_GenericHammer → extends BP_Weap_GenericBlunt BP_Weap_GenericMace → extends BP_Weap_GenericBlunt Scabbard variants: BP_Weap_GenericLongSwordWithScabbard BP_Weap_GenericDaggerWithScabbard BP_Weap_GenericShortSwordWithScabbard Individual Weapon Forms (Forms/items/weapons/): BP_Iron_LongSword → extends BP_Weap_GenericLongSwordWithScabbard ... (one per weapon in the game)
WeaponTypeTag. The Key Identifier
Each weapon Blueprint has a WeaponTypeTag Gameplay Tag that drives animation, combat behavior, and loot generation. The complete confirmed tag set:
| Tag | Blueprint | Locomotion Tag |
|---|---|---|
| WeaponType.OneHanded.Blade.Longsword | BP_Weap_GenericBlade | Actor.Locomotion.MoveSet.OneHanded |
| WeaponType.OneHanded.Blunt.Mace | BP_Weap_GenericBlunt | Actor.Locomotion.MoveSet.OneHanded |
| WeaponType.TwoHanded.Blade.Claymore | BP_Weap_GenericClaymore | Actor.Locomotion.MoveSet.TwoHanded |
| WeaponType.Bow | BP_Weap_GenericBow | Actor.Locomotion.MoveSet.Bow |
| WeaponType.Staff | BP_Weap_GenericStaff | Actor.Locomotion.MoveSet.Staff |
| WeaponType.Shield | BP_Weap_GenericShield | (none, shield has no locomotion tag) |
VHitBoxComponent. Hit Detection
Bladed weapons use VHitBoxComponent for hit detection. The hitbox is a box collider whose dimensions define the blade's hit region:
| Weapon | BoxExtent (X,Y,Z) | RelativeLocation Y | Notes |
|---|---|---|---|
| GenericBlade (base) | 0,0,0 | n/a | Zero default, children override |
| LongSword | 8, 50, 0.7 | -52.74 | Y = blade length offset |
| Claymore | 7, 55, 1.0 | -63.92 | Longer blade |
The Y axis is the blade length. RelativeLocation.Y offsets from the hilt. Blunt weapons and bows do not have VHitBoxComponent, they use different hit detection (likely capsule/sphere from physics).
Mesh Types by Weapon Class
| Weapon type | Mesh type | Notes |
|---|---|---|
| Blades, Blunt, Staff, Shield | StaticMeshComponent | "Main Mesh Component" |
| Bow | SkeletalMeshComponent | Needs ABP_Reverse_Bow animation Blueprint |
| Weapons with scabbard | Two StaticMeshComponents | "Main Mesh Component" + "Scabbard Mesh Component" |
VPhysicsControllerComponent. Weapon Physics
| Setting | Blade/Blunt/Staff/Shield | Bow |
|---|---|---|
| PhysicsSimulationBehaviour | WHEN_UNEQUIPPED | ALWAYS |
| bIsGrabbable | true | true |
| bIsTelekinesisTargetable | true | true |
| DefaultSelfSurfaceType | Metal | Metal |
Shield adds: bDoesUseCCD: true, bCanSnapToEnvironment: true, PhysicsLoadingBehaviour: LOAD_POSE_FROM_SAVE, BuoyancyCoefficient: 0.2
BPC_WeapBloodSplatter
All blade weapons inherit BPC_WeapBloodSplatter from BP_Weap_GenericBlade. Each specific weapon type overrides bloodSplatterTextureSize:
LongSword = 80.0, Claymore = 100.0. Individual weapon Blueprints (like BP_Iron_LongSword) override MICsBloodParentReferences to point to their specific blood splatter material (e.g. MIC_Iron_LongSword_WeapBloodSplatter).
TESObjectWEAP Form. Complete Field Reference
From WeapIronLongsword confirmed:
| Field | Value/Example | Notes |
|---|---|---|
| FullName | "Iron Longsword" | Display name |
| TextureIcon | /Game/ArtOriginal/textures/menus/icons/weapons/T_ironlongsword | Inventory icon, 2D texture |
| Type | EOblivionWeaponType::BLADE_ONE_HAND | See enum below |
| Speed | 1.0 | Attack speed multiplier |
| Mass | 4.5359235 | Weight in lbs (≈ 10 lbs) |
| m_formID | 3084 | Decimal FormID |
| m_formEditorID | "WeapIronLongsword" | EditorID |
| m_formType | "FormID::WEAP_ID" | Always this for weapons |
| Blueprint | /Game/Forms/items/weapons/BP_Iron_LongSword | UE Blueprint asset path |
| BlueprintClass | /Game/Forms/items/weapons/BP_Iron_LongSword_C | The _C class path |
| BSXFlags | 3 | Always 3 for weapons |
| EnchantSaveData | VEnchantSaveData sub-object | Enchantment state |
EOblivionWeaponType Enum
| Value | Weapon type | Blueprint base |
|---|---|---|
| BLADE_ONE_HAND | One-handed sword/dagger | BP_Weap_GenericBlade or children |
| BLADE_TWO_HAND | Claymore/battleaxe | BP_Weap_GenericClaymore etc |
| BLUNT_ONE_HAND | Mace/axe | BP_Weap_GenericBlunt |
| BLUNT_TWO_HAND | Warhammer | BP_Weap_GenericBlunt or child |
| BOW | Bow | BP_Weap_GenericBow |
| STAFF | Staff | BP_Weap_GenericStaff |
Individual Weapon Blueprint. Iron LongSword Pattern
The concrete weapon Blueprint (in Forms/items/weapons/) extends the specific generic type and sets the actual meshes:
BP_Iron_LongSword_C extends BP_Weap_GenericLongSwordWithScabbard_C Overrides: Main Mesh Component.StaticMesh → SM_Iron_LongSword (/Game/Art/Equipment/weapons/Iron/SM_Iron_LongSword) Scabbard Mesh Component.StaticMesh → SM_Iron_LongSword_scabbard (/Game/Art/Equipment/weapons/Iron/SM_Iron_LongSword_scabbard) BPC_WeapBloodSplatter.MICsBloodParentReferences → [MIC_Iron_LongSword_WeapBloodSplatter] Art assets at: /Game/Art/Equipment/weapons/[Material]/ SM_[Material]_[WeaponType] ← weapon mesh SM_[Material]_[WeaponType]_scabbard ← scabbard mesh MIC_[Material]_[WeaponType]_WeapBloodSplatter ← blood MIC
CK Weapon Creator. Complete Pipeline
- Choose weapon type → selects correct Generic base class
- Assign static mesh → overrides "Main Mesh Component"
- Optionally assign scabbard mesh → overrides "Scabbard Mesh Component"
- Set VHitBox dimensions (X=blade width, Y=blade length, Z=blade thickness) and offset Y
- Assign blood splatter MIC
- Set TESObjectWEAP fields (name, type enum, speed, mass, icon)
- Generate: weapon Blueprint JSON + TESObjectWEAP form JSON + ESP record + SyncMap entry
Spell System bridge
TESSpell Asset Structure
Spell form assets live at /Game/Forms/magic/. Each is a TESSpell object.
TESSpell Fields
| Field | Example | Notes |
|---|---|---|
| FullName | "Shocking Death" | Display name |
| m_formID | 530235 | Decimal FormID |
| m_formEditorID | "SE05ShockSpell" | EditorID, no spaces |
| EffectSettings | Array of asset paths | References to magic effect setting forms at /Game/Forms/magic/effectsetting/ |
EffectSettings. The Magic Effect References
Each spell's power comes entirely from its EffectSettings array, references to EffectSetting form assets at /Game/Forms/magic/effectsetting/. Confirmed effect setting identifiers:
| Asset | Effect |
|---|---|
| SHDG | Shock Damage, the shock damage magic effect |
| SEFF | Script Effect. OBScript-driven custom effect |
SETestShockDamageArea combines both: SHDG (shock damage) + SEFF (script area effect). This is the pattern for custom magic effects that combine built-in damage with custom scripted behavior.
Magic Effect Setting Path Pattern
/Game/Forms/magic/effectsetting/[4-CHAR-CODE].[4-CHAR-CODE] Known effects (from dumps + wiki): SHDG = Shock Damage FRDG = Frost Damage FIDG = Fire Damage SEFF = Script Effect HEAL = Restore Health DRAT = Drain Attribute FOAW = Fortify Attribute (etc, same 4-char codes as vanilla Oblivion)
TESSpell has no Blueprint reference
Unlike weapons and NPCs, TESSpell has no Blueprint or BlueprintClass field, the visual VFX for spells is driven by the EffectSetting assets themselves, not the spell form. This means spell forms are purely Gamebryo-side data; the UE side reads the effect type and selects the appropriate particle/VFX Blueprint automatically based on the effect code. Custom spell VFX requires modifying the EffectSetting assets, not the TESSpell.
Engine Internals
Cells & Interior Maps
The six-file map structure, cell transition doors, the ReferenceHolder pairing actor, and the interior streaming architecture (the base level is the streaming parent) that explains why MagicLoader cells get broken lighting and missing navmesh.
Cell & Map System bridge
Map File Structure
Each world area in Content/Maps/World/ consists of six files with a shared base name:
| Suffix | Contents | Required for custom cells |
|---|---|---|
| (none) | Base level, actors, gameplay objects | Yes |
| _Del | Destructible objects | Stub required |
| _Env | Environment / static geometry | Yes, primary visual content |
| _Li | Lighting and lightmass | Yes, controls baked lighting |
| _SD | Shadows and decals | Stub required |
| _VFX | Particle effects and ambient FX | Stub required |
.umap entry in CellsToMapPath. It does not create stubs for the five variant files. This is why some interior lighting and FX behave incorrectly with ML-generated cells. The CK build pipeline will generate all six files.
LevelSelectDoors. Cell Transition Architecture
Cell transitions are handled by Blueprint actors in Content/Dev/LevelSelectDoors/:
| Blueprint | Role |
|---|---|
| BP_LevelEntryDoorObv | Gamebryo-synced door, the one to use for mod cells. Reads from CellsToMapPath DataTable. |
| BP_LevelEntryDoor | UE-only door, no Gamebryo cell sync |
| BP_LevelDoorsDispatcher | Manages multiple doors in a level, central dispatch |
| BP_DoorToUnpaired | Door that leads to an area with no paired Gamebryo cell |
| BP_LevelEntryDoor_Market / _Sewer | Specialized variants for specific area types |
PairingActors
BP_ReferenceHolder is a hidden actor (tick interval 100s, bHidden=true) that holds an array of VPairedPawn class references. This is the lookup mechanism Altar uses to find UE actors from Gamebryo references at runtime. Custom cells need a ReferenceHolder instance if they contain NPCs.
InteractibleObjects. Placeable Types
The complete set of interactible object Blueprints available for cell authoring:
BP_VChestvariants (1/2/3 rotations and translations), containersBP_VDoorvariants (1/2 rotations + translation), interior doorsBP_Flora_InteractibleObjects, harvestable plantsBP_TortureCage_Parent, scripted cage objectBP_VMisc_ConditionalStatic, static with conditional visibilityBPE_InteractibleObjectList, list/collection of interactibles
Interior Map Structure bridge
The Base Level is the Streaming Parent
This is the critical architectural fact: the base L_[CellName].umap is not just one of many equal files, it is the parent level that streams in all variants via LevelStreamingAlwaysLoaded. The sub-levels are never loaded directly by MagicLoader or the player, they are loaded as streaming sub-levels of the base.
L_BrumaMain.umap ← BASE (parent, loaded by CellsToMapPath) StreamingLevels (all LevelStreamingAlwaysLoaded): → L_BrumaMain_Del.umap (color: #15FF00 green) → L_BrumaMain_Env.umap (color: #F1FF00 yellow) → L_BrumaMain_Li.umap (color: #FF00DE pink) → L_BrumaMain_VFX.umap (color: #0066FF blue) → L_BrumaMain_SD.umap (color: #FF007F red)
What Each Sub-Level Actually Contains
| Sub-level | Actors in L_BrumaMain | Purpose |
|---|---|---|
| Base (_none) | AltarWorldSettings, InstancedFoliageActor, LevelInstance (LI_RVT_Interiors), NavMeshBoundsVolume, RecastNavMesh-Humanoids | Gameplay actors, nav mesh, foliage, streaming parent |
| _Del | AltarWorldSettings only | Destructibles, empty stub in Bruma (no destructibles) |
| _Env | AltarWorldSettings only | Environment geometry, empty in this dump (geometry is packed separately) |
| _Li | AltarWorldSettings, SkyLight, PostProcessVolume (unbound), ExponentialHeightFog | Lighting, the most content-rich sub-level |
| _SD | AltarWorldSettings, VAmbientSound | Shadows/decals + ambient sound |
| _VFX | AltarWorldSettings only | Particle effects, empty stub in Bruma |
Key Actors Confirmed in Each Sub-Level
Base Level. Navigation & Foliage
NavMeshBoundsVolume, defines the navmesh bake area for NPC pathfinding. NamedRecastNavMesh-Humanoids. RuntimeGeneration:DynamicModifiersOnly. Without this, NPCs can't navigate the cell.InstancedFoliageActor, holds all foliage/grass instancesLevelInstancepointing toLI_RVT_Interiors. Runtime Virtual Texture for interiors (referenced in PlaygoChunk_OtherMaps)VAltarNavigationSystemModuleConfig. Altar's custom nav system config
_Li Sub-Level. Lighting Setup (Confirmed)
SkyLight, source typeSLS_SpecifiedCubemap, cubemap:GrayDarkTextureCube(engine default dark), Intensity: 2.0, Mobility: MovablePostProcessVolume, bUnbound=true (affects entire level), has film/bloom/exposure overridesExponentialHeightFog. FogDensity: 0.025, VolumetricFog enabled, ScatteringDistribution: 0.4, ExtinctionScale: 0.9
_SD Sub-Level. Ambient Sound Lives Here
VAmbientSound actor is in _SD, not _VFX as might be expected. The ambient sound for a cell is placed in the shadow/decal sub-level. Custom cells needing ambient audio should add VAmbientSound to their _SD sub-level.
LevelStreamingAlwaysLoaded. The Critical Asset
Each streaming entry in the base level is a LevelStreamingAlwaysLoaded object. Unlike LevelStreamingKismet, these load immediately and stay loaded for the lifetime of the parent. The sub-levels have distinct editor colors (purely visual in UE editor, not functional).
AltarWorldSettings. Custom World Settings
All levels use AltarWorldSettings (subclass of AWorldSettings) rather than standard UE WorldSettings. This contains Altar-specific configuration. BookmarkArray has 10 null slots (editor bookmarks). NavigationSystemConfig references the nav system config object.
Complete CK Cell Editor Output Spec (Updated)
Content/Maps/[ModName]/ L_[CellName].umap ← BASE: streaming parent Must contain: - AltarWorldSettings - NavMeshBoundsVolume (for NPC pathfinding) - RecastNavMesh-Humanoids - LevelStreamingAlwaysLoaded × 5 (pointing to sub-levels) - LevelInstance → LI_RVT_Interiors (for RVT) - InstancedFoliageActor (if foliage needed) L_[CellName]_Del.umap ← AltarWorldSettings only (stub) L_[CellName]_Env.umap ← AltarWorldSettings + static mesh actors L_[CellName]_Li.umap ← AltarWorldSettings + SkyLight + PostProcessVolume + ExponentialHeightFog L_[CellName]_Li_BuiltData.uasset ← Generated by Lightmass bake (optional for dynamic lighting) L_[CellName]_SD.umap ← AltarWorldSettings + VAmbientSound (if ambient audio needed) L_[CellName]_VFX.umap ← AltarWorldSettings only (stub, add Niagara emitters for FX)
Interior Cell Path
Bruma's interiors are at /Game/Maps/Interiors/, not Maps/VerticalSlice/ as initially assumed from the VerticalSlice screenshot. VerticalSlice contains the tutorial/starting dungeon cells. City interiors live in Maps/Interiors/. The CK should let modders choose the output path.
Engine Internals
CosmicBridge Runtime & the CK
The plan: a persistent UE4SS Lua mod (CosmicBridge) exposing a clean API for every proven engine capability, plus a Rust/egui Construction Kit that unifies xEdit, the CS, TSMI and MagicLoader into one author-build-deploy pipeline.
Why This Exists
Oblivion Remastered runs on two engines simultaneously. The original Gamebryo handles all game logic. AI, quests, items, scripting. Unreal Engine 5 handles all rendering, audio, and visual systems. Altar Studios (Virtuous) wrote a proprietary bridge layer that translates between them at runtime.
The modding community has reverse-engineered enough of that bridge to build tools, but those tools are fragmented: TSMI handles item mapping, MagicLoader handles cell loading, xEdit handles ESP authoring, retoc handles asset packing. None of them talk to each other. The CosmicBridge CK unifies all of them.
The Four Deliverables
| Layer | Role |
|---|---|
| CosmicBridge Runtime | UE4SS Lua mod. Persistent. Loaded first. Clean API for all other mods. Replaces raw UE4SS scripting for 95% of use cases. |
| CK Authoring GUI | Rust + egui desktop app. Replaces xEdit + CS + MagicLoader + TSMI for NPC, dialogue, item, and cell workflows. Generates all output files. |
| Build Pipeline | Wraps retoc, UnrealPak, Wwise cook, and Audio2Face into one click. Author → Build → Deploy. |
| CK Bible (This Doc) | The complete reverse-engineered spec. Everything confirmed by probe sessions, FModel dumps, and live runtime analysis. |
CosmicBridge Architecture cosmicbridge
require("CosmicBridge") and you have everything.
Module Structure
ue4ss/Mods/CosmicBridge/Scripts/ main.lua ← entry point, loads all modules, exports global Bridge modules/ DialogueAPI.lua AudioAPI.lua CombatAPI.lua WorldAPI.lua ActorAPI.lua LipSyncAPI.lua ← pending speech2face property dump utils/ safe.lua ← pcall wrappers, IsValid guards find.lua ← FindNearestActor, FindByClass helpers
Usage Pattern for Dependent Mods
local Bridge = require("CosmicBridge")
-- Custom voiced NPC greeting
Bridge.Dialogue.onLine(function(speaker, subtitle)
if speaker == "MyCustomNPC" then
Bridge.Audio.playVoice(
"/Game/WwiseAudio/Events/Voice/custom/MyNPC/Play_Greeting",
speaker
)
end
end)
-- Custom combat reaction
Bridge.Combat.onLightAttack(function(attacker)
if attacker == Bridge.Actor.getPlayer() then
Bridge.Audio.playAt("/Game/WwiseAudio/Events/custom/SwingFX", attacker)
end
end)
Implementation Status
| Module | Status | Blocking issue |
|---|---|---|
| DialogueAPI | ready to write | None, all hooks proven |
| AudioAPI | ready to write | Interior bank residency (non-blocking) |
| CombatAPI | ready to write | None. AST_ hooks proven |
| WorldAPI | ready to write | None. TimeStop proven |
| ActorAPI | partial | GetName/GetFullName SEH issue limits actor identity |
| LipSyncAPI | pending | VHumanoidHeadComponent property dump needed |
DialogueAPI cosmicbridge
| Function | Status | Description |
|---|---|---|
Bridge.Dialogue.onStart(fn) | proven | Calls fn(speaker: string) when dialogue opens. Hooks AST_Dialogue:OnEntered. Speaker name may be empty, use onLine for reliable speaker identity. |
Bridge.Dialogue.onLine(fn) | proven | Calls fn(speaker: string, subtitle: string) each time a new line of dialogue starts. Hooks GetSubtitle post-return with non-empty guard. Fires once per line change. |
Bridge.Dialogue.onEnd(fn) | proven | Calls fn(speaker: string) when dialogue ends. Hooks AST_Dialogue:OnExited. |
Bridge.Dialogue.getSpeaker() | proven | Returns current speaker name string or nil if not in dialogue. |
Bridge.Dialogue.getSubtitle() | proven | Returns current subtitle string or nil. |
Bridge.Dialogue.isActive() | proven | Returns bool. Uses VConversationIdleAnimInstance:IsInDialogue(). |
AudioAPI cosmicbridge
| Function | Status | Description |
|---|---|---|
Bridge.Audio.playAt(eventPath, actor) | proven | Fires a Wwise event at the actor's world position via PostEventAtLocation. Returns playing_id (0 = bank not resident). Calls LoadAsset before posting. |
Bridge.Audio.playVoice(eventPath, speakerName) | proven | Same as playAt but looks up the nearest NPC matching speakerName and posts at their location. Designed for dialogue audio replacement. |
Bridge.Audio.playAtLocation(eventPath, x, y, z) | proven | PostEventAtLocation at explicit world coordinates. |
Bridge.Audio.playAtPlayer(eventPath) | proven | Posts at player location. Base behavior from ZA WARUDO mod. |
Bridge.Audio.preload(eventPath) | proven | Calls LoadAsset + StaticFindObject to warm the event into the object registry before play time. Eliminates first-play hitch. |
CombatAPI cosmicbridge
All combat events hook the corresponding AST_Character state machine states. Callbacks receive the actor that entered the state.
| Function | Status | Description |
|---|---|---|
Bridge.Combat.onLightAttack(fn) | ready | Hooks AST_CharacterActionLightAttack:OnEntered. fn(actor). |
Bridge.Combat.onPowerAttack(fn) | ready | Hooks AST_CharacterActionPowerAttack:OnEntered. fn(actor). |
Bridge.Combat.onHit(fn) | proven | Uses SendMeleeHitOnPairedPawn hook (proven in Mounted Combat). fn(attacker, victim, damage). |
Bridge.Combat.applyDamage(actor, amount) | proven | Uses actor:SendMeleeHitOnPairedPawn(). The only working damage method. UGameplayStatics::ApplyDamage is a no-op in OBR. |
Bridge.Combat.onDeath(fn) | ready | Hooks AST_CharacterDead:OnEntered. fn(actor). |
Bridge.Combat.onMounted(fn) | proven | Hooks AST_CharacterDocked:OnEntered. fn(rider, mount). Proven in Mounted Combat work. |
WorldAPI cosmicbridge
| Function | Status | Description |
|---|---|---|
Bridge.World.freeze() | proven | SloMo 0.001 + player CustomTimeDilation compensation. From ZA WARUDO. |
Bridge.World.unfreeze() | proven | SloMo 1 + restore player dilation. |
Bridge.World.setTimeDilation(factor) | proven | Set global time dilation. 1.0 = normal, 0.5 = half speed, 0.001 = near freeze. |
Bridge.World.getPlayer() | proven | Returns player pawn. Note: returns horse when mounted. |
Bridge.World.getPlayerLocation() | proven | Returns {X, Y, Z} table of player world position. |
Bridge.World.console(cmd) | proven | Execute a console command via KismetSystemLibrary. From ZA WARUDO. |
ActorAPI cosmicbridge
| Function | Status | Description |
|---|---|---|
Bridge.Actor.findNearest(className, maxDist) | proven | FindAllOf + distance sort using K2_GetActorLocation. Returns nearest valid actor. Safe, never calls GetName. |
Bridge.Actor.getHealth(actor) | proven | Reads actor value index 10 (Health). Confirmed in Mounted Combat work. |
Bridge.Actor.setHealth(actor, value) | proven | Sets actor value index 10. Use for guaranteed kill (set to 0) or healing. |
Bridge.Actor.isMounted() | proven | Scans for BP_Generic_Horse_C instances, checks if player pawn matches. From Mounted Combat. |
Bridge.Actor.fadeOut(actor) | ready | Calls BPI_NPC_CustomFadeOut:CustomFadeOut() on NPC. Confirmed interface exists. |
CK Tool Architecture construction kit
The Three Tool Layers
| Layer | Role |
|---|---|
| Authoring GUI | NPC Editor, Dialogue Editor, Cell Editor, Item Editor. Replaces xEdit + Construction Set + MagicLoader + TSMI for all common workflows. Written in Rust + egui. |
| Build Pipeline | One-click: ESP → retoc (uassets) → UnrealPak (audio) → Wwise cook → Audio2Face (facial anims) → final pak structure. Configurable per-mod. |
| Runtime CosmicBridge | UE4SS Lua mod. Persistent. Handles TSMI/MagicLoader injection via the same DataTable patching mechanism, plus all modder-facing APIs. |
What the CK Replaces
| Current tool | CK equivalent | Improvement |
|---|---|---|
| xEdit (ESP authoring) | CK NPC/Item/Dialogue editors | GUI with live validation, dual-engine awareness |
| Construction Set (cell layout) | CK Cell Editor | Generates all 6 umap variants + DataTable entries simultaneously |
| TSMI (SyncMap injection) | CosmicBridge runtime | Unified, no separate tool run, aware of all mod types |
| MagicLoader (cell loading) | CosmicBridge runtime | Generates all 6 map variants, not just base |
| retoc + UnrealPak (packing) | CK Build Pipeline | One click, correct structure guaranteed |
| Wwise + Audio2Face (voice) | CK Build Pipeline | Automated from WAV input to final pak |
Dialogue Authoring Module construction kit
Inputs → Outputs
| Input | Process | Output |
|---|---|---|
| Dialogue text | ESP record generation | .esp with TESTopicInfo FormID |
| Voice WAV file (44100hz mono 16-bit) | Wwise cook pipeline | .bnk + .wem + AkAudioEvent.uasset |
| Voice WAV file | Audio2Face batch | Facial AnimSequence.uasset |
| NPC race + sex + voice type | TESTopicInfo authoring | Keyed AkAudioEvent + Animation entries |
| Dialogue FormID | TSMI mapping generation | SyncMap patch entry |
TESTopicInfo Asset Template
{
"Type": "TESTopicInfo",
"Name": "[TopicType]_[ModName]_[FormID]",
"Properties": {
"Responses": [{
"ResponseID": 1,
"Text": "[your dialogue text]",
"AkAudioEvents": [{
"Key": {
"Sex": "ECharacterSex::[MALE|FEMALE]",
"Race": "/Game/Forms/actors/race/[Race].[Race]",
"VoiceType": "EVVoiceType::LEGACY"
},
"Value": {
"AssetPathName": "/Game/WwiseAudio/Events/Voice/oblivion/[race]/[m|f]/[EventName].[EventName]"
}
}],
"Animations": [{
"Key": {
"Sex": "ECharacterSex::[MALE|FEMALE]",
"Race": "/Game/Forms/actors/race/[Race].[Race]"
},
"Value": {
"AssetPathName": "/Game/Art/Animation/Humanoid/Facial/[VoiceSet]/[AnimName].[AnimName]"
}
}]
}],
"m_formID": [decimal FormID],
"m_formEditorID": "[TopicEditorID]",
"m_formType": "FormID::INFO_ID"
}
}
Voice Line Naming Convention
Wwise event: Play_[race]_[gender]_[questid]_[topic]_[formid]_[index] AnimSequence: A_[questid]_[topic]_[formid]_[index] UE event path: /Game/WwiseAudio/Events/Voice/oblivion/[race]/[m|f]/[EventName] UE anim path: /Game/Art/Animation/Humanoid/Facial/[VoiceSet]/[AnimName] Bank path: Event/English(US)/[EventName].bnk Media path: Media/English(US)/[MediaId].wem
Cell Editor Module construction kit
What a Complete Custom Interior Cell Requires
- Gamebryo side: Cell record in ESP with EditorID, region, lighting data
- Six UE map files: base + _Del + _Env + _Li + _SD + _VFX (currently MagicLoader only generates base)
- CellsToMapPath entry:
EditorID → /Game/Maps/[YourMap]/[YourMap].umap - BP_LevelEntryDoorObv instance in source cell pointing to new cell
- BP_ReferenceHolder instance in new cell (if NPCs are placed)
CK Cell Editor Output (per new cell)
Content/Maps/[ModName]/ [CellName].umap ← actors + interactive objects [CellName]_Del.umap ← destructibles stub [CellName]_Env.umap ← static geometry [CellName]_Li.umap ← lighting [CellName]_SD.umap ← shadow/decal stub [CellName]_VFX.umap ← VFX stub Content/Dev/ObvData/Data/ [ModName].esp ← CELL record with EditorID matching CellsToMapPath key CosmicBridge/config/ cells.json ← CellsToMapPath patch entries for runtime injection
NPC Editor Module construction kit
Custom NPC Checklist
| Layer | Asset | Tool |
|---|---|---|
| brain | NPC record in ESP (race, class, factions, AI, voice type) | CK NPC Editor → generates ESP record |
| bridge | SyncMap entry: FormID → Blueprint path | CosmicBridge runtime injection |
| visual | Blueprint (duplicate BP_Generic_NPC, set phenotype) | Altar UE project or CK stub generator |
| visual | PhenotypePreset (face morphs, skin params) | VCharacterPhenotypePreset asset. FaceMorphsSource_Human + SkinParameterCollection_Humans |
| bridge | AllRaceModifications entry (if custom race) | CosmicBridge runtime injection |
Phenotype System
Character appearance is controlled by VCharacterPhenotypePreset assets. The base preset (PhenotypePreset_BASE) references:
FaceMorphsSource_Human, at/Game/Dev/Phenotypes/MorphSources/, defines face shape morph targetsSkinParameterCollection_Humans, at/Game/Dev/Phenotypes/SkinParameterCollections/, skin tone, texture parameters
Custom NPCs need their own PhenotypePreset or can reference an existing racial preset.
Voice Type Enum (Confirmed)
EVVoiceType::LEGACY ← vanilla Oblivion voice types (altvoice, beggar, etc.) ECharacterSex::MALE ECharacterSex::FEMALE
Build Pipeline construction kit
Complete Build Flow
Author in CK GUI │ ├── ESP records ──────────────────────→ [ModName].esp │ ├── UAssets (TESTopicInfo, Blueprints) → retoc → [ModName]_P.pak │ ├── Voice WAVs ──→ Wwise cook ─────→ .bnk + .wem │ └─ Audio2Face ────→ AnimSequence.uasset → retoc │ → [ModName]_Audio_P.pak (loose bnk/wem) │ ├── Maps ─────────────────────────→ 6x .umap per cell → retoc → [ModName]_Maps_P.pak │ └── CosmicBridge config ─────────→ cells.json + syncmap.json (runtime injection)
Pak Naming Rules
| Pak | Contents | Tool |
|---|---|---|
[Mod]_P.pak | Cooked uassets (TESTopicInfo, AkAudioEvent, Blueprints) | retoc |
[Mod]_Audio_P.pak | Loose .bnk and .wem files | UnrealPak filelist |
[Mod]_Maps_P.pak | Cooked .umap files (all 6 variants) | retoc |
Load Order in Plugins.txt
Oblivion.esm AltarESPMain.esp AltarDeluxe.esp [YourMod].esp ← MUST be above AltarDeluxe.esp AltarESPLocal.esp
Engine Internals
Probe Log & Dead Ends
Primary-source research output: confirmed findings from live UE4SS probe sessions and FModel dumps, the approaches that were fully investigated and do not work (so no one repeats them), and the open questions still being chased.
Probe Session Log research
Session: 2026-06-12. Dialogue & Lip Sync Research
Full UE4SS probe session using live game + FModel dumps. Jauffre and Chorrol Guard test NPCs.
Confirmed Findings
| Finding | Method | Status |
|---|---|---|
| All four dialogue hooks register successfully | RegisterHook on AST_Dialogue + VDialogueMenuViewModel | confirmed |
| SetVDialogueMenuViewModel fires first, data empty | Probe v6 hook firing order | confirmed |
| OnStateUpdate fires every frame during dialogue | Probe v7 poll loop | confirmed |
| GetSpeakerName():ToString() returns "Jauffre" | Probe v5 FText:ToString() | confirmed |
| GetSubtitle():ToString() returns line text | Probe v5 | confirmed |
| playing_id > 0 outdoors (Chorrol Guard) | Probe v7, id=498,500,501,504,505,514 | confirmed |
| playing_id = 0 indoors (Jauffre) | All probe versions | confirmed |
| speech2face does NOT respond to PostEventAtLocation | Probe v5 Attempt D, watched mouth, no movement | confirmed |
| VAltarAkComponent has no custom PostEvent wrapper | Probe v2 UFunction scan, only 2 functions | confirmed |
| VHumanoidHeadComponent has no speech UFunctions | Probe v2, 6 functions, none speech-related | confirmed |
| TESTopicInfo stores AnimSequence path for lip sync | Abilities_000151f2.json FModel dump | confirmed |
| Lip sync is baked AnimSequence, not speech2face | TESTopicInfo Animations array | confirmed |
Confirmed Dead Ends research
| Approach | Why it fails | Investigation cost |
|---|---|---|
| actor:GetName() on NPC actors | SEHs through pcall, native nullptr dereference. Never call on actors returned by FindAllOf. | 3 crash sessions |
| K2_GetComponentsByClass(nil) | Passing nil to native UFunction SEHs through pcall | 1 crash session |
| Direct property access: npc.AudioComponent | Returns userdata that IsValid()=false. Property exists but UE4SS can't resolve the instance ptr. | 2 probe sessions |
| GetComponentByClass → GetName() | GetComponentByClass "finds" the component but returns a null ptr. GetName() SEHs | 1 probe session |
| AkComponent:PostEvent(event) | Requires OnAkPostEventCallback delegate parameter. UE4SS cannot construct delegates | 1 probe session |
| AkGameplayStatics:GetAkComponent | Has Out-param bool (ComponentCreated) UE4SS can't satisfy, "Tried storing reference to a Lua table for an Out parameter" | 1 probe session |
| speech2face via ambient audio | Confirmed not audio-driven. No mouth movement on PostEventAtLocation regardless of audio content. | Full probe session v1-v7 |
| UGameplayStatics::ApplyDamage | Confirmed no-op in OBR, returns immediately without applying damage | Mounted combat research |
| LoadAsset for new-path assets | Silent failure. LoadAsset only works for assets on known mount paths | Mounted combat research |
Open Research Questions research
| Question | Why it matters | Attack vector |
|---|---|---|
| RESOLVED | ABP_HumanoidHead AnimGraph confirmed: DefaultSlot AnimSequence playback. No Wwise RTPC, no audio analysis. Must provide A_ AnimSequence in TESTopicInfo.Animations. See Head ABP page. | |
| Why does bank not load in interiors? | Custom audio only works outdoors | Compare pak mount triggers between interior/exterior level loads. L_PersistentDungeon confirmed as Jauffre's level, may use different pak mount path. |
| VHumanoidHeadComponent properties during dialogue? | May reveal speech2face input property, settable from Lua | Property dump probe mid-dialogue, compare silent vs speaking state |
| Can vanilla NPC voice be muted? | Full audio replacement requires silencing original | AkGameplayStatics:StopActor on NPC, untested, needs probe |
| SpawnAkComponentAtLocation full signature? | Could provide component-attached audio without GetAkComponent | Expects 8 params, need to discover remaining 2 args |
| GetOrCreateAkComponent Out-param workaround? | Would give direct component reference for audio posting | UE4SS Out-param table passing, research UE4SS docs for Out-param syntax |
| CellsToMapPath DataTable direct write from Lua? | Would allow runtime cell registration without MagicLoader | Find DataTable object via StaticFindObject, probe write methods |
| Multiplayer mod state sync compatibility? | CosmicBridge should be the platform for multiplayer mods too | Reach out to multiplayer mod team. CosmicBridge actor state APIs may already be what they need |