-
Notifications
You must be signed in to change notification settings - Fork 0
Relique Developer Architecture
Technical documentation for Relique (Item Editor) development.
- Overview
- Project Structure
- Component Architecture
- Key Services
- MVVM Layer
- Item Property System (2DA Cascade)
- File Format: UTI
- Startup Lifecycle
- Integration Points
- Testing
- Settings & Persistence
Relique edits UTI (Item) files for Neverwinter Nights.
Status: Alpha (active development)
Namespace: ItemEditor (historical — product renamed to Relique)
Dependencies: Radoub.Formats, Radoub.UI, Radoub.Dictionary
File Locking: FileSessionLockService integrated into open/save/close flow
Relique/
├── CHANGELOG.md
├── CLAUDE.md (tool-specific guidance)
├── version.json (NBGV: 0.9.0-alpha)
├── Relique/
│ ├── Program.cs
│ ├── App.axaml(.cs)
│ ├── Services/
│ │ ├── CommandLineService.cs
│ │ ├── SettingsService.cs
│ │ ├── BaseItemCategoryService.cs
│ │ ├── ItemPropertyService.cs
│ │ ├── PropertyCategoryService.cs
│ │ ├── ItemNamingService.cs
│ │ ├── ItemStatisticsService.cs
│ │ ├── IItemPreviewRenderer.cs (#1908)
│ │ └── ItemPreviewController.cs (#1908)
│ ├── ViewModels/
│ │ ├── ItemViewModel.cs
│ │ └── VariableViewModel.cs
│ ├── Views/
│ │ ├── MainWindow.axaml
│ │ ├── MainWindow.axaml.cs (core: fields, constructor, ItemIconService)
│ │ ├── MainWindow.Lifecycle.cs (window open/close, game data + HAK init, item browser)
│ │ ├── MainWindow.FileOps.cs (open/save/close, recent files, browser sync)
│ │ ├── MainWindow.EditorPopulation.cs (editor binding, variables, icon preview, AC display)
│ │ ├── MainWindow.ItemPreview.cs (#1908: 3D preview pipeline + adapter + DispatcherTimer)
│ │ ├── MainWindow.ItemProperties.cs (property editing UI)
│ │ ├── MainWindow.MenuHandlers.cs (menus, keyboard, browser toggle, dialogs)
│ │ ├── BaseItemTypePickerWindow.axaml(.cs) (searchable base type selection)
│ │ ├── ItemIconPickerWindow.axaml(.cs) (icon picker dialog with inventory sizing)
│ │ ├── SettingsWindow.axaml(.cs) (game paths, theme display, Trebuchet integration)
│ │ └── NewItemWizardWindow.axaml(.cs)
│ └── Assets/
└── Relique.Tests/
├── CommandLineServiceTests.cs
├── SettingsServiceTests.cs
├── Services/
│ ├── BaseItemCategoryServiceTests.cs
│ ├── BaseItemTypeServiceTests.cs
│ ├── ItemNamingServiceTests.cs
│ ├── ItemPropertyServiceTests.cs
│ ├── ItemPropertyOperationTests.cs
│ └── ItemStatisticsServiceTests.cs
└── ViewModels/
├── ItemViewModelTests.cs
├── ItemViewModelConditionalTests.cs
├── ItemEditingRoundTripTests.cs
└── VariableViewModelTests.cs
flowchart TD
subgraph Views
MW[MainWindow]
BITP[BaseItemTypePickerWindow]
IIPW[ItemIconPickerWindow]
SETW[SettingsWindow]
WIZ[NewItemWizardWindow]
end
subgraph ViewModels
IVM[ItemViewModel]
VVM[VariableViewModel]
end
subgraph Services
CMD[CommandLineService]
SET[SettingsService]
BIC[BaseItemCategoryService]
IPS[ItemPropertyService]
INS[ItemNamingService]
ISS[ItemStatisticsService]
IPC[ItemPreviewController<br/>#1908]
IPR[IItemPreviewRenderer<br/>#1908]
end
subgraph SharedLibs["Shared Libraries"]
RF[Radoub.Formats]
BIT[BaseItemTypeService<br/>Radoub.Formats]
RU[Radoub.UI]
RD[Radoub.Dictionary]
end
subgraph SharedControls["Shared Controls (Radoub.UI)"]
IBP[ItemBrowserPanel]
IIS[ItemIconService]
PCS[PaletteColorService]
CPW[ColorPickerWindow]
IMR[ItemModelResolver<br/>#1908]
MPC[MdlPartComposer<br/>#2160]
MPGL[ModelPreviewGLControl<br/>#2156]
TXS[TextureService<br/>#2156]
end
MW --> IVM
MW --> BITP
MW --> IIPW
MW --> SETW
MW --> IBP
MW --> IIS
MW --> IPC
IIPW --> IIS
IIPW --> BIT
BITP --> BIT
WIZ --> BIT
WIZ --> BIC
WIZ --> INS
WIZ --> IIS
IVM --> RF
IPS --> RF
BIT --> RF
IBP --> RU
MW --> RU
MW --> RD
MW --> PCS
MW --> CPW
MW --> FSL[FileSessionLockService]
SET --> RU
IPC --> IMR
IPC --> MPC
IPC --> IPR
IPR -.implemented by.-> MW
MW --> MPGL
MW --> TXS
Prevents concurrent edits across Radoub tools. Integrated in MainWindow.axaml.cs:
-
AcquireLock()called inOpenFileAsync()— lock failure opens file read-only (WARN logged) -
ReleaseLock()on close -
ReleaseAllLocks()on window exit - Save blocked when read-only (lock not held)
The three Relique paths that release a lock all release after the operation they guard succeeds, so a failure mid-flight never strands a lock sidecar that blocks other tools:
| Path | Ordering rule |
|---|---|
OnCloseFileClick (MainWindow.MenuHandlers.cs) |
ReleaseLock(_currentFilePath) runs before _currentFilePath = null. Earlier code nulled the path first, leaking the sidecar until window close. |
OnItemBrowserFileDeleteRequested (MainWindow.Lifecycle.cs) |
ReleaseLock runs before delete when deleting the currently-open file. A sidecar persisting past the delete would block a recreated file with the same path. Delete now routes through FileDeletionService.DeleteWithBackupAsync (#2347) — backs the file up to ~/Radoub/Backups/{module}/{timestamp}/ via the shared BackupService before removing it, so a misclick is recoverable. The confirm dialog uses SizeToContent + resizable so its buttons are never clipped (#2348). |
OpenFileAsync (MainWindow.FileOps.cs) |
Prior file's lock is held until UtiReader.Read(filePath) succeeds; only then is the previous lock released. On exception, the newly-acquired lock on the failed file is released in the catch block, the prior file's lock is left untouched, and the editor stays on the previous document. |
The third path is the subtle one — the old ordering released the previous lock before attempting to read, so a corrupt UTI left no lock on the file the user actually had open and could leak a lock on the file that failed to load.
Parse CLI arguments: --file, --safemode, --new, --help, -m (module context).
Dependencies: CommandLineParser (Radoub.UI)
Singleton inheriting BaseToolSettingsService. Stores settings in ~/Radoub/Relique/ReliqueSettings.json. Migrates from legacy ~/Radoub/ItemEditor/ on first launch (#1948).
Key settings: BrowserPanelWidth, OpenInEditorAfterCreate
Shared service in Radoub.Formats.Services since #1987 (previously local copies in Fence and Relique). Loads base item types from baseitems.2da via IGameDataService. Filters garbage entries via TlkHelper.IsGarbageLabel on both label and TLK-resolved display name. Hardcoded fallback if game data unavailable.
Unified model: BaseItemTypeInfo serves both Fence (StorePanel) and Relique (ModelType, Stacking, Charges) needs.
Columns read: label, Name, ModelType, Description, Stacking, ChargesStarting, StorePanel, InvSlotWidth, InvSlotHeight
Stacking/Charges (#1814): Stacking is max stack size (1=single, >1=stackable). ChargesStarting identifies charge-based items (wands, rods, staves) when >0. BaseItemTypeInfo exposes IsStackable and HasCharges convenience properties. UI uses these to conditionally enable/disable Stack Size and Charges fields.
Computed properties: HasColorFields, HasArmorParts, HasModelParts, HasMultipleModelParts (driven by ModelType).
Categorizes base items by EquipableSlots bitmask into groups: Weapons, Armor, Jewelry, etc. Used by New Item Wizard for filtered type selection. Identifies custom content items.
Walks the 2DA cascade chain to resolve property types, subtypes, cost values, and parameter values. Powers the cascading dropdown UI for property editing.
itempropdef.2da → iprp_[subtype].2da → iprp_costtable.2da → iprp_[param].2da
Move Semantics (#1809): Properties use move semantics — adding a property to the item removes it from the available list. Filtering is subtype-level:
-
IsPropertyAvailable(propertyIndex, subtypeIndex, assignedProperties)— checks if a specific property+subtype is unassigned -
GetAvailableSubtypes(propertyIndex, allSubtypes, assignedProperties)— returns only unassigned subtypes -
HasAvailableSubtypes(propertyIndex, assignedProperties)— determines whether to show the property type in the available list at all
For properties without subtypes (e.g., Haste): binary present/not. For properties with subtypes (e.g., Damage Bonus): subtype-level filtering — adding Fire leaves Cold available. Pre-existing duplicates in loaded files are preserved (no auto-dedup).
Base Item Type Filtering (#1972): GetValidPropertyIndicesForBaseItem(baseItemIndex) filters the available property list by base item type. Resolution chain: baseitems.2da[PropColumn] → find matching column prefix in itemprops.2da → rows with value "1" are valid. Returns null if filtering data unavailable (shows all properties). The available properties tree refreshes on base item type change via OnBrowseBaseItemTypeClick.
Add-time defense in depth (#2166): The tree-population filter alone is not enough — SubtypeComboBox and AvailablePropertiesTree hold stale UI state across refreshes, and bad-state combos (e.g. AC Bonus on a sword) can crash deep in the Avalonia render loop. The handler-level helper MainWindow.ItemProperties.TryAddProperty adds two more layers on top of the tree filter:
- Layer 2 (validation recheck):
IsPropertyValidForBaseItem(propertyIndex, baseItemIndex)re-runs thePropColumn × itemprops.2dalookup at add-time. Returnstruewhen validation data is unavailable (fail-open) so legacy / CEP data still loads. - Layer 2 (move-semantics recheck):
IsPropertyAvailable(propertyIndex, subtypeIndex, currentItem.Properties)re-runs beforeAddso a stale subtype selection inSubtypeComboBoxcan't add the same(prop, subtype)pair twice. - Layer 1 (crash recovery): outer try/catch around
CreateItemProperty, inner try/catch aroundRefreshAssignedProperties → MarkDirty. If the UI rebind throws, the just-added entry is removed so_currentItem.Propertiesstays consistent. Failures surface as a status-bar message +WARN/ERRORlog instead of process death.OnApplyEditClickfollows the same pattern with rollback to the pre-edit value.
Rollback across all property handlers (#2258): the single-add TryAddProperty was the canonical reference, but three sibling handlers — batch-add (OnAddCheckedClick), remove (OnRemovePropertyClick), clear-all (OnClearAllPropertiesClick) — mutated the list and then refreshed with no rollback. The mutate-refresh-rollback logic is now extracted into a pure, unit-tested helper Relique/Services/PropertyListMutator.cs with three operations the handlers delegate to:
-
BatchAdd(properties, toAdd, refresh)— appends, refreshes, truncates the appended entries on throw. -
RemoveAt(properties, indices, refresh)— removes (descending), refreshes, re-inserts at original positions on throw. -
ClearAll(properties, refresh)— snapshots, clears, refreshes, restores the snapshot on throw.
Each returns true only when the mutation applied and the refresh succeeded; false on a no-op input or a rolled-back refresh failure (the handler then logs ERROR, reports via status bar, and best-effort re-refreshes). The helper is pure (operates on a List<ItemProperty> + an Action), so the rollback math is covered by PropertyListMutatorTests (9 tests) without FlaUI. FlaUI fault-injection coverage of the handlers themselves is tracked in #2380.
Duplicate Display Name Disambiguation: Multiple itempropdef.2da rows can resolve to the same TLK string (e.g., three "On Hit" variants). DisambiguateDuplicateNames() appends suffixes using a constants map derived from nwscript.nss:
- Known constants (On Hit, AC Bonus vs., etc.) → clean suffixes like "(Properties)", "(vs. Racial Group)"
- Unknown duplicates → fallback to
FormatLabel()using the 2DA Label column
Generates and validates NWN identifiers:
-
Tag: max 32 chars,
[a-zA-Z0-9_], typically UPPERCASE -
ResRef: max 16 chars,
[a-z0-9_], lowercase
Handles filename conflict resolution via ResolveResRefConflict().
Auto-generates formatted statistics description from an item's property list. One line per property with TLK-resolved names. Call-site MainWindow.ItemProperties.RefreshStatistics prepends Base Armor Class: N for armor items (ModelType 3) by re-running the parts_chest.ACBONUS[Torso] lookup so the panel distinguishes the armor's intrinsic AC from any AC-Bonus item properties (#2229 sprint feedback).
Pure-static snapshot/restore for the Available Properties tree. PopulateAvailableProperties captures the set of expanded top-node PropertyIndex values before clearing the tree and restores IsExpanded during rebuild — adding a property no longer collapses every category. Subtype-level expansion remains transient (children don't have stable identity across filter changes). Lives in Services/; testable without an Avalonia UI thread.
Pure decision helper: ShouldAutoApply(editingPropertyIndex, suppressAutoApply) returns true when the user is in edit mode (editingPropertyIndex >= 0) AND the change isn't a programmatic pre-select. Used by MainWindow.ItemProperties.OnEditComboSelectionChanged to fire ApplyEditCore(teardownOnSuccess: false) on every Subtype/Value/Param ComboBox change. The retired Apply Changes button stays in AXAML for wireup safety but is hidden. Trade-off filed as #2231: no Undo in Relique, so a fumbled combo silently mutates the file — bootstrap checklist now requires Undo/Redo wiring for new tools.
Reads parts_*.2da tables for armor slots (Neck/Torso/Belt/Pelvis/Shoulder/Bicep/Forearm/Hand/Thigh/Shin/Foot/Robe) and returns engine-valid parts only. Filters rows where ACBONUS != ****, sorts by ACBONUS ascending then row-index ascending per wiki Ch4 §4.1.4. Returns ArmorPartEntry(RowIndex, DisplayIndex, ACBonus) records with a ToDisplayString(includeAcBonus) formatter — "Part N — ID NNN" by default, "Part N — ID NNN (AC ±X)" when caller opts in. MainWindow.EditorPopulation.BuildArmorPartComboBox passes includeAcBonus: partName == "Torso" since only parts_chest ACBONUS contributes to item AC (per the same wiki section). Resolve2DAName(partName) is a static pure helper exposing the 19-entry slot → 2DA map for unit tests.
Lists composite-weapon model parts (ModelType 2 — double axes, two-bladed swords) by scanning all MDL resources via IGameDataService.ListResources(ResourceTypes.Mdl) and extracting parts whose ResRef matches <itemClass>_<b|m|t>_NNN. PositionForPartIndex(1|2|3) → "b"|"m"|"t". TryParseCompositeResRef(resRef, itemClass, position, out int) is a static pure helper for case-insensitive ResRef parsing; ExtractPartNumbers(resRefs, itemClass, position) filters, dedupes, and sorts ascending — both are exposed for unit tests so the filter logic is testable without a live GameDataService.
Wraps UtiFile. Exposes all editable fields via INotifyPropertyChanged: Name, Description, Tag, ResRef, BaseItem, Cost, StackSize, Charges, flags (Plot, Stolen, Cursed, Identified), model parts, colors, local variables, scripts.
Property changes update the underlying UTI directly.
TLK Name Resolution: Constructor accepts optional Func<uint, string?> TLK resolver. When CExoLocString.LocalizedStrings is empty but StrRef is set (base game items), the resolver looks up the name from dialog.tlk. Editing the name writes to LocalizedStrings, overriding the TLK reference.
Wraps a single local variable (Int/Float/String) with validation. Converts via FromVariable() / ToVariable().
BoolToErrorBrushConverter removed from local ViewModels — now uses shared BoolToErrorBrushConverter from Radoub.UI.Converters.
| View | Purpose |
|---|---|
| MainWindow | Primary editor: menu bar, status bar, ItemBrowserPanel sidebar (F4), editing sections (Basic, Descriptions, Flags, Appearance, Statistics, Properties, Variables, Comments). Token insertion (Ctrl+T / right-click) on NameTextBox, DescriptionTextBox, DescIdentifiedTextBox via SpellCheckTextBox.ContextMenuExtras (#1817). Icon preview with "Browse..." button opens ItemIconPickerWindow. Appearance expander (#1908) hosts a 2-column grid: left for fields, right for the live ModelPreviewGLControl + view-preset buttons + read-only Armor Class display. |
| BaseItemTypePickerWindow | Searchable modal picker for base item types. Search by name, label, or index. Shows ModelType and description preview. |
| ItemIconPickerWindow | Modal icon picker for item model variations (#1911). Shows all icons for a base type at correct inventory slot proportions (InvSlotWidth × InvSlotHeight from baseitems.2da). Uses ItemIconService for icon loading. Returns selected ModelPart1 via ShowDialog<byte?>. |
| SettingsWindow | Non-modal settings (#2009). Game paths (editable with Browse/Auto-Detect), theme/font (read-only with "Manage in Trebuchet" button), Relique options (OpenInEditorAfterCreate). Follows Fence/QM pattern. |
| NewItemWizardWindow | Guided creation: Step 1 (base type) → Step 2 (name/tag/ResRef) → Step 3 (palette category) → Step 4 (finish) |
| Control | Source | Purpose |
|---|---|---|
| ItemBrowserPanel | Radoub.UI | Embedded sidebar for browsing .uti files from module directory with HAK and base game (BIF) support (#2106). Three filter checkboxes: Module / Show HAK / Base Game. Extends FileBrowserPanelBase. Copy-to-Module inherited from base (#2065) — right-click any HAK or BIF item → dialog prompts for TemplateResRef/Tag/LocalizedName; ApplyUtiCopyCustomizations mutates UTI bytes. Click an HAK/BIF row to load it as a read-only preview (yellow 🔒 Read-Only banner via ThemeWarning, all property mutators disabled, available-property tree disabled). MainWindow wires GameDataService into the panel after InitializeGameDataAsync completes. Name/Tag sort + search (#2198/#2199): ResRef/Name/Tag columns; module rows index lazily, HAK+BIF pull from SharedPaletteCacheService. Implements IBrowserRowRefresher — SaveCurrentFileAsync calls BrowserSaveNotifier.NotifyAsync(ItemBrowserPanel, _currentFilePath) so saved rows update Tag/Name without full reindex. |
| ItemIconService | Radoub.UI | Loads item icons from game files (TGA/PLT/DDS) with caching. Uses baseitems.2da MinRange/MaxRange for valid model scan. |
| PaletteColorService | Radoub.UI | Loads NWN palette TGA files (cloth, leather, metal) for color swatch previews. Shared with Quartermaster (skin, hair, tattoo). |
| ColorPickerWindow | Radoub.UI | 176-swatch modal color picker. Shows palette gradients, double-click confirms. Used for all 6 item color fields. Optional dialogTitle parameter (#1908) since palette names no longer uniquely identify slots after the layer-2 palette correctness fix — Cloth1Color and Cloth2Color both pass pal_cloth01, but the dialog title distinguishes them. |
| ModelPreviewGLControl | Radoub.UI (#2156) | OpenGL renderer reused by Quartermaster and Relique. Composite weapons / armor go through MdlPartComposer (#2160) before being handed to the renderer's Model setter. |
| MdlPartComposer | Radoub.UI/Services (#2160) |
Compose(skeletonResRef, parts) for armor / creature body, ComposeFlat(partResRefs) for composite weapons. |
| ItemModelResolver | Radoub.UI/Services (#1908) | Pure logic: maps UtiFile → (MdlResRefs, HasArmorParts, HasColorFields, HasModel) per BaseItemTypeService.ModelType (0/1/2/3). |
| TokenContextMenu / TokenInsertionHelper | Radoub.UI | NWN token insertion via right-click submenu (Name/Gender/Character tokens, quick slots, "All Tokens...") and Ctrl+T shortcut (#1817). Routes "All Tokens..." through TokenSelectorWindow (4 tabs: Standard / Highlight / Custom Tokens / Custom Colors) after #2075 — the older TokenInsertionWindow lacked the Custom Tokens tab and is no longer instantiated by the shared helper. |
flowchart LR
BI[baseitems.2da] -->|PropColumn| IP[itemprops.2da]
IP -->|"valid rows"| PD[itempropdef.2da]
PD -->|SubTypeResRef| ST["iprp_[subtype].2da"]
PD -->|CostTableResRef| CT[iprp_costtable.2da]
CT -->|"table name"| CV["iprp_[cost].2da"]
PD -->|Param1ResRef| PT[iprp_paramtable.2da]
PT -->|"table name"| PV["iprp_[param].2da"]
ItemPropertyService walks this chain to:
- Filter available property types by base item type via
itemprops.2da(#1972) - List available property types from
itempropdef.2da(with duplicate name disambiguation) - Load subtypes, cost values, param values on demand
- Search by property name or subtype name (case-insensitive)
- Provide cascading dropdowns in the UI
Available Properties Tree UI features:
- Category filter ComboBox — driven by
PropertyCategoryService, which mapsitempropdef.2daLabels to curated categories (Bonus/Enhancement, Damage, Defense/AC, On Hit, Cast Spell, Penalty/Decreased, Skill/Ability, Use Limitation, Miscellaneous). Unmapped labels (CEP/PRC custom content) fall into an Other bucket. Empty categories are hidden; canonical display order is enforced regardless of which categories are present. - Text search with auto-expand of matching subtype nodes (bold highlighting)
- Right-click context menu for "Add to Item" with default values
- Property count label showing filtered results
- Checkbox multi-select for bulk add
GFF-based binary format parsed by Radoub.Formats/Uti/.
| Section | Key Fields |
|---|---|
| Identity |
LocName, Tag (32 chars), TemplateResRef (16 chars), Description
|
| Base Properties |
BaseItem (baseitems.2da index), Cost, AddCost, Weight, StackSize, Charges
|
| Flags |
Plot, Stolen, Cursed, Identified, Droppable, Pickpocketable
|
| Properties |
PropertiesList — enchantments via 2DA cascade |
| Appearance |
ModelPart1/2/3, ArmorPart_*, Cloth/Leather/MetalColor
|
| Variables | Local variables (Int/Float/String) |
| Scripts |
OnActivate, OnAcquire, OnUnacquire, OnEquip, OnUnequip
|
sequenceDiagram
participant P as Program.cs
participant A as App.axaml.cs
participant MW as MainWindow
P->>P: Parse CLI args
P->>P: Init UnifiedLogger
P->>A: Build Avalonia app
A->>A: Register tool path
A->>A: Apply theme/font
A->>MW: Create MainWindow
MW->>MW: Constructor (light init)
MW->>MW: Loaded (restore window state)
MW->>MW: Opened (async: GameData on background thread via Task.Run, palettes, startup file)
InitializeGameDataAsync (#2024): Heavy I/O (GameDataService init, HAK loading, 2DA parsing, palette category resolution) runs on a background thread via Task.Run. Results are applied on the UI thread afterward. LoadPaletteCategories() accepts pre-computed data instead of querying GameDataService directly, keeping UI-thread work minimal.
| Tool | Integration |
|---|---|
| Trebuchet | Registers Relique for discovery and file launch via --file
|
| Trebuchet | Module change via RadoubSettings.PropertyChanged → updates module indicator and item browser (#1802) |
| Fence | Context menu "Edit Item" → launches Relique with --file
|
| Quartermaster | Context menu "Edit Item" → launches Relique with --file
|
| Marlinspike |
UtiSearchProvider searches names, descriptions, tag, ResRef, comment, and local variables (VarTable) with replace support (#1940) |
| Category | Focus | Key Files |
|---|---|---|
| Service tests | Business logic isolation | ItemPropertyServiceTests, ItemNamingServiceTests, ItemStatisticsServiceTests, BaseItemCategoryServiceTests, BaseItemTypeServiceTests, TreeExpansionTrackerTests (#2227), EditAutoApplyDeciderTests (#2226), ArmorPartCatalogServiceTests (#2164), CompositeWeaponPartCatalogServiceTests (#2164) |
| ViewModel tests | Data binding, property changes | ItemViewModelTests, VariableViewModelTests |
| Conditional logic | Field visibility by base item type | ItemViewModelConditionalTests |
| Round-trip | File save → load cycle integrity | ItemEditingRoundTripTests |
| CLI | Command-line parsing, --new wizard | NewItemCommandLineTests, CommandLineServiceTests |
dotnet test Relique/Relique.TestsLive OpenGL preview of the currently-edited item, rendered inside the Appearance expander. Reuses the shared ModelPreviewGLControl (promoted in #2156), MdlPartComposer (extracted in #2160), and ItemModelResolver (Radoub.UI). Static rendering — no animations.
| Class | Location | Role |
|---|---|---|
| ItemPreviewController | Relique/Services/ |
Wires ItemViewModel.PropertyChanged to ModelType-specific load + recolor. Owns the _pendingUpdate flag; the production code-behind pumps FlushDebounce() on a 100ms DispatcherTimer so rapid color/part bursts coalesce. UI-thread-agnostic, fully unit-tested via IItemPreviewRenderer fake. |
| IItemPreviewRenderer | Relique/Services/ |
Seam between controller and live OpenGL preview. SetModel(MdlModel), Clear(), SetArmorColors(metal1, metal2, cloth1, cloth2, leather1, leather2). Production implementation lives in MainWindow.ItemPreview.cs as a private RendererAdapter wrapping ModelPreviewGLControl. |
| MainWindow.ItemPreview.cs | Relique/Views/ |
Per-window pipeline owner: constructs TextureService, MdlPartComposer, ItemModelResolver, ItemPreviewController, RendererAdapter. Pumps the 100ms DispatcherTimer. Disposes everything in OnWindowClosing. Hosts F/B/L/R/Reset view-preset button handlers. |
| ItemModelResolver |
Radoub.UI/Services/ (#1908) |
Pure logic: maps UtiFile → (MdlResRefs, HasArmorParts, HasColorFields, HasModel) per BaseItemTypeService.ModelType. Each candidate ResRef checked against IGameDataService for existence. |
| MdlPartComposer |
Radoub.UI/Services/ (#2160) |
Generic skeleton+parts → composite MdlModel. Two entry points: Compose("pmh0", parts) for armor, ComposeFlat([resRefs]) for composite weapons. |
| ModelType | Path | Rotation |
|---|---|---|
0 Simple — held weapon (IsHeldWeapon=true) |
ComposeFlat([single]) |
90° X (trophy) |
| 0 Simple — non-weapon (helmet, amulet, key, etc.) | ComposeFlat([single]) |
identity |
| 1 Layered (cloak, robe) |
ComposeFlat([single]) + Cloth1/2 PLT colors |
identity |
| 2 Composite (twobladed, quarterstaff, dire mace, magic rod) |
ComposeFlat([b, m, t]) from ModelPart1/2/3 |
90° X (always held) |
| 3 Armor |
Compose("pmh0", parts) from ArmorParts dict + all 6 PLT colors |
identity |
| Other / out-of-range |
HasModel=false → renderer.Clear() shows placeholder |
n/a |
IsHeldWeapon (added to BaseItemTypeInfo in #1908) returns true for WeaponWield ∈ {-1 (****), 4, 5, 6, 8, 10, 11} (default-melee/pole/bow/crossbow/two-bladed/sling/thrown). The trophy rotation is Quaternion.CreateFromAxisAngle(Vector3.UnitX, π/2) — converts the MDL's authored +Y forward axis into +Z up since in-game weapons get their orientation from the rhand/lhand bone.
ItemPreviewController.OnViewModelPropertyChanged triggers a reload when e.PropertyName matches one of:
| Property | Reason |
|---|---|
BaseItem |
ModelType / ItemClass changes; resolver decision differs |
ModelPart1/2/3 |
Single-MDL or composite ResRef changes |
Cloth1/2Color, Leather1/2Color, Metal1/2Color
|
PLT color slot changes |
ArmorPart_* (prefix-match for all 19 body part keys) |
Armor composition changes |
Other property changes (Tag, Name, Description, etc.) don't trigger preview reloads.
MainWindow.OnWindowOpened
└── InitializeGameDataAsync (background)
└── InitializeItemPreview() (UI thread)
├── new TextureService(_gameDataService)
├── new MdlPartComposer(_gameDataService, LoadMdlForPreview)
├── new ItemModelResolver(baseItemSvc, _gameDataService)
├── new RendererAdapter(ItemPreviewGL, ItemPreviewPlaceholder, ItemPreviewControls)
├── ItemPreviewGL.SetTextureService(_previewTextureService)
├── new ItemPreviewController(...)
└── DispatcherTimer (100ms) → controller.FlushDebounce()
MainWindow.PopulateEditor (item open)
└── BindItemPreview(_itemViewModel)
└── controller.BindViewModel(vm)
└── vm.PropertyChanged += handler
MainWindow.PopulateEditor (item close)
└── BindItemPreview(null) → controller.Unbind()
MainWindow.OnWindowClosing
└── DisposeItemPreview()
├── timer.Stop()
├── controller.Unbind()
└── _previewTextureService.ClearCache()
Close re-entry guard (#2258): OnWindowClosing cancels the close on the dirty path, prompts, then calls Close() re-entrantly — which re-fires the handler. Both invocations would otherwise reach the cleanup block (RadoubSettings.PropertyChanged -=, DisposeItemPreview(), FileSessionLockService.ReleaseAllLocks(), SaveWindowPosition()), running it twice on Save / Don't-Save. A _cleanedUp flag now gates the cleanup so it runs exactly once: the dirty path returns after the re-entrant Close() (no fall-through), and the inner invocation (now clean) performs the single cleanup pass.
-
Relique.Tests/Services/ItemPreviewControllerTests.cs— 17 tests viaFakePreviewRenderer: per-ModelType dispatch (Simple/Layered/Composite/Armor), debounce coalescing, Unbind detaches handler, rotation applied/skipped perIsHeldWeapon, ArmorPart_Torso reload trigger, BaseItem out-of-range placeholder, irrelevant property change ignored -
Radoub.UI.Tests/Services/ItemModelResolverTests.cs— 13 tests: per-ModelType resolution, partial-availability (some armor parts missing), missing MDL files, out-of-range BaseItem, null UTI, empty ArmorParts dict
Per Aurora item format spec § 2.1.2.4, all "2" color slots index the same palette TGA as their matching "1" slot — there is no pal_*02.tga in NWN. Both PltLayers.GetPaletteResRef (Radoub.Formats) and PaletteColorService.Palettes (Radoub.UI) reflect this:
| Slot | Palette file |
|---|---|
| Cloth1Color, Cloth2Color | pal_cloth01.tga |
| Leather1Color, Leather2Color | pal_leath01.tga |
| Metal1Color, Metal2Color | pal_armor01.tga |
| Tattoo1, Tattoo2 | pal_tattoo01.tga |
The "1"/"2" suffix on color fields refers to which PLT layer pixel the color applies to — not to a separate palette file.
Read-only field next to the Armor Parts header. Per the Aurora item format spec (Section 4.4.1), armor AC is parts_chest.2da[ArmorPart_Torso].ACBONUS. MainWindow.UpdateArmorClassDisplay() runs on initial population and whenever ArmorPart_Torso changes. Returns "—" when game data unavailable or ACBONUS is ****.
Tool settings: ~/Radoub/Relique/ReliqueSettings.json (fixed in #2023; migrates from ~/Radoub/ItemEditor/ automatically)
Shared settings (RadoubSettings): CurrentModulePath, ReliquePath, game paths, TLK, theme/font
Built executable: Relique.exe / Relique after #2080 — AssemblyName in Relique.csproj now matches the tool name (was ItemEditor). RootNamespace stays ItemEditor for source-code continuity. Trebuchet.ToolLauncherService no longer overrides AssemblyName on the Relique ToolInfo (sibling discovery walks Name). Radoub.UI.Services.ItemEditorLauncher probes for Relique.exe / Relique. Radoub.UI.Services.Search.ToolDispatchService maps ResourceTypes.Uti → { ToolName = "Relique", AssemblyName = "Relique" }.
Tool-path migration (#2080): Radoub.Formats.Settings.ReliqueExePathMigration.Migrate(string) rewrites the legacy ItemEditor.exe / ItemEditor filename segment in a cached ReliquePath to Relique.exe / Relique. Invoked in RadoubSettings.Persistence.LoadSettings; when the migration rewrites a value, SaveSettings() is called immediately so other tools reading the same JSON pick up the new path. Pure function, fully covered by Radoub.Formats.Tests/Settings/ReliqueExePathMigrationTests.cs (9 cases: Windows / Linux / mid-path directory false-positive / case / null / empty / unrelated exe / mixed separators / already-migrated).
DocumentState (Radoub.UI): Centralized dirty tracking, title bar updates with [*] marker. UpdateTitle() helper (#1803) provides defensive title updates — ClearDirty() only fires DirtyStateChanged on dirty→clean transitions, so clean→clean file switches require explicit UpdateTitle() calls.
Page freshness: 2026-06-06
Getting Started
User Guide
Features
Help
- Manifest - Journal Editor
- Quartermaster - Creature/Inventory Editor
- Relique - Item Editor
- Reliquary - Placeable Editor (Alpha)
- Fence - Merchant/Store Editor
- Trebuchet - Radoub Launcher
- Marlinspike - Search and Replace
- Spell Check - Dictionary-based spell checking
- Token System - Dialog tokens and custom colors
Parley Internals
Manifest Internals
Quartermaster Internals
Relique Internals
Reliquary Internals
Fence Internals
Marlinspike (Search Engine)
Trebuchet Internals
Radoub.UI
Library
Low-Level Formats
High-Level Parsers
- JRL Format (.jrl)
- UTI Format (.uti) - Item blueprints
- UTC Format (.utc) - Creature blueprints
- UTM Format (.utm) - Store blueprints
- UTP Format (.utp) - Placeable blueprints
- UTD Format (.utd) - Door blueprints
- ARE Format (.are) - Area properties
- BIC Format (.bic) - Player characters
Original BioWare Aurora Engine file format specifications.
Core Formats
- GFF Format - Generic File Format
- KEY/BIF Format - Resource archives
- ERF Format - Encapsulated resources
- TLK Format - Talk tables
- 2DA Format - Data tables
- Localized Strings
- Common GFF Structs
Object Blueprints
- Creature Format (.utc)
- Item Format (.uti)
- Store Format (.utm)
- Door/Placeable (.utd/.utp)
- Encounter Format (.ute)
- Sound Object (.uts)
- Trigger Format (.utt)
- Waypoint Format (.utw)
Module/Area Files
- Conversation Format (.dlg)
- Journal Format (.jrl)
- Area File Format (.are/.git/.gic)
- Module Info (.ifo)
- Faction Format (.fac)
- Palette/ITP Format (.itp)
- SSF Format - Sound sets
Reference
Page freshness: 2026-05-24