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/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..83490df
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,26 @@
+# Changelog
+
+All notable changes in **[phillenton/D-OS-Save-Editor](https://github.com/phillenton/D-OS-Save-Editor)** (this repository) are documented here. The original open-source project by Anthony Jiang is **[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
+
+- **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).
+
+### 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.).
diff --git a/D-OS Save Editor/About.xaml b/D-OS Save Editor/About.xaml
index 73ebc05..2a464b0 100644
--- a/D-OS Save Editor/About.xaml
+++ b/D-OS Save Editor/About.xaml
@@ -19,7 +19,11 @@
- Github
+ Github
+
+
+
+ AnthonyZJiang/D-OS-Save-Editor
diff --git a/D-OS Save Editor/MainWindow.xaml.cs b/D-OS Save Editor/MainWindow.xaml.cs
index b3ca01a..275c97a 100644
--- a/D-OS Save Editor/MainWindow.xaml.cs
+++ b/D-OS Save Editor/MainWindow.xaml.cs
@@ -69,7 +69,7 @@ public MainWindow()
private async Task CheckUpdate()
{
Debug.WriteLine("start");
- const string urlAddress = "https://github.com/tmxkn1/D-OS-Save-Editor/blob/master/UpdateCheck";
+ const string urlAddress = "https://github.com/phillenton/D-OS-Save-Editor/blob/master/UpdateCheck";
_updateLink = null;
UpdatePanel.Visibility = Visibility.Collapsed;
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..29ccd80 100644
--- a/D-OS Save Editor/savegame/DataTable.cs
+++ b/D-OS Save Editor/savegame/DataTable.cs
@@ -1,9 +1,17 @@
-namespace D_OS_Save_Editor
+using System;
+
+namespace D_OS_Save_Editor
{
public class DataTable
{
public const string SupportedGameVersion = "2.0.119.430";
+ ///
+ /// Inventory slot indices 0..(EquipmentPaperDollSlotCount-1) on the character's main inventory are paper-doll equipment; higher slots are bag/grid.
+ /// Matches typical EquipmentSlots count on inventory nodes (15).
+ ///
+ public const int EquipmentPaperDollSlotCount = 15;
+
public enum Attributes
{
Strength = 0,
@@ -327,13 +335,42 @@ 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.
///
public static readonly string[] ArrowTypeNames = {"arrow", "arrowhead", "arrowshaft"};
+ ///
+ /// Exact Stats ids for bags/chests/pouches (lowercase). Not exhaustive — also match prefixes cont_ and gen_container_.
+ /// There is no bundled list in LSLib; extend from game data or saves as needed.
+ ///
+ public static readonly string[] ContainerStatsExactNames =
+ {
+ "cont_backpack_a",
+ "cont_backpack_a_sourcehunter",
+ "gen_container_indestructible"
+ };
+
+ ///
+ /// True if this stats id should be categorized as a container (backpack, pouch, chest, etc.).
+ ///
+ public static bool IsContainerStatsName(string statsLower)
+ {
+ if (string.IsNullOrEmpty(statsLower)) return false;
+ if (System.Array.IndexOf(ContainerStatsExactNames, statsLower) >= 0) return true;
+ if (statsLower.StartsWith("cont_", StringComparison.Ordinal)) return true;
+ if (statsLower.StartsWith("gen_container_", StringComparison.Ordinal)) return true;
+ return false;
+ }
+
public static string[] TraitNames =
{
"Forgiving",
diff --git a/D-OS Save Editor/savegame/Item.cs b/D-OS Save Editor/savegame/Item.cs
index 2a73b1a..4c201f4 100644
--- a/D-OS Save Editor/savegame/Item.cs
+++ b/D-OS Save Editor/savegame/Item.cs
@@ -12,7 +12,7 @@ public enum ChangeType { Modify, Add, Delete}
///
/// Item category or type
///
- public enum ItemSortType { Item = 0, Potion, Armor, Weapon, Gold, Skillbook, Scroll, Granade, Food, Furniture, Loot, Quest, Tool, Unique, Book, Other, Key, Arrow }
+ public enum ItemSortType { Item = 0, Potion, Armor, Weapon, Gold, Skillbook, Scroll, Granade, Food, Furniture, Loot, Quest, Tool, Unique, Book, Container, Other, Key, Arrow }
public class Item
{
#region properties
@@ -224,6 +224,26 @@ public string MaxDurabilityPatchCheck
/// Xml node name: stats of the item
///
public StatsNode Stats { get; set; }
+
+ ///
+ /// Xml attribute id="Inventory": handle of this item's nested inventory (items whose Parent equals this). "0" if none.
+ ///
+ public string NestedInventoryId { get; set; } = "0";
+
+ ///
+ /// Optional friendly label from the save: id="DisplayName" or "Name" — resolved value if present, else Larian handle (h…;n).
+ ///
+ public string DisplayName { get; set; }
+
+ ///
+ /// True if this item sits in the character's paper-doll equipment band (slot 0..EquipmentPaperDollSlotCount-1 on main inventory).
+ ///
+ public bool IsEquippedPaperDoll(string characterInventoryId)
+ {
+ if (characterInventoryId == null || Parent != characterInventoryId) return false;
+ if (!int.TryParse(Slot, out var slot)) return false;
+ return slot >= 0 && slot < DataTable.EquipmentPaperDollSlotCount;
+ }
#endregion
#region methods
@@ -248,6 +268,34 @@ 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:
+ case ItemSortType.Container:
+ 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 +314,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/D-OS Save Editor/savegame/LsxParser.cs b/D-OS Save Editor/savegame/LsxParser.cs
index 6d65179..0255f9f 100644
--- a/D-OS Save Editor/savegame/LsxParser.cs
+++ b/D-OS Save Editor/savegame/LsxParser.cs
@@ -12,6 +12,67 @@ namespace D_OS_Save_Editor
[SuppressMessage("ReSharper", "AssignNullToNotNullAttribute")]
public class LsxParser
{
+ ///
+ /// LSX stores <attribute id="..." type="..." value="..." />; value is not always Attributes[1]. Read the value attribute by name.
+ ///
+ private static string GetLsxAttributeValue(XmlNode attributeElement)
+ {
+ if (attributeElement?.Attributes == null) return null;
+ var byName = attributeElement.Attributes["value"];
+ if (byName != null) return byName.Value;
+ foreach (XmlAttribute a in attributeElement.Attributes)
+ {
+ if (string.Equals(a.LocalName, "value", StringComparison.OrdinalIgnoreCase))
+ return a.Value;
+ }
+ return null;
+ }
+
+ ///
+ /// Type 28 (TranslatedString) often stores only handle in the save; resolved value may be absent until load.
+ ///
+ private static string GetLsxAttributeValueOrHandle(XmlNode attributeElement)
+ {
+ var v = GetLsxAttributeValue(attributeElement);
+ if (!string.IsNullOrEmpty(v)) return v;
+ if (attributeElement?.Attributes == null) return null;
+ var h = attributeElement.Attributes["handle"];
+ if (h != null) return h.Value;
+ foreach (XmlAttribute a in attributeElement.Attributes)
+ {
+ if (string.Equals(a.LocalName, "handle", StringComparison.OrdinalIgnoreCase))
+ return a.Value;
+ }
+ return null;
+ }
+
+ ///
+ /// Sets the LSX value= attribute (not a fixed index — attribute order varies).
+ ///
+ private static void SetLsxAttributeValue(XmlNode parentNode, string attributeId, string value)
+ {
+ var attrEl = parentNode.SelectSingleNode($"attribute [@id='{attributeId}']");
+ if (attrEl?.Attributes == null)
+ throw new InvalidOperationException($"Missing attribute id='{attributeId}' on node.");
+ var v = attrEl.Attributes["value"];
+ if (v != null)
+ {
+ v.Value = value;
+ return;
+ }
+
+ foreach (XmlAttribute a in attrEl.Attributes)
+ {
+ if (string.Equals(a.LocalName, "value", StringComparison.OrdinalIgnoreCase))
+ {
+ a.Value = value;
+ return;
+ }
+ }
+
+ throw new InvalidOperationException($"Attribute id='{attributeId}' has no value=.");
+ }
+
#region read file
public static List GenerationBoostCollector;
public static List StatsBoostsCollector;
@@ -39,46 +100,53 @@ public static Player[] ParsePlayer(XmlDocument doc)
throw new PlayerParserException(e, playerData[i]);
}
- // now we have the inventory id, we can get items
- var inventoryData =
- doc.DocumentElement.SelectNodes($"//attribute [@id='Parent'] [@value='{players[i].InventoryId}']");
- if (inventoryData == null)
- return;
+ // BFS: top-level inventory, then each nested container (child items' Parent = container's NestedInventoryId)
+ var itemList = new List- ();
+ var nodeList = new List();
+ var expandQueue = new Queue();
+ var expandedInventories = new HashSet();
+ expandQueue.Enqueue(players[i].InventoryId);
- players[i].Items = new Item[inventoryData.Count];
- //var notAnItemIdx = new List();
- var notAnItemIdx = new ConcurrentQueue();
- Parallel.For(0, inventoryData.Count, j =>
+ while (expandQueue.Count > 0)
{
- Item item;
- try
- {
- item = ParseItem(inventoryData[j].ParentNode);
- item.ItemXmlNodeIdx = j;
- }
- catch (ObjectNullException)
- {
- notAnItemIdx.Enqueue(j);
- return;
- }
- catch (Exception e)
+ var invId = expandQueue.Dequeue();
+ if (!expandedInventories.Add(invId))
+ continue;
+
+ var inventoryData =
+ doc.DocumentElement.SelectNodes($"//attribute [@id='Parent'] [@value='{invId}']");
+ if (inventoryData == null) continue;
+
+ for (var j = 0; j < inventoryData.Count; j++)
{
- throw new ItemParserException(e, inventoryData[j].ParentNode);
- }
+ var itemNode = inventoryData[j].ParentNode;
+ Item item;
+ try
+ {
+ item = ParseItem(itemNode);
+ item.ItemXmlNodeIdx = itemList.Count;
+ }
+ catch (ObjectNullException)
+ {
+ continue;
+ }
+ catch (Exception e)
+ {
+ throw new ItemParserException(e, itemNode);
+ }
- players[i].Items[j] = item;
- players[i].SlotsOccupation[int.Parse(item.Slot)] = true;
- });
+ itemList.Add(item);
+ nodeList.Add(itemNode);
+ players[i].SlotsOccupation[int.Parse(item.Slot)] = true;
- if (notAnItemIdx.Count == 0) return;
+ var nested = item.NestedInventoryId;
+ if (!string.IsNullOrEmpty(nested) && nested != "0")
+ expandQueue.Enqueue(nested);
+ }
+ }
- // remove not an item entry
- var items = new List
- (players[i].Items);
- var list = notAnItemIdx.ToList();
- list.Sort((a, b) => b.CompareTo(a));
- foreach (var idx in list)
- items.RemoveAt(idx);
- players[i].Items = items.ToArray();
+ players[i].Items = itemList.ToArray();
+ players[i].ItemXmlNodes = nodeList;
});
return players;
}
@@ -165,13 +233,35 @@ public static Item ParseItem(XmlNode node)
item.StatsName = node.SelectSingleNode("attribute [@id='Stats']").Attributes[1].Value;
item.Parent = node.SelectSingleNode("attribute [@id='Parent']").Attributes[1].Value;
item.Slot = node.SelectSingleNode("attribute [@id='Slot']").Attributes[1].Value;
- item.Amount = node.SelectSingleNode("attribute [@id='Amount']").Attributes[1].Value;
+ var amountAttr = node.SelectSingleNode("attribute [@id='Amount']");
+ item.Amount = GetLsxAttributeValue(amountAttr) ?? amountAttr.Attributes[1].Value;
item.IsGenerated = node.SelectSingleNode("attribute [@id='IsGenerated']").Attributes[1].Value;
item.LockLevel = node.SelectSingleNode("attribute [@id='LockLevel']").Attributes[1].Value;
item.Vitality = node.SelectSingleNode("attribute [@id='Vitality']").Attributes[1].Value;
item.ItemType = node.SelectSingleNode("attribute [@id='ItemType']").Attributes[1].Value;
item.MaxVitalityPatchCheck = node.SelectSingleNode("attribute [@id='MaxVitalityPatchCheck']").Attributes[1].Value;
- item.MaxDurabilityPatchCheck = node.SelectSingleNode("attribute [@id='MaxDurabilityPatchCheck']")?.Attributes[1].Value;
+ var maxDurAttr = node.SelectSingleNode("attribute [@id='MaxDurabilityPatchCheck']");
+ item.MaxDurabilityPatchCheck = maxDurAttr == null
+ ? null
+ : GetLsxAttributeValue(maxDurAttr) ?? maxDurAttr.Attributes[1].Value;
+
+ var invAttr = node.SelectSingleNode("attribute [@id='Inventory']");
+ item.NestedInventoryId = invAttr?.Attributes[1].Value ?? "0";
+
+ // Friendly label: DisplayName / Name — prefer resolved value, else Larian handle (h…;n) when only that is stored.
+ var displayNameAttr = node.SelectSingleNode("attribute [@id='DisplayName']") ??
+ node.SelectSingleNode(".//attribute [@id='DisplayName']");
+ var rawDisplay = GetLsxAttributeValueOrHandle(displayNameAttr);
+ item.DisplayName = string.IsNullOrWhiteSpace(rawDisplay) ? null : rawDisplay.Trim();
+
+ if (string.IsNullOrEmpty(item.DisplayName))
+ {
+ var nameAttr = node.SelectSingleNode("attribute [@id='Name']") ??
+ node.SelectSingleNode("children//attribute [@id='Name']") ??
+ node.SelectSingleNode(".//attribute [@id='Name']");
+ var rawName = GetLsxAttributeValueOrHandle(nameAttr);
+ item.DisplayName = string.IsNullOrWhiteSpace(rawName) ? null : rawName.Trim();
+ }
}
catch (NullReferenceException e)
{
@@ -183,6 +273,8 @@ public static Item ParseItem(XmlNode node)
item.ItemSort = ItemSortType.Key;
else if (DataTable.GoldNames.Contains(item.StatsName.ToLower()))
item.ItemSort = ItemSortType.Gold;
+ else if (DataTable.IsContainerStatsName(item.StatsName.ToLower()))
+ item.ItemSort = ItemSortType.Container;
else
{
var nameParts = item.StatsName.ToLower().Split('_');
@@ -342,7 +434,8 @@ public static XmlDocument WritePlayer(XmlDocument doc, Player[] players)
if (playerData == null)
throw new XmlException("Unable to find any player data in the savegame.");
- Parallel.For(0, playerData.Count, i =>
+ // Sequential: XmlDocument is not safe for concurrent writes; item edits must target this loaded doc.
+ for (var i = 0; i < playerData.Count; i++)
{
playerData[i].ParentNode.ParentNode.SelectSingleNode("attribute [@id='MaxVitalityPatchCheck']")
.Attributes[1].Value = players[i].MaxVitalityPatchCheck;
@@ -395,16 +488,73 @@ public static XmlDocument WritePlayer(XmlDocument doc, Player[] players)
// write item changes
doc = WriteItemChanges(doc, players[i]);
- });
+ }
return doc;
}
+ ///
+ /// Replays the same BFS inventory walk as so we get live
+ /// references into . Required for save:
+ /// loads a new ; cached point at the old tree.
+ ///
+ private static List CollectItemXmlNodesForPlayer(XmlDocument doc, Player player)
+ {
+ var nodeList = new List();
+ var expandQueue = new Queue();
+ var expandedInventories = new HashSet();
+ expandQueue.Enqueue(player.InventoryId);
+
+ while (expandQueue.Count > 0)
+ {
+ var invId = expandQueue.Dequeue();
+ if (!expandedInventories.Add(invId))
+ continue;
+
+ var inventoryData =
+ doc.DocumentElement.SelectNodes($"//attribute [@id='Parent'] [@value='{invId}']");
+ if (inventoryData == null) continue;
+
+ for (var j = 0; j < inventoryData.Count; j++)
+ {
+ var itemNode = inventoryData[j].ParentNode;
+ Item item;
+ try
+ {
+ item = ParseItem(itemNode);
+ }
+ catch (ObjectNullException)
+ {
+ continue;
+ }
+ catch (Exception e)
+ {
+ throw new ItemParserException(e, itemNode);
+ }
+
+ nodeList.Add(itemNode);
+
+ var nested = item.NestedInventoryId;
+ if (!string.IsNullOrEmpty(nested) && nested != "0")
+ expandQueue.Enqueue(nested);
+ }
+ }
+
+ return nodeList;
+ }
+
public static XmlDocument WriteItemChanges(XmlDocument doc, Player player)
{
- // get all items belong to this player
- // find item data
- var inventoryData = doc.DocumentElement.SelectNodes($"//attribute [@id='Parent'] [@value='{player.InventoryId}']");
+ if (player.ItemChanges == null || player.ItemChanges.Count == 0)
+ return doc;
+
+ if (player.Items == null)
+ throw new InvalidOperationException("Player.Items is null.");
+
+ var itemNodes = CollectItemXmlNodesForPlayer(doc, player);
+ if (itemNodes.Count != player.Items.Length)
+ throw new InvalidOperationException(
+ $"Item XML node count ({itemNodes.Count}) must match Player.Items length ({player.Items.Length}) when writing inventory.");
foreach (var ic in player.ItemChanges)
{
@@ -420,35 +570,25 @@ public static XmlDocument WriteItemChanges(XmlDocument doc, Player player)
}
else if (ic.Value.ChangeType == ChangeType.Modify)
{
- var itemNode = inventoryData[ic.Value.Item.ItemXmlNodeIdx].ParentNode;
+ var itemNode = itemNodes[ic.Value.Item.ItemXmlNodeIdx];
var allowedChanges = ic.Value.Item.GetAllowedChangeType();
if (allowedChanges.Contains(nameof(ic.Value.Item.Amount)))
- itemNode.SelectSingleNode("attribute [@id='Amount']").Attributes[1].Value =
- ic.Value.Item.Amount;
+ SetLsxAttributeValue(itemNode, "Amount", ic.Value.Item.Amount);
if (allowedChanges.Contains(nameof(ic.Value.Item.LockLevel)))
- itemNode.SelectSingleNode("attribute [@id='LockLevel']").Attributes[1].Value =
- ic.Value.Item.LockLevel;
+ SetLsxAttributeValue(itemNode, "LockLevel", ic.Value.Item.LockLevel);
if (allowedChanges.Contains(nameof(ic.Value.Item.Vitality)))
{
- itemNode.SelectSingleNode("attribute [@id='Vitality']").Attributes[1].Value =
- ic.Value.Item.Vitality;
- itemNode.SelectSingleNode("attribute [@id='MaxVitalityPatchCheck']").Attributes[1].Value =
- ic.Value.Item.MaxVitalityPatchCheck;
+ SetLsxAttributeValue(itemNode, "Vitality", ic.Value.Item.Vitality);
+ SetLsxAttributeValue(itemNode, "MaxVitalityPatchCheck", ic.Value.Item.MaxVitalityPatchCheck);
}
if (allowedChanges.Contains(nameof(ic.Value.Item.ItemRarity)))
- itemNode.SelectSingleNode("attribute [@id='ItemType']").Attributes[1].Value =
- ic.Value.Item.ItemRarity.ToString();
+ SetLsxAttributeValue(itemNode, "ItemType", ic.Value.Item.ItemRarity.ToString());
- // max durability cannot be changed
- //var node = itemNode.SelectSingleNode("attribute [@id='MaxDurabilityPatchCheck']");
- //if (allowedChanges.Contains(nameof(ic.Value.Item.Stats)) && node!=null)
- //{
- // node.Attributes[1].Value = ic.Value.Item.MaxDurabilityPatchCheck;
- //}
+ // MaxDurabilityPatchCheck: display-only — game derives real max from item data at runtime.
// check if has generation
if (allowedChanges.Contains(nameof(ic.Value.Item.Generation)) &&
@@ -488,7 +628,7 @@ public static XmlDocument WriteItemChanges(XmlDocument doc, Player player)
childrenNode.AppendChild(boost);
}
- itemNode.SelectSingleNode("attribute [@id='IsGenerated']").Attributes[1].Value = "True";
+ SetLsxAttributeValue(itemNode, "IsGenerated", "True");
}
// check if has stats
@@ -497,17 +637,12 @@ public static XmlDocument WriteItemChanges(XmlDocument doc, Player player)
ic.Value.Item.Stats == null)
continue;
- statsNode.SelectSingleNode("attribute [@id='Durability']").Attributes[1].Value =
- ic.Value.Item.Stats.Durability;
- statsNode.SelectSingleNode("attribute [@id='DurabilityCounter']").Attributes[1].Value =
- ic.Value.Item.Stats.DurabilityCounter;
- statsNode.SelectSingleNode("attribute [@id='RepairDurabilityPenalty']").Attributes[1].Value =
- ic.Value.Item.Stats.RepairDurabilityPenalty;
- statsNode.SelectSingleNode("attribute [@id='Level']").Attributes[1].Value =
- ic.Value.Item.Stats.Level;
- statsNode.SelectSingleNode("attribute [@id='ItemType']").Attributes[1].Value =
- ic.Value.Item.ItemType; // ItemType is taken from item.ItemType
- statsNode.SelectSingleNode("attribute [@id='IsIdentified']").Attributes[1].Value = "1";
+ SetLsxAttributeValue(statsNode, "Durability", ic.Value.Item.Stats.Durability);
+ SetLsxAttributeValue(statsNode, "DurabilityCounter", ic.Value.Item.Stats.DurabilityCounter);
+ SetLsxAttributeValue(statsNode, "RepairDurabilityPenalty", ic.Value.Item.Stats.RepairDurabilityPenalty);
+ SetLsxAttributeValue(statsNode, "Level", ic.Value.Item.Stats.Level);
+ SetLsxAttributeValue(statsNode, "ItemType", ic.Value.Item.ItemType);
+ SetLsxAttributeValue(statsNode, "IsIdentified", "1");
}
}
diff --git a/D-OS Save Editor/savegame/Player.cs b/D-OS Save Editor/savegame/Player.cs
index 921a1b8..dc1a8f6 100644
--- a/D-OS Save Editor/savegame/Player.cs
+++ b/D-OS Save Editor/savegame/Player.cs
@@ -213,6 +213,11 @@ public string TalentPoints
///
public Item[] Items { get; set; }
+ ///
+ /// Xml nodes for each entry in (same index as ). Used when writing item edits for nested inventories.
+ ///
+ public List ItemXmlNodes { get; set; }
+
///
/// NOT IN USE. Amount of gold.
///
diff --git a/README.md b/README.md
index 246c843..56398d9 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,13 @@
The save editor will only work for the said game version. Do *NOT* use it for the `original D-OS` or `D-OS 2`.
+**This repository:** [github.com/phillenton/D-OS-Save-Editor](https://github.com/phillenton/D-OS-Save-Editor) — current development and releases.
+
+**Original project:** [github.com/AnthonyZJiang/D-OS-Save-Editor](https://github.com/AnthonyZJiang/D-OS-Save-Editor) (Anthony Jiang).
For use and discussions, please head over to: [Larian Forum](http://larian.com/forums/ubbthreads.php?ubb=showflat&Number=644516#Post644516)
-Latest release: [alpha preview](https://github.com/tmxkn1/D-OS-Save-Editor/releases)
+Older published builds (pre-fork): [alpha preview @ tmxkn1](https://github.com/tmxkn1/D-OS-Save-Editor/releases).
----
Credit goes to Norbyte and his [LSTools](https://github.com/Norbyte/lslib).
diff --git a/SAVE_FORMAT_NOTES.md b/SAVE_FORMAT_NOTES.md
new file mode 100644
index 0000000..8737b34
--- /dev/null
+++ b/SAVE_FORMAT_NOTES.md
@@ -0,0 +1,27 @@
+# Save / LSX notes (D:OS EE)
+
+## Example unpacked XML to inspect
+
+`GameSaves/Divinity Original Sin Enhanced Edition/SaveFiles/globals.lsx`
+
+(Path is relative to the **D-OS Game File Editor** workspace folder.)
+
+## Inventory `Item` nodes vs in-game names
+
+Character **inventory** `` rows usually have **no** `attribute id="DisplayName"`. The friendly name you see in-game for a given `Stats` value (e.g. `SCROLL_Fireball_1`) comes from the **game’s data** (string tables / stats definitions), not from an extra string stored on that save row.
+
+`DisplayName` **does** appear elsewhere in `globals.lsx` (e.g. on **`Template`** nodes in level data), but those are not the same nodes as inventory items.
+
+## Same `Stats` (e.g. three `CONT_Backpack_A`) — what can still differ?
+
+If several items share **`Stats`** and the same **`CurrentTemplate` / `OriginalTemplate`** GUIDs, the save can still differ on fields such as:
+
+| Area | Examples (see your `globals.lsx`) |
+|------|-------------------------------------|
+| **`Flags`** | Same stats, different numeric `Flags` (e.g. `4227992` vs `33688`) — bit flags for item state. |
+| **`ItemMachine`** | Some instances include `Type`, `ItemState` / `TransactionID`; others have an empty `ItemMachine`. |
+| **`Key`** | Non-empty if the player renamed the item in-game. |
+| **`Inventory` / `Parent` / `Slot` / `owner`** | Which bag or character holds the item (different container instances). |
+| **Nested `Generation` / `Stats` / `VariableManager`** | Modifiers, identification, script variables. |
+
+So: there is **not** a second “display name” column on inventory items for the UI text. Differences you see between “backpack” vs “pouch” for the **same** `Stats` string are likely **flags**, **ItemMachine**, **rename (`Key`)**, or **game-side logic** reading template + state—not a missing `DisplayName` attribute in the save.
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/UpdateCheck b/UpdateCheck
index 05e4d62..b95d977 100644
--- a/UpdateCheck
+++ b/UpdateCheck
@@ -1,4 +1,4 @@
HandShake={ABCQWEZXCrtyfghvbnUIOJKLNM}
LatestVersion={v1.6.0}
-Link=linkStart{https://github.com/tmxkn1/D-OS-Save-Editor/releases}linkEnd
+Link=linkStart{https://github.com/phillenton/D-OS-Save-Editor/releases}linkEnd
Msg=msgStart{Updated! v1.6.0. Meta info, descpritions for Abilities and more. }msgEnd
diff --git a/UpdateCheck1 b/UpdateCheck1
index 05e4d62..b95d977 100644
--- a/UpdateCheck1
+++ b/UpdateCheck1
@@ -1,4 +1,4 @@
HandShake={ABCQWEZXCrtyfghvbnUIOJKLNM}
LatestVersion={v1.6.0}
-Link=linkStart{https://github.com/tmxkn1/D-OS-Save-Editor/releases}linkEnd
+Link=linkStart{https://github.com/phillenton/D-OS-Save-Editor/releases}linkEnd
Msg=msgStart{Updated! v1.6.0. Meta info, descpritions for Abilities and more. }msgEnd
diff --git a/scripts/create-upstream-pr.ps1 b/scripts/create-upstream-pr.ps1
new file mode 100644
index 0000000..a0225a8
--- /dev/null
+++ b/scripts/create-upstream-pr.ps1
@@ -0,0 +1,43 @@
+# OPTIONAL: Open a pull request from your fork into the original maintainer's repo (AnthonyZJiang/D-OS-Save-Editor).
+# Day-to-day work: push to origin — https://github.com/phillenton/D-OS-Save-Editor
+# Requires: GitHub CLI (`winget install GitHub.cli`) and `gh auth login` once.
+
+$ErrorActionPreference = "Stop"
+$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
+
+if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
+ Write-Error "GitHub CLI (gh) not found. Install with: winget install GitHub.cli"
+}
+
+gh auth status 2>$null
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Not authenticated. Run: gh auth login" -ForegroundColor Yellow
+ exit 1
+}
+
+$bodyFile = Join-Path $env:TEMP "gh-pr-dos-body.md"
+@'
+## Summary
+Enhancements for **Divinity: Original Sin – Enhanced Edition** saves: full nested inventory support, reliable persistence of inventory edits to `globals.lsx`, and clearer inventory UI/session behavior.
+
+## Key changes
+- **Nested items:** BFS inventory walk (same order for parse and write); `ItemXmlNodeIdx` targets the correct XML node for nested containers.
+- **Save fix:** `WriteEditsToLsxAsync` reloads `globals.lsx`; item writes use **fresh** `XmlNode`s from a replayed BFS on that document. `WritePlayer` is sequential (no parallel DOM writes).
+- **LSX writes:** `SetLsxAttributeValue` sets the `value=` attribute reliably.
+- **UI:** Inventory `TreeView`, filters/search, **Apply** + pending labels; `TreeViewItem.Tag` synced after Apply; wider save dialog; equip slot (0–14) for paper doll; compact equipped marker; removed non-functional display-name row.
+- **Amount:** `IsAmountEditable()` rules; extended gold names in `DataTable`.
+- **Docs:** `CHANGELOG.md`, `SAVE_FORMAT_NOTES.md`.
+
+Fork: `phillenton/D-OS-Save-Editor` branch `feature/nested-containers` → original repo `master`.
+'@ | Set-Content -Path $bodyFile -Encoding UTF8
+
+gh pr create `
+ --repo AnthonyZJiang/D-OS-Save-Editor `
+ --base master `
+ --head phillenton:feature/nested-containers `
+ --title "D:OS EE: nested inventory, item save fixes, inventory UX" `
+ --body-file $bodyFile
+
+if ($LASTEXITCODE -eq 0) {
+ Write-Host "Done." -ForegroundColor Green
+}
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