Skip to content

Relique Developer Architecture

LordOfMyatar edited this page Jun 14, 2026 · 9 revisions

Relique Developer Architecture

Technical documentation for Relique (Item Editor) development.


Table of Contents


Overview

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


Project Structure

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

Component Architecture

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
Loading

Key Services

FileSessionLockService

Prevents concurrent edits across Radoub tools. Integrated in MainWindow.axaml.cs:

  • AcquireLock() called in OpenFileAsync() — lock failure opens file read-only (WARN logged)
  • ReleaseLock() on close
  • ReleaseAllLocks() on window exit
  • Save blocked when read-only (lock not held)

Lock Release Ordering (#2257)

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.

CommandLineService

Parse CLI arguments: --file, --safemode, --new, --help, -m (module context).

Dependencies: CommandLineParser (Radoub.UI)

SettingsService

Singleton inheriting BaseToolSettingsService. Stores settings in ~/Radoub/Relique/ReliqueSettings.json. Migrates from legacy ~/Radoub/ItemEditor/ on first launch (#1948).

Key settings: BrowserPanelWidth, OpenInEditorAfterCreate

BaseItemTypeService (Radoub.Formats)

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).

BaseItemCategoryService

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.

ItemPropertyService

Walks the 2DA cascade chain to resolve property types, subtypes, cost values, and parameter values. Powers the cascading dropdown UI for property editing.

itempropdef.2daiprp_[subtype].2daiprp_costtable.2daiprp_[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 the PropColumn × itemprops.2da lookup at add-time. Returns true when validation data is unavailable (fail-open) so legacy / CEP data still loads.
  • Layer 2 (move-semantics recheck): IsPropertyAvailable(propertyIndex, subtypeIndex, currentItem.Properties) re-runs before Add so a stale subtype selection in SubtypeComboBox can't add the same (prop, subtype) pair twice.
  • Layer 1 (crash recovery): outer try/catch around CreateItemProperty, inner try/catch around RefreshAssignedProperties → MarkDirty. If the UI rebind throws, the just-added entry is removed so _currentItem.Properties stays consistent. Failures surface as a status-bar message + WARN/ERROR log instead of process death. OnApplyEditClick follows 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

ItemNamingService

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().

ItemStatisticsService

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).

TreeExpansionTracker (#2227)

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.

EditAutoApplyDecider (#2226)

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.

ArmorPartCatalogService (#2164)

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.

CompositeWeaponPartCatalogService (#2164)

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.

ItemCostCalculator (#2235)

Reproduces the Aurora engine item-cost formula (wiki Ch4 §4.4) so the editor displays/saves the value the game recomputes on load, instead of trusting the stored Cost. Returns uint? — null when game data is unconfigured or the base-item row is missing, so the caller keeps the stored value.

ItemCost = [BaseCost + 1000·(Σpos² − Σneg²) + SpellCosts] · MaxStack · ItemMultiplier + AddCost

Key deviation from the BioWare doc prose: a property's itempropdef.2da Cost is a multiplier on its magnitude (PropertyCost × CostValue), not an additive term — NWN:EE behavior, verified against the Aurora toolset (studded leather +2 AC → 0.9 × 1.9 = 1.71; 15 + 1000·1.71² = 2939). A zero/**** PropertyCost is treated as a multiplier of 1 so magnitude-only properties still count. Armor BaseCost resolves through parts_chest[Torso].ACBONUS → armor.2da[baseAC].COST. Weapons apply baseitems.ItemMultiplier (= 2), doubling the bracket. Cast-spell properties are tiered (most expensive 100%, second 75%, rest 50%). 13 unit tests, 7 toolset-verified. MainWindow.EditorPopulation calls RecomputeCost() on base-item and armor-part change; the recompute is guarded by _isLoading so opening a file doesn't false-dirty it (#2235 load bug).

MannequinPoseAdjuster (#2232)

Static, pure pose relaxer for the armor-preview mannequin. The stock skeleton stands at attention (arms down, occluded by hips; legs together). ApplyRelaxedPose(model) premultiplies a local-frame delta rotation (orientation = orientation · delta) onto named bones so attached meshes and child bones follow: arm abduction (lbicep_g/rbicep_g, ±18° about Y), leg separation (lthigh_g/rthigh_g, ±9° about Y), and slight elbow/knee flexion (lforearm_g/rforearm_g +5°, lshin_g/rshin_g −4° about X). Angles are named constants for visual tuning. Applied by ItemPreviewController only when resolution.HasArmorParts, mutating the composite's cloned bones — MdlPartComposer and other consumers (QM creature preview) are unaffected. Missing bones skipped silently. Bone names verified against MdlPartBoneMap. Follow-ups: gender models (#2407), larger viewport (#2408), rotation (#2409).


MVVM Layer

ItemViewModel

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.

VariableViewModel

Wraps a single local variable (Int/Float/String) with validation. Converts via FromVariable() / ToVariable().

Converters

BoolToErrorBrushConverter removed from local ViewModels — now uses shared BoolToErrorBrushConverter from Radoub.UI.Converters.

Views

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)

Shared Controls

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 IBrowserRowRefresherSaveCurrentFileAsync 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.

Item Property System (2DA Cascade)

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"]
Loading

ItemPropertyService walks this chain to:

  1. Filter available property types by base item type via itemprops.2da (#1972)
  2. List available property types from itempropdef.2da (with duplicate name disambiguation)
  3. Load subtypes, cost values, param values on demand
  4. Search by property name or subtype name (case-insensitive)
  5. Provide cascading dropdowns in the UI

Available Properties Tree UI features:

  • Category filter ComboBox — driven by PropertyCategoryService, which maps itempropdef.2da Labels 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

File Format: UTI

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

Startup Lifecycle

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)
Loading

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.


Integration Points

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)

Undo / Redo (#2231, Sprint 1)

Relique is the first blueprint editor to adopt the shared Radoub.UI.Undo foundation (see Radoub.UI Developer). Policy: document / whole-field undo — Ctrl+Z always undoes at the document level (no TextBox-focus guard), reverting a field to its previous committed value, not char-by-char.

Wiring lives in MainWindow.Undo.cs (_undo manager, WireEditor, RefreshUndoMenu, per-control helpers) with handlers in MainWindow.ItemProperties.cs and MainWindow.EditorPopulation.cs.

Surface Command Notes
Property add / batch-add / remove / clear AddPropertyCommand, BatchAddPropertiesCommand, RemovePropertyCommand, ClearPropertiesCommand (Relique/Commands/) Wrap PropertyListMutator so the #2258 rollback-on-refresh-failure seam guards Do + Undo; Do() returns the mutator bool → manager refuses to record a self-rolled-back add/remove. Added PropertyListMutator.InsertAt as the inverse of RemoveAt for undo.
Text fields (name, tag, descriptions, comment) RecordedFieldEditCommand<string> WireFieldUndo: snapshot on GotFocus, record whole-value on LostFocus/Enter (reads box.Text, not the VM).
Flags (Plot/Cursed/Stolen/Identified/Droppable) RecordedFieldEditCommand<bool> WireFlagUndo: tracks the checkbox's own baseline (never reads the VM at event time — binding may not have flushed), drives IsChecked on undo.
Numerics (AddCost, StackSize, Charges) + 6 colors RecordedFieldEditCommand<decimal?> WireNumericUndo: uses NumericUpDownValueChangedEventArgs old/new directly.
Category, icon (ModelPart1), model parts 1/2/3 SetFieldCommand<byte> Event-driven (handler fires before mutation); setter re-syncs the combo/preview on undo.

Each wired control has a re-entrancy guard so undo-driven setters do not re-record. PopulateEditor runs under the loading flag and clears undo after binding, so bind-time events on document load are not recorded.

Out of scope: local variables (shared VariablesPanel not yet undo-aware) — deferred to #2467.

Testing

Category Focus Key Files
Undo/Redo Command round-trips + refuse-to-push PropertyCommandTests, PropertyListMutatorTests (InsertAt)
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.Tests

3D Item Preview (#1908)

Live 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.

Components

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 Dispatch

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.

Watched ViewModel Properties

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.

Lifecycle

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.

Tests

  • Relique.Tests/Services/ItemPreviewControllerTests.cs — 17 tests via FakePreviewRenderer: per-ModelType dispatch (Simple/Layered/Composite/Armor), debounce coalescing, Unbind detaches handler, rotation applied/skipped per IsHeldWeapon, 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

Layer-2 Color Slot Mapping

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.

Armor Class Display

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 ****.


Settings & Persistence

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.


Home | Index

Page freshness: 2026-06-07


Parley

Getting Started

User Guide

Features

Help


Manifest


Quartermaster


Relique


Reliquary


Fence

  • Fence - Merchant/Store Editor

Trebuchet


Shared Features


Developers

Parley Internals

Manifest Internals

Quartermaster Internals

Relique Internals

Reliquary Internals

Fence Internals

Marlinspike (Search Engine)

Trebuchet Internals

Radoub.UI


Radoub.Formats

Library

Low-Level Formats

High-Level Parsers


Legacy Bioware Docs

Original BioWare Aurora Engine file format specifications.

Core Formats

Object Blueprints

Module/Area Files

Reference


Page freshness: 2026-05-24

Index

Clone this wiki locally