From a0af8027895491dc1f8fbc9fafb231dd4a97660e Mon Sep 17 00:00:00 2001 From: Phil Lenton Date: Fri, 3 Apr 2026 01:21:15 -0500 Subject: [PATCH 1/6] Save editor UX: dirty state, unapplied edits, Apply gating; gold/armor rules; wishlist Made-with: Cursor --- .gitignore | 3 + D-OS Save Editor/SE/AbilitiesTab.xaml | 1 + D-OS Save Editor/SE/AbilitiesTab.xaml.cs | 52 ++++++++ D-OS Save Editor/SE/InventoryTab.xaml | 12 +- D-OS Save Editor/SE/InventoryTab.xaml.cs | 122 +++++++++++++++++-- D-OS Save Editor/SE/SaveEditor.xaml | 5 +- D-OS Save Editor/SE/SaveEditor.xaml.cs | 88 +++++++++++-- D-OS Save Editor/SE/StatsTab.xaml | 1 + D-OS Save Editor/SE/StatsTab.xaml.cs | 37 ++++++ D-OS Save Editor/SE/TalentTab.xaml.cs | 42 ++++++- D-OS Save Editor/SE/TraitCouple.xaml | 4 +- D-OS Save Editor/SE/TraitCouple.xaml.cs | 16 ++- D-OS Save Editor/SE/TraitsTab.xaml.cs | 25 ++++ D-OS Save Editor/savegame/DataTable.cs | 8 +- D-OS Save Editor/savegame/Item.cs | 35 ++++-- SortGenerationList/App.config | 6 +- SortGenerationList/SortGenerationList.csproj | 6 +- wishlist.md | 23 ++++ 18 files changed, 438 insertions(+), 48 deletions(-) create mode 100644 wishlist.md diff --git a/.gitignore b/.gitignore index a844fb5..866dca5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ SortGenerationList/bin/ SortGenerationList/obj/ *.bak DeltaModifier.txt + +_patch_apply_pending.py +_patch*.py diff --git a/D-OS Save Editor/SE/AbilitiesTab.xaml b/D-OS Save Editor/SE/AbilitiesTab.xaml index f174818..74e9869 100644 --- a/D-OS Save Editor/SE/AbilitiesTab.xaml +++ b/D-OS Save Editor/SE/AbilitiesTab.xaml @@ -15,6 +15,7 @@ + diff --git a/D-OS Save Editor/SE/StatsTab.xaml.cs b/D-OS Save Editor/SE/StatsTab.xaml.cs index 4cc0d28..5f37544 100644 --- a/D-OS Save Editor/SE/StatsTab.xaml.cs +++ b/D-OS Save Editor/SE/StatsTab.xaml.cs @@ -2,6 +2,7 @@ using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; +using System.Windows.Controls.Primitives; namespace D_OS_Save_Editor { @@ -11,6 +12,7 @@ namespace D_OS_Save_Editor public partial class StatsTab { private Player _player; + private bool _suppressFieldEvents; private Brush DefaultTextBoxBorderBrush { get; } public Player Player @@ -30,8 +32,38 @@ public StatsTab() DefaultTextBoxBorderBrush = ExpTextBox.BorderBrush; } + + public bool HasPendingEdits() + { + if (Player == null) return false; + if (ExpTextBox.Text != Player.Experience) return true; + if (ReputationTextBox.Text != Player.Reputation) return true; + if (HpCurrentTextBox.Text != Player.Vitality) return true; + if (HpMaxTextBox.Text != Player.MaxVitalityPatchCheck) return true; + if (AttributePointsTextBox.Text != Player.AttributePoints) return true; + if (AbilityPointsTextBox.Text != Player.AbilityPoints) return true; + if (TalentPointsTextBox.Text != Player.TalentPoints) return true; + if (StrengthTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Strength].ToString()) return true; + if (DexterityTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Dexerity].ToString()) return true; + if (IntelligenceTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Intelligence].ToString()) return true; + if (ConstitutionTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Consitution].ToString()) return true; + if (SpeedTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Speed].ToString()) return true; + if (PerceptionTextBox.Text != Player.Attributes[(int)DataTable.Attributes.Perception].ToString()) return true; + return false; + } + + private void CharacterField_TextChanged(object sender, TextChangedEventArgs e) + { + if (_suppressFieldEvents) return; + if (Window.GetWindow(this) is SaveEditor editor) + editor.RefreshCharacterApplyPendingState(); + } + public void UpdateForm() { + _suppressFieldEvents = true; + try + { ExpTextBox.Text = Player.Experience; ReputationTextBox.Text = Player.Reputation; HpCurrentTextBox.Text = Player.Vitality; @@ -45,6 +77,11 @@ public void UpdateForm() ConstitutionTextBox.Text = Player.Attributes[(int)DataTable.Attributes.Consitution].ToString(); SpeedTextBox.Text = Player.Attributes[(int)DataTable.Attributes.Speed].ToString(); PerceptionTextBox.Text = Player.Attributes[(int)DataTable.Attributes.Perception].ToString(); + } + finally + { + _suppressFieldEvents = false; + } } public void SaveEdits() diff --git a/D-OS Save Editor/SE/TalentTab.xaml.cs b/D-OS Save Editor/SE/TalentTab.xaml.cs index 3e24dcc..0bd10a5 100644 --- a/D-OS Save Editor/SE/TalentTab.xaml.cs +++ b/D-OS Save Editor/SE/TalentTab.xaml.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -13,6 +14,7 @@ namespace D_OS_Save_Editor public partial class TalentTab { private Player _player; + private bool _suppressTalentEvents; public Player Player { get => _player; @@ -29,6 +31,30 @@ public TalentTab() InitializeComponent(); } + public bool HasPendingEdits() + { + if (Player == null || TalentGroup0.Children.Count + TalentGroup1.Children.Count == 0) + return false; + var checkedTalents = new bool[DataTable.TalentIsHidden.Length]; + foreach (CheckBox ckb in TalentGroup0.Children) + checkedTalents[(int)ckb.Tag] = ckb.IsChecked == true; + foreach (CheckBox ckb in TalentGroup1.Children) + checkedTalents[(int)ckb.Tag] = ckb.IsChecked == true; + var bytes = new byte[12]; + new BitArray(checkedTalents).CopyTo(bytes, 0); + var t0 = BitConverter.ToUInt32(bytes, 0); + var t1 = BitConverter.ToUInt32(bytes, 4); + var t2 = BitConverter.ToUInt32(bytes, 8); + return t0 != Player.Talents[0] || t1 != Player.Talents[1] || t2 != Player.Talents[2]; + } + + private void TalentCheckBox_Changed(object sender, RoutedEventArgs e) + { + if (_suppressTalentEvents) return; + if (Window.GetWindow(this) is SaveEditor editor) + editor.RefreshCharacterApplyPendingState(); + } + public void SaveEdits() { var checkedTalents = new bool[DataTable.TalentIsHidden.Length]; @@ -51,6 +77,9 @@ public void SaveEdits() private void UpdateForm() { + _suppressTalentEvents = true; + try + { TalentGroup0.Children.Clear(); TalentGroup1.Children.Clear(); @@ -73,20 +102,27 @@ void AddToPanel(Panel panel, Talent talent) // toolTipContent = talent.Effect; //} - panel.Children.Add( - new CheckBox + var ckb = new CheckBox { Content = talent.Name, Tag = talent.Index, ToolTip = talent.Effect, IsChecked = playerTalentIds.Contains(talent.Index) - }); + }; + ckb.Checked += TalentCheckBox_Changed; + ckb.Unchecked += TalentCheckBox_Changed; + panel.Children.Add(ckb); } foreach (var talent in talentArray) { AddToPanel(talent.IsHidden ? TalentGroup1 : TalentGroup0, talent); } + } + finally + { + _suppressTalentEvents = false; + } } private int[] GetPlayerTalentIds() diff --git a/D-OS Save Editor/SE/TraitCouple.xaml b/D-OS Save Editor/SE/TraitCouple.xaml index 88eabe0..1f9d3d1 100644 --- a/D-OS Save Editor/SE/TraitCouple.xaml +++ b/D-OS Save Editor/SE/TraitCouple.xaml @@ -37,8 +37,8 @@ - - + + diff --git a/D-OS Save Editor/SE/TraitCouple.xaml.cs b/D-OS Save Editor/SE/TraitCouple.xaml.cs index 45cf330..e1dcd7c 100644 --- a/D-OS Save Editor/SE/TraitCouple.xaml.cs +++ b/D-OS Save Editor/SE/TraitCouple.xaml.cs @@ -1,7 +1,9 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -using System.Windows.Controls; +using System.Windows; using System.Windows.Media; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; using D_OS_Save_Editor.Annotations; namespace D_OS_Save_Editor @@ -13,6 +15,18 @@ public partial class TraitCouple : UserControl { private Brush DefaultTextBoxBorderBrush { get; } + + private void TraitValue_TextChanged(object sender, TextChangedEventArgs e) + { + for (DependencyObject p = this; p != null; p = System.Windows.Media.VisualTreeHelper.GetParent(p)) + { + if (p is TraitsTab traits && traits.SuppressTraitNotifications) + return; + } + if (Window.GetWindow(this) is SaveEditor editor) + editor.RefreshCharacterApplyPendingState(); + } + public TraitCouple(Trait leftTrait, Trait rightTrait) { InitializeComponent(); diff --git a/D-OS Save Editor/SE/TraitsTab.xaml.cs b/D-OS Save Editor/SE/TraitsTab.xaml.cs index 1dbc9eb..64dfdfd 100644 --- a/D-OS Save Editor/SE/TraitsTab.xaml.cs +++ b/D-OS Save Editor/SE/TraitsTab.xaml.cs @@ -18,14 +18,33 @@ public Player Player } private TraitCouple[] _traitCouples; + public bool SuppressTraitNotifications { get; private set; } public TraitsTab() { InitializeComponent(); } + + public bool HasPendingEdits() + { + if (Player == null || _traitCouples == null) return false; + for (var i = 0; i < DataTable.TraitNames.Length / 2; i++) + { + if (_traitCouples[i] == null) continue; + var tc = _traitCouples[i].DataContext as TraitCoupleViewModel; + if (tc == null) continue; + if (tc.LeftTrait.Value != Player.Traits[2 * i].ToString()) return true; + if (tc.RightTrait.Value != Player.Traits[2 * i + 1].ToString()) return true; + } + return false; + } + public void UpdateForm() { + SuppressTraitNotifications = true; + try + { _traitCouples = new TraitCouple[DataTable.TraitNames.Length / 2]; StackPanel.Children.Clear(); @@ -39,12 +58,18 @@ public void UpdateForm() StackPanel.Children.Add(_traitCouples[i]); } + } + finally + { + SuppressTraitNotifications = false; + } } public void SaveEdits() { for (var i = 0; i < DataTable.TraitNames.Length / 2; i++) { + if (_traitCouples[i] == null) continue; var tc = _traitCouples[i].DataContext as TraitCoupleViewModel; Player.Traits[2 * i] = int.Parse(tc.LeftTrait.Value); Player.Traits[2 * i + 1] = int.Parse(tc.RightTrait.Value); diff --git a/D-OS Save Editor/savegame/DataTable.cs b/D-OS Save Editor/savegame/DataTable.cs index 0491b43..bc8c32b 100644 --- a/D-OS Save Editor/savegame/DataTable.cs +++ b/D-OS Save Editor/savegame/DataTable.cs @@ -327,7 +327,13 @@ public enum Abilities /// Names used for Gold category items /// public static readonly string[] GoldNames = - {"small_gold", "inbetween_gold", "trader_large_gold", "trader_insane_gold"}; + { + "small_gold", + "inbetween_gold", + "larger_gold", + "trader_large_gold", + "trader_insane_gold" + }; /// /// Names used for Arrow category items. Arrow items have prefix WPN which is shared with the Weapon category. Therefore, we need these strings to identify the Arrow category. diff --git a/D-OS Save Editor/savegame/Item.cs b/D-OS Save Editor/savegame/Item.cs index 2a73b1a..c9bb7dd 100644 --- a/D-OS Save Editor/savegame/Item.cs +++ b/D-OS Save Editor/savegame/Item.cs @@ -248,6 +248,33 @@ public Item DeepClone() } + + + /// + /// Whether Amount (stack count) may be edited. Disabled for weapons, armor, furniture, quest items, keys, and Unique rarity. + /// + private bool IsAmountEditable() + { + switch (ItemSort) + { + case ItemSortType.Weapon: + case ItemSortType.Armor: + case ItemSortType.Furniture: + case ItemSortType.Quest: + case ItemSortType.Key: + return false; + } + + // Stats ids use arm_* for armor; keep Amount locked if sort was misclassified (e.g. item_* armor). + if (!string.IsNullOrEmpty(StatsName) && + StatsName.StartsWith("arm_", StringComparison.OrdinalIgnoreCase)) + return false; + + if (ItemRarity == ItemRarityType.Unique) + return false; + + return true; + } /// /// Get the names of the properties that can be safely and meaningfully changed. /// @@ -266,14 +293,8 @@ public string GetAllowedChangeType() s += nameof(Generation); } - if (ItemSort == ItemSortType.Potion || - ItemSort == ItemSortType.Gold || - ItemSort == ItemSortType.Granade || - ItemSort == ItemSortType.Scroll || - ItemSort == ItemSortType.Food) - { + if (IsAmountEditable()) s += nameof(Amount); - } if (ItemSort == ItemSortType.Furniture) { diff --git a/SortGenerationList/App.config b/SortGenerationList/App.config index 731f6de..f2f3978 100644 --- a/SortGenerationList/App.config +++ b/SortGenerationList/App.config @@ -1,6 +1,6 @@ - + - + - \ No newline at end of file + diff --git a/SortGenerationList/SortGenerationList.csproj b/SortGenerationList/SortGenerationList.csproj index 728023c..b24c11f 100644 --- a/SortGenerationList/SortGenerationList.csproj +++ b/SortGenerationList/SortGenerationList.csproj @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ Exe SortGenerationList SortGenerationList - v4.6.1 + v4.6.2 512 true @@ -50,4 +50,4 @@ - \ No newline at end of file + diff --git a/wishlist.md b/wishlist.md new file mode 100644 index 0000000..b6f7760 --- /dev/null +++ b/wishlist.md @@ -0,0 +1,23 @@ +# D-OS Save Editor — wishlist / backlog + +Items are roughly priority-agnostic unless noted. Pick up in any order. + +## Done (reference) + +- Session vs disk dirty state (“Not saved to file”, close prompts) +- Unapplied-edit labeling and Apply gating (character tabs + inventory) +- Gold name filter / amount rules; armor amount locked like weapons + +## Backlog + +1. **Duplicate item** — copy a row (sensible rules: maybe exclude stackables / Unique where unsafe) +2. **Delete inventory item** — remove any item row from inventory +3. **Nested containers** — open bags / pouches / backpacks (scrolls, arrows, organization) +4. **Rename items** — especially pouches; optionally add new containers +5. **Inventory icons** — resolve from user-selected local D:OS EE install (no bundled Larian assets) +6. **Auto-detect game install** — registry / Steam–GOG-style paths (parallel to save-folder logic) +7. **Remove character** — drop a party member / stuck NPC from the save +8. **Main character** — detect main PC and autoselect on load (not alphabetical) +9. **Skills tab** — UI tab to edit memorized skills +10. **Story / log tab** — UI tab to edit story quests (and related log/story state as exposed by the save format) +11. **D:OS 2** — Divinity: Original Sin 2 save support From 49eba788e0865427fdbd5b1d8306058619340ed3 Mon Sep 17 00:00:00 2001 From: Phil Lenton Date: Fri, 3 Apr 2026 01:28:15 -0500 Subject: [PATCH 2/6] docs: CHANGELOG for dirty state, Amount rules, gold, build (4.6.2) Made-with: Cursor --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d1a00d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes in this fork are documented here (upstream: [AnthonyZJiang/D-OS-Save-Editor](https://github.com/AnthonyZJiang/D-OS-Save-Editor)). + +## Post-fork improvements (master) + +### Session and save UX + +- **Dirty state:** Window title `*` and “Not saved to file” when there are in-memory changes not written to disk; close prompt; cleared on successful Save and Reset. +- **Unapplied edits:** Character **Apply** and inventory **Apply changes** enable when pending edits exist; orange labels (“Unapplied edits”) with **Apply** before the label so the button does not jump; close warns if character or inventory edits are still unapplied. +- **Tabs:** Stats, Abilities, Traits, Talents report pending edits so Apply state stays accurate. + +### Inventory + +- **Amount (stack count):** Centralized rules via `IsAmountEditable()`. Amount is editable for stackable-style items (potions, loot, skill books, arrows, etc.) and **not** for equipment-style rows (weapons, armor, furniture), quest items, keys, **Unique** rarity, or stats ids starting with `arm_` (armor), matching game behavior better than the old allow-list (potion/gold/grenade/scroll/food only). +- **Gold:** `DataTable.GoldNames` extended (e.g. `larger_gold`) so gold is categorized and filtered correctly. +- **Layout:** Inventory pending label aligned with character bar (Apply left, message right). + +### Build + +- **SortGenerationList** retargeted to **.NET Framework 4.6.2** so the solution builds with the same targeting pack as the main WPF app (`App.config` / `csproj` updated). + +### Other + +- **wishlist.md** — backlog and done reference for future work (nested containers, skills/story tabs, D:OS 2, etc.). From b6d5cc604ce47b5c74d9715002ddc13e4affd43b Mon Sep 17 00:00:00 2001 From: Phil Lenton Date: Fri, 3 Apr 2026 04:41:51 -0500 Subject: [PATCH 3/6] feat(D:OS EE): nested inventory, save fixes, inventory UX - BFS parse/load for all player items (nested containers); ItemXmlNodeIdx aligned - Write item edits against freshly loaded globals.lsx (replay BFS for XmlNode refs) - SetLsxAttributeValue for LSX writes; sequential WritePlayer (no parallel DOM writes) - Inventory: TreeView, filters, search, Apply + pending state; sync TreeView Tag after apply - Amount rules (IsAmountEditable), gold names; remove display name field; equip slot (0-14) - Save editor width; optional display-name parse from value/handle (unused in UI) - SAVE_FORMAT_NOTES.md; CHANGELOG updated Made-with: Cursor --- CHANGELOG.md | 1 + D-OS Save Editor/SE/InventoryTab.xaml | 43 ++- D-OS Save Editor/SE/InventoryTab.xaml.cs | 447 ++++++++++++++--------- D-OS Save Editor/SE/SaveEditor.xaml | 2 +- D-OS Save Editor/savegame/DataTable.cs | 33 +- D-OS Save Editor/savegame/Item.cs | 23 +- D-OS Save Editor/savegame/LsxParser.cs | 275 ++++++++++---- D-OS Save Editor/savegame/Player.cs | 5 + SAVE_FORMAT_NOTES.md | 27 ++ 9 files changed, 589 insertions(+), 267 deletions(-) create mode 100644 SAVE_FORMAT_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1a00d..f6cc9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes in this fork are documented here (upstream: [AnthonyZJiang/D ### Inventory +- **Saving item edits:** `WriteEditsToLsxAsync` loads a fresh `globals.lsx` document; inventory changes must target **that** tree. Previously, `ItemXmlNodes` from the initial parse pointed at the old DOM, so amount and other item edits did not persist to disk while character attributes (written via fresh XPath) still worked. Writes now replay the same BFS inventory walk on the loaded document. `WritePlayer` also uses a sequential loop instead of `Parallel.For` so multiple players do not concurrently mutate one `XmlDocument`. - **Amount (stack count):** Centralized rules via `IsAmountEditable()`. Amount is editable for stackable-style items (potions, loot, skill books, arrows, etc.) and **not** for equipment-style rows (weapons, armor, furniture), quest items, keys, **Unique** rarity, or stats ids starting with `arm_` (armor), matching game behavior better than the old allow-list (potion/gold/grenade/scroll/food only). - **Gold:** `DataTable.GoldNames` extended (e.g. `larger_gold`) so gold is categorized and filtered correctly. - **Layout:** Inventory pending label aligned with character bar (Apply left, message right). diff --git a/D-OS Save Editor/SE/InventoryTab.xaml b/D-OS Save Editor/SE/InventoryTab.xaml index 3f70420..e60e392 100644 --- a/D-OS Save Editor/SE/InventoryTab.xaml +++ b/D-OS Save Editor/SE/InventoryTab.xaml @@ -46,7 +46,8 @@ - +