diff --git a/Ultima/SkillGroups.cs b/Ultima/SkillGroups.cs index fad1c120..f28b03f3 100644 --- a/Ultima/SkillGroups.cs +++ b/Ultima/SkillGroups.cs @@ -15,12 +15,47 @@ public SkillGroup(string name) } } + /// + /// skillgrp.mul: a group count, the names of groups 1..count-1 (group 0, Misc, has no stored name), + /// then one int32 group id per skill. The order of skills inside a group is not stored. + /// public sealed class SkillGroups { + /// + /// Skill entries the client reads and writes, whatever skills.idx holds (measured on client 7.0.117.0). + /// Skills past this are not shown in the client's skill groups. + /// + public const int ClientSkillLimit = 58; + + /// + /// Groups the client allows, Misc included. Its loader does not check this, so a file with more + /// groups overruns the client's name table (measured on client 7.0.117.0). + /// + public const int ClientGroupLimit = 12; + + /// + /// Longest group name: names are 17-character slots that the client reads up to a NUL. + /// + public const int MaxNameLength = 16; + + private const int _nameSlot = 17; + + /// + /// Groups in file order; index 0 is always Misc. + /// public static List List { get; private set; } + + /// + /// Group index of each skill, by skill id. + /// public static List SkillList { get; private set; } - private static bool _unicode; + /// + /// True when the file stores group names in 16-bit slots (count preceded by -1). This is not Unicode + /// support: the client keeps only the low byte of each character and saves the file back as 8-bit. + /// Names should be ASCII in either form. + /// + public static bool Unicode { get; private set; } static SkillGroups() { @@ -31,8 +66,9 @@ public static void Initialize() { string path = Files.GetFilePath("skillgrp.mul"); - List = new List(); + List = new List { new SkillGroup("Misc") }; SkillList = new List(); + Unicode = false; if (path == null) { @@ -42,58 +78,60 @@ public static void Initialize() using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var bin = new BinaryReader(fs)) { + if (fs.Length < 4) + { + return; + } + int start = 4; - int strLen = 17; int count = bin.ReadInt32(); if (count == -1) { - _unicode = true; + Unicode = true; count = bin.ReadInt32(); - start *= 2; - strLen *= 2; + start = 8; } - List.Add(new SkillGroup("Misc")); + int charSize = Unicode ? 2 : 1; + int slotSize = _nameSlot * charSize; + int names = Math.Max(0, count - 1); - for (int i = 0; i < count - 1; ++i) + for (int i = 0; i < names; ++i) { - int strBuild; - - fs.Seek(start + (i * strLen), SeekOrigin.Begin); - - var builder2 = new StringBuilder(17); - if (_unicode) + long offset = start + ((long)i * slotSize); + if (offset + slotSize > fs.Length) { - while ((strBuild = bin.ReadInt16()) != 0) - { - builder2.Append((char)strBuild); - } + break; } - else + + fs.Seek(offset, SeekOrigin.Begin); + + var builder = new StringBuilder(_nameSlot); + for (int c = 0; c < _nameSlot; ++c) { - while ((strBuild = bin.ReadByte()) != 0) + int ch = Unicode ? bin.ReadUInt16() : bin.ReadByte(); + if (ch == 0) { - builder2.Append((char)strBuild); + break; } + + builder.Append((char)ch); } - List.Add(new SkillGroup(builder2.ToString())); + List.Add(new SkillGroup(builder.ToString())); } - fs.Seek((start + ((count - 1) * strLen)), SeekOrigin.Begin); - - try + long skillsStart = start + ((long)names * slotSize); + if (skillsStart >= fs.Length) { - while (bin.BaseStream.Length != bin.BaseStream.Position) - { - SkillList.Add(bin.ReadInt32()); - } + return; } - catch // just for safety + + fs.Seek(skillsStart, SeekOrigin.Begin); + while (fs.Length - fs.Position >= 4) { - // TODO: ignored? - // ignored + SkillList.Add(bin.ReadInt32()); } } } @@ -105,47 +143,17 @@ public static void Save(string path) using (var fs = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) using (var bin = new BinaryWriter(fs)) { - if (_unicode) + if (Unicode) { bin.Write(-1); } bin.Write(List.Count); - foreach (SkillGroup group in List) + // Group 0 is Misc and has no stored name + for (int i = 1; i < List.Count; ++i) { - if (group.Name == "Misc") - { - continue; - } - - byte[] name = _unicode ? new byte[34] : new byte[17]; - - if (group.Name != null) - { - if (_unicode) - { - byte[] bb = Encoding.Unicode.GetBytes(group.Name); - if (bb.Length > 34) - { - Array.Resize(ref bb, 34); - } - - bb.CopyTo(name, 0); - } - else - { - byte[] bb = Encoding.ASCII.GetBytes(group.Name); - if (bb.Length > 17) - { - Array.Resize(ref bb, 17); - } - - bb.CopyTo(name, 0); - } - } - - bin.Write(name); + bin.Write(EncodeName(List[i].Name)); } foreach (int group in SkillList) @@ -154,5 +162,25 @@ public static void Save(string path) } } } + + private static byte[] EncodeName(string name) + { + var slot = new byte[_nameSlot * (Unicode ? 2 : 1)]; + if (string.IsNullOrEmpty(name)) + { + return slot; + } + + if (name.Length > MaxNameLength) + { + name = name.Substring(0, MaxNameLength); + } + + // 8-bit names are written back byte for byte as loaded (Latin-1) + byte[] bytes = Unicode ? Encoding.Unicode.GetBytes(name) : Encoding.Latin1.GetBytes(name); + bytes.CopyTo(slot, 0); + + return slot; + } } -} \ No newline at end of file +} diff --git a/Ultima/Skills.cs b/Ultima/Skills.cs index 96abdb98..8d13a160 100644 --- a/Ultima/Skills.cs +++ b/Ultima/Skills.cs @@ -92,7 +92,8 @@ private static string ReadNameString(BinaryReader bin, int length) //; } - return Encoding.ASCII.GetString(_stringBuffer, 0, count); + // Names are single bytes; Latin-1 keeps every byte as loaded so saving writes it back unchanged + return Encoding.Latin1.GetString(_stringBuffer, 0, count); } public static void Save(string path) @@ -120,7 +121,7 @@ public static void Save(string path) var length = (int)fsmul.Position; binmul.Write(skill.IsAction); - byte[] nameBytes = Encoding.ASCII.GetBytes(skill.Name); + byte[] nameBytes = Encoding.Latin1.GetBytes(skill.Name); binmul.Write(nameBytes); binmul.Write((byte)0); // null terminated diff --git a/UoFiddler.Controls/UserControls/SkillGroupControl.Designer.cs b/UoFiddler.Controls/UserControls/SkillGroupControl.Designer.cs index 4ff2f292..02c54880 100644 --- a/UoFiddler.Controls/UserControls/SkillGroupControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/SkillGroupControl.Designer.cs @@ -44,11 +44,10 @@ private void InitializeComponent() this.addToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.renameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); - this.SkillIDTextBox = new System.Windows.Forms.ToolStripTextBox(); this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); this.saveToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.treeView1 = new System.Windows.Forms.TreeView(); + this.StatusLabel = new System.Windows.Forms.Label(); this.contextMenuStrip1.SuspendLayout(); this.SuspendLayout(); // @@ -58,57 +57,42 @@ private void InitializeComponent() this.addToolStripMenuItem, this.removeToolStripMenuItem, this.renameToolStripMenuItem, - this.toolStripMenuItem1, this.toolStripSeparator1, this.saveToolStripMenuItem}); this.contextMenuStrip1.Name = "contextMenuStrip1"; - this.contextMenuStrip1.Size = new System.Drawing.Size(118, 120); + this.contextMenuStrip1.Size = new System.Drawing.Size(158, 98); this.contextMenuStrip1.Opening += new System.ComponentModel.CancelEventHandler(this.OnOpeningContext); // // addToolStripMenuItem // this.addToolStripMenuItem.Name = "addToolStripMenuItem"; - this.addToolStripMenuItem.Size = new System.Drawing.Size(117, 22); - this.addToolStripMenuItem.Text = "Add"; + this.addToolStripMenuItem.Size = new System.Drawing.Size(157, 22); + this.addToolStripMenuItem.Text = "Add Group"; this.addToolStripMenuItem.Click += new System.EventHandler(this.OnClickAdd); // // removeToolStripMenuItem // this.removeToolStripMenuItem.Name = "removeToolStripMenuItem"; - this.removeToolStripMenuItem.Size = new System.Drawing.Size(117, 22); - this.removeToolStripMenuItem.Text = "Remove"; + this.removeToolStripMenuItem.Size = new System.Drawing.Size(157, 22); + this.removeToolStripMenuItem.Text = "Remove Group"; this.removeToolStripMenuItem.Click += new System.EventHandler(this.OnClickRemove); // // renameToolStripMenuItem // this.renameToolStripMenuItem.Name = "renameToolStripMenuItem"; - this.renameToolStripMenuItem.Size = new System.Drawing.Size(117, 22); - this.renameToolStripMenuItem.Text = "Rename"; + this.renameToolStripMenuItem.Size = new System.Drawing.Size(157, 22); + this.renameToolStripMenuItem.Text = "Rename Group"; this.renameToolStripMenuItem.Click += new System.EventHandler(this.OnClickRename); // - // toolStripMenuItem1 - // - this.toolStripMenuItem1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.SkillIDTextBox}); - this.toolStripMenuItem1.Name = "toolStripMenuItem1"; - this.toolStripMenuItem1.Size = new System.Drawing.Size(117, 22); - this.toolStripMenuItem1.Text = "SkillID"; - // - // SkillIDTextBox - // - this.SkillIDTextBox.Name = "SkillIDTextBox"; - this.SkillIDTextBox.Size = new System.Drawing.Size(100, 23); - this.SkillIDTextBox.KeyDown += new System.Windows.Forms.KeyEventHandler(this.OnKeyDownSkillID); - // // toolStripSeparator1 // this.toolStripSeparator1.Name = "toolStripSeparator1"; - this.toolStripSeparator1.Size = new System.Drawing.Size(114, 6); + this.toolStripSeparator1.Size = new System.Drawing.Size(154, 6); // // saveToolStripMenuItem // this.saveToolStripMenuItem.Name = "saveToolStripMenuItem"; - this.saveToolStripMenuItem.Size = new System.Drawing.Size(117, 22); + this.saveToolStripMenuItem.Size = new System.Drawing.Size(157, 22); this.saveToolStripMenuItem.Text = "Save"; this.saveToolStripMenuItem.Click += new System.EventHandler(this.OnClickSave); // @@ -121,18 +105,30 @@ private void InitializeComponent() this.treeView1.Location = new System.Drawing.Point(0, 0); this.treeView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.treeView1.Name = "treeView1"; - this.treeView1.Size = new System.Drawing.Size(737, 386); + this.treeView1.ShowNodeToolTips = true; + this.treeView1.Size = new System.Drawing.Size(737, 326); this.treeView1.TabIndex = 1; this.treeView1.AfterLabelEdit += new System.Windows.Forms.NodeLabelEditEventHandler(this.AfterLabelEdit); this.treeView1.ItemDrag += new System.Windows.Forms.ItemDragEventHandler(this.OnItemDrag); + this.treeView1.NodeMouseClick += new System.Windows.Forms.TreeNodeMouseClickEventHandler(this.OnNodeMouseClick); this.treeView1.DragDrop += new System.Windows.Forms.DragEventHandler(this.OnDragDrop); - this.treeView1.DragEnter += new System.Windows.Forms.DragEventHandler(this.OnDragEnter); + this.treeView1.DragOver += new System.Windows.Forms.DragEventHandler(this.OnDragOver); + // + // StatusLabel + // + this.StatusLabel.Dock = System.Windows.Forms.DockStyle.Bottom; + this.StatusLabel.Location = new System.Drawing.Point(0, 326); + this.StatusLabel.Name = "StatusLabel"; + this.StatusLabel.Padding = new System.Windows.Forms.Padding(4, 2, 4, 2); + this.StatusLabel.Size = new System.Drawing.Size(737, 60); + this.StatusLabel.TabIndex = 2; // // SkillGroupControl // this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.Controls.Add(this.treeView1); + this.Controls.Add(this.StatusLabel); this.DoubleBuffered = true; this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.Name = "SkillGroupControl"; @@ -150,9 +146,8 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem renameToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem saveToolStripMenuItem; - private System.Windows.Forms.ToolStripTextBox SkillIDTextBox; - private System.Windows.Forms.ToolStripMenuItem toolStripMenuItem1; private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; private System.Windows.Forms.TreeView treeView1; + private System.Windows.Forms.Label StatusLabel; } } diff --git a/UoFiddler.Controls/UserControls/SkillGroupControl.cs b/UoFiddler.Controls/UserControls/SkillGroupControl.cs index aa593beb..ac0451fc 100644 --- a/UoFiddler.Controls/UserControls/SkillGroupControl.cs +++ b/UoFiddler.Controls/UserControls/SkillGroupControl.cs @@ -1,9 +1,9 @@ /*************************************************************************** * * $Author: Turley - * + * * "THE BEER-WARE LICENSE" - * As long as you retain this notice you can do whatever you want with + * As long as you retain this notice you can do whatever you want with * this stuff. If we meet some day, and you think this stuff is worth it, * you can buy me a beer in return. * @@ -12,6 +12,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Linq; using System.Windows.Forms; using Ultima; using UoFiddler.Controls.Classes; @@ -20,6 +21,10 @@ namespace UoFiddler.Controls.UserControls { + /// + /// Edits skillgrp.mul as a tree: groups at the root (Misc first and fixed), every skill under exactly one group. + /// Skills come from the file and skills.idx; they can be moved between groups but not removed. + /// public partial class SkillGroupControl : UserControl { public SkillGroupControl() @@ -30,7 +35,9 @@ public SkillGroupControl() } private bool _loaded; - private TreeNode _sourceNode; + private int _invalidGroupCount; + + private static Color MiscColor => Options.DarkMode ? Color.CornflowerBlue : Color.Blue; /// /// ReLoads if loaded @@ -54,51 +61,38 @@ private void OnLoad(object sender, EventArgs e) { Options.LoadedUltimaClass["SkillGrp"] = true; - treeView1.BeginUpdate(); - treeView1.Nodes.Clear(); - List cache = new List(); - + var groupNodes = new List(); foreach (SkillGroup group in SkillGroups.List) { - TreeNode groupNode = new TreeNode - { - Text = group.Name - }; + groupNodes.Add(new TreeNode(group.Name)); + } - if (string.Equals("Misc", group.Name)) - { - groupNode.ForeColor = Options.DarkMode ? Color.CornflowerBlue : Color.Blue; - } + groupNodes[0].ForeColor = MiscColor; + groupNodes[0].ToolTipText = "Skills without a group. Misc is always the first group and can't be renamed or removed."; - for (int i = 0; i < SkillGroups.SkillList.Count; ++i) + // Every skill gets a node: those in the file and those only in skills.idx + int entryCount = Math.Max(SkillGroups.SkillList.Count, Skills.SkillEntries.Count); + _invalidGroupCount = 0; + for (int id = 0; id < entryCount; ++id) + { + int group = id < SkillGroups.SkillList.Count ? SkillGroups.SkillList[id] : 0; + if (group < 0 || group >= groupNodes.Count) { - if (SkillGroups.SkillList[i] != cache.Count) - { - continue; - } - - var skillInfo = Skills.GetSkill(i); - - if (skillInfo == null) - { - continue; - } - - TreeNode skillNode = new TreeNode - { - Text = skillInfo.Name, - Tag = i - }; - - groupNode.Nodes.Add(skillNode); + // Points at a group that doesn't exist; show it under Misc, where saving puts it + group = 0; + _invalidGroupCount++; } - cache.Add(groupNode); + groupNodes[group].Nodes.Add(CreateSkillNode(id)); } - treeView1.Nodes.AddRange(cache.ToArray()); + treeView1.BeginUpdate(); + treeView1.Nodes.Clear(); + treeView1.Nodes.AddRange(groupNodes.ToArray()); treeView1.EndUpdate(); + UpdateStatus(); + if (!_loaded) { ControlEvents.FilePathChangeEvent += OnFilePathChangeEvent; @@ -113,228 +107,267 @@ private void OnFilePathChangeEvent() Reload(); } - private void OnClickSave(object sender, EventArgs e) + private static TreeNode CreateSkillNode(int id) { - SkillGroups.List.Clear(); - int skillCount = int.MinValue; - foreach (TreeNode root in treeView1.Nodes) + string name = Skills.GetSkill(id)?.Name; + var node = new TreeNode(name == null ? $"{id}: (not in skills.idx)" : $"{id}: {name}") { - foreach (TreeNode child in root.Nodes) - { - int id = (int)child.Tag; - if (id > skillCount) - { - skillCount = id; - } - } + Tag = id + }; + + if (id >= SkillGroups.ClientSkillLimit) + { + node.ForeColor = Color.Gray; + node.ToolTipText = $"The client only reads the first {SkillGroups.ClientSkillLimit} skill entries; this skill won't appear in its skill groups."; } - ++skillCount; - SkillGroups.SkillList.Clear(); - for (int i = 0; i < skillCount; ++i) + + return node; + } + + private static bool IsSkill(TreeNode node) => node?.Tag is int; + + private static bool IsMisc(TreeNode node) => node != null && node.Parent == null && node.Index == 0; + + /// + /// Adds a skill to a group, keeping the group's skills in id order. + /// + private static void InsertSkill(TreeNode group, TreeNode skill) + { + int id = (int)skill.Tag; + int index = 0; + while (index < group.Nodes.Count && (int)group.Nodes[index].Tag < id) + { + index++; + } + + group.Nodes.Insert(index, skill); + } + + private void MarkChanged() + { + Options.ChangedUltimaClass["SkillGrp"] = true; + UpdateStatus(); + } + + private void UpdateStatus() + { + int groups = treeView1.Nodes.Count; + int entries = treeView1.Nodes.Cast().Sum(group => group.Nodes.Count); + + var lines = new List { - SkillGroups.SkillList.Add(0); + $"{groups} groups including Misc (client limit {SkillGroups.ClientGroupLimit}). " + + $"{entries} skills, {Skills.SkillEntries.Count} in skills.idx; the client reads the first {SkillGroups.ClientSkillLimit}.", + }; + + bool warn = false; + if (groups > SkillGroups.ClientGroupLimit) + { + lines.Add($"More than {SkillGroups.ClientGroupLimit} groups: the client can't load this file safely."); + warn = true; } - foreach (TreeNode root in treeView1.Nodes) + + if (_invalidGroupCount > 0) + { + lines.Add($"{_invalidGroupCount} skills pointed at a missing group and are shown under Misc."); + warn = true; + } + + lines.Add(@"After replacing skillgrp.mul, delete the copies the client keeps in its Desktop\\\ and Desktop\common folders."); + + StatusLabel.Text = string.Join(Environment.NewLine, lines); + StatusLabel.ForeColor = warn ? (Options.DarkMode ? Color.OrangeRed : Color.Red) : SystemColors.ControlText; + } + + private void OnClickSave(object sender, EventArgs e) + { + int entryCount = Math.Max(SkillGroups.SkillList.Count, + treeView1.Nodes.Cast().SelectMany(group => group.Nodes.Cast()).Select(skill => (int)skill.Tag + 1).DefaultIfEmpty(0).Max()); + + var assignment = new int[entryCount]; + SkillGroups.List.Clear(); + foreach (TreeNode group in treeView1.Nodes) { - SkillGroups.List.Add(new SkillGroup(root.Text)); - foreach (TreeNode skill in root.Nodes) + SkillGroups.List.Add(new SkillGroup(group.Text)); + foreach (TreeNode skill in group.Nodes) { - SkillGroups.SkillList[(int)skill.Tag] = root.Index; + assignment[(int)skill.Tag] = group.Index; } } + + SkillGroups.SkillList.Clear(); + SkillGroups.SkillList.AddRange(assignment); SkillGroups.Save(Options.OutputPath); + + _invalidGroupCount = 0; Options.ChangedUltimaClass["SkillGrp"] = false; + UpdateStatus(); FileSavedDialog.Show(FindForm(), Options.OutputPath, "SkillGrp saved successfully."); } - private void OnItemDrag(object sender, ItemDragEventArgs e) + private void OnNodeMouseClick(object sender, TreeNodeMouseClickEventArgs e) { - _sourceNode = (TreeNode)e.Item; - if (_sourceNode == null) + // Right-click acts on the node under the mouse + if (e.Button == MouseButtons.Right) { - return; + treeView1.SelectedNode = e.Node; } + } - if (string.Equals("Misc", _sourceNode.Text)) + private void OnItemDrag(object sender, ItemDragEventArgs e) + { + if (e.Item is TreeNode node && !IsMisc(node)) { - return; + DoDragDrop(node, DragDropEffects.Move); } - - DoDragDrop(e.Item.ToString(), DragDropEffects.Move | DragDropEffects.Copy); } - private void OnDragEnter(object sender, DragEventArgs e) + private void OnDragOver(object sender, DragEventArgs e) { - e.Effect = e.Data.GetDataPresent(DataFormats.Text) ? DragDropEffects.Move : DragDropEffects.None; + e.Effect = e.Data.GetDataPresent(typeof(TreeNode)) ? DragDropEffects.Move : DragDropEffects.None; } private void OnDragDrop(object sender, DragEventArgs e) { - Point pos = treeView1.PointToClient(new Point(e.X, e.Y)); - TreeNode targetNode = treeView1.GetNodeAt(pos); - - if (targetNode == null) - { - return; - } - - var nodeCopy = new TreeNode(_sourceNode.Text, _sourceNode.ImageIndex, _sourceNode.SelectedImageIndex) - { - Tag = _sourceNode.Tag - }; - - bool targetIsSkill = targetNode.Tag != null; - bool sourceIsSkill = _sourceNode.Tag != null; - if (targetIsSkill && !sourceIsSkill) + var source = (TreeNode)e.Data.GetData(typeof(TreeNode)); + TreeNode target = treeView1.GetNodeAt(treeView1.PointToClient(new Point(e.X, e.Y))); + if (source == null || target == null || source == target) { return; } - if (targetIsSkill && sourceIsSkill) + if (IsSkill(source)) { - if (_sourceNode.Index > targetNode.Index) - { - targetNode.Parent.Nodes.Insert(targetNode.Index, nodeCopy); - } - else + // A skill dropped on a group or on one of its skills joins that group + TreeNode group = IsSkill(target) ? target.Parent : target; + if (group == source.Parent) { - targetNode.Parent.Nodes.Insert(targetNode.Index + 1, nodeCopy); + return; } + + source.Remove(); + InsertSkill(group, source); } - else if (!targetIsSkill && sourceIsSkill) - { - targetNode.Nodes.Add(nodeCopy); - } - else if (!targetIsSkill && !sourceIsSkill) + else { - if (string.Equals("Misc", targetNode.Text)) + // A group dropped on another group takes its place; Misc stays first + TreeNode group = IsSkill(target) ? target.Parent : target; + if (group == source) { - treeView1.Invalidate(); return; } - if (_sourceNode.Index > targetNode.Index) - { - treeView1.Nodes.Insert(targetNode.Index, nodeCopy); - } - else - { - treeView1.Nodes.Insert(targetNode.Index + 1, nodeCopy); - } - while (_sourceNode.GetNodeCount(false) > 0) - { - TreeNode node = _sourceNode.FirstNode; - node.Remove(); - nodeCopy.Nodes.Add(node); - } + int index = Math.Max(1, group.Index); + source.Remove(); + treeView1.Nodes.Insert(index, source); } - _sourceNode.Remove(); - nodeCopy.EnsureVisible(); - treeView1.SelectedNode = nodeCopy; - treeView1.Invalidate(); - Options.ChangedUltimaClass["SkillGrp"] = true; + source.EnsureVisible(); + treeView1.SelectedNode = source; + MarkChanged(); } - private void OnClickRename(object sender, EventArgs e) + private void OnOpeningContext(object sender, System.ComponentModel.CancelEventArgs e) { - if (treeView1.SelectedNode != null && treeView1.SelectedNode.Parent == null && - !string.Equals("Misc", treeView1.SelectedNode.Text)) - { - treeView1.LabelEdit = true; - treeView1.SelectedNode.BeginEdit(); - } + TreeNode node = treeView1.SelectedNode; + bool editableGroup = node != null && !IsSkill(node) && !IsMisc(node); + removeToolStripMenuItem.Enabled = editableGroup; + renameToolStripMenuItem.Enabled = editableGroup; } - private void AfterLabelEdit(object sender, NodeLabelEditEventArgs e) + private void OnClickAdd(object sender, EventArgs e) { - if (e.Label == null) + if (treeView1.Nodes.Count >= SkillGroups.ClientGroupLimit) { + MessageBox.Show($"The client supports at most {SkillGroups.ClientGroupLimit} groups, Misc included.", + "Skill Groups", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - if (string.Equals("Misc", e.Label)) - { - e.CancelEdit = true; - MessageBox.Show("Invalid name. Name is reserved.", "SkillGroup Edit"); - e.Node.BeginEdit(); - } - else if (e.Label.Length > 0) - { - e.Node.EndEdit(false); - } - else - { - e.CancelEdit = true; - MessageBox.Show("Invalid name. Name cannot be empty.", "SkillGroup Edit"); - e.Node.BeginEdit(); - } - treeView1.LabelEdit = false; - } + var group = new TreeNode("New Group"); + treeView1.Nodes.Add(group); + treeView1.SelectedNode = group; + MarkChanged(); - private void OnClickAdd(object sender, EventArgs e) - { - TreeNode newNode = new TreeNode("new"); - treeView1.Nodes.Add(newNode); treeView1.LabelEdit = true; - newNode.BeginEdit(); + group.BeginEdit(); } private void OnClickRemove(object sender, EventArgs e) { - if (treeView1.SelectedNode == null) + TreeNode group = treeView1.SelectedNode; + if (group == null || IsSkill(group) || IsMisc(group)) { return; } - while (treeView1.SelectedNode.GetNodeCount(false) > 0) + // Its skills go back to Misc; skills are never dropped from the file + TreeNode misc = treeView1.Nodes[0]; + while (group.Nodes.Count > 0) { - TreeNode node = treeView1.SelectedNode.FirstNode; - node.Remove(); + TreeNode skill = group.Nodes[0]; + skill.Remove(); + InsertSkill(misc, skill); } - treeView1.SelectedNode.Remove(); + + group.Remove(); + MarkChanged(); } - private void OnOpeningContext(object sender, System.ComponentModel.CancelEventArgs e) + private void OnClickRename(object sender, EventArgs e) { - if (treeView1.SelectedNode != null) + TreeNode group = treeView1.SelectedNode; + if (group == null || IsSkill(group) || IsMisc(group)) { - if (treeView1.SelectedNode.Tag == null) - { - SkillIDTextBox.Text = ""; - } - else - { - int id = (int)treeView1.SelectedNode.Tag; - SkillIDTextBox.Text = id.ToString(); - } - SkillIDTextBox.Enabled = true; - } - else - { - SkillIDTextBox.Text = ""; - SkillIDTextBox.Enabled = false; + return; } + + treeView1.LabelEdit = true; + group.BeginEdit(); } - private void OnKeyDownSkillID(object sender, KeyEventArgs e) + private void AfterLabelEdit(object sender, NodeLabelEditEventArgs e) { - if (treeView1.SelectedNode == null) + treeView1.LabelEdit = false; + + if (e.Label == null) { return; } - if (e.KeyCode != Keys.Enter) + string error = null; + string name = e.Label.Trim(); + if (name.Length == 0) + { + error = "Name cannot be empty."; + } + else if (name.Length > SkillGroups.MaxNameLength) + { + error = $"Name can be at most {SkillGroups.MaxNameLength} characters."; + } + else if (name.Any(c => c > 127)) + { + // Even the 16-bit variant of the file is narrowed to single bytes by the client + error = "Use ASCII characters only: the client stores group names as single bytes."; + } + + if (error != null) { + e.CancelEdit = true; + MessageBox.Show(error, "Skill Groups", MessageBoxButtons.OK, MessageBoxIcon.Information); + treeView1.LabelEdit = true; + e.Node.BeginEdit(); return; } - string line = SkillIDTextBox.Text.Trim(); - if (line.Length > 0 && int.TryParse(line, out int id)) + if (name != e.Label) { - treeView1.SelectedNode.Tag = id; + e.CancelEdit = true; + e.Node.Text = name; } + + MarkChanged(); } } } diff --git a/UoFiddler.Controls/UserControls/SkillsControl.cs b/UoFiddler.Controls/UserControls/SkillsControl.cs index 697803f4..84baf0f8 100644 --- a/UoFiddler.Controls/UserControls/SkillsControl.cs +++ b/UoFiddler.Controls/UserControls/SkillsControl.cs @@ -10,6 +10,7 @@ ***************************************************************************/ using System; +using System.Linq; using System.Windows.Forms; using Ultima; using UoFiddler.Controls.Classes; @@ -25,6 +26,26 @@ public SkillsControl() InitializeComponent(); SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); _source = new BindingSource(); + dataGridView1.CellValidating += OnCellValidating; + } + + private void OnCellValidating(object sender, DataGridViewCellValidatingEventArgs e) + { + // Only typed values; a name loaded with other bytes must not trap the cursor + if (!dataGridView1.IsCurrentCellInEditMode || + dataGridView1.Columns[e.ColumnIndex].DataPropertyName != nameof(SkillInfo.Name)) + { + return; + } + + // skills.mul stores names as single bytes; the client has no Unicode for them + string name = e.FormattedValue as string ?? string.Empty; + if (name.Any(c => c > 127)) + { + e.Cancel = true; + MessageBox.Show("Use ASCII characters only: skills.mul stores names as single bytes.", "Skills", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } } private bool _loaded;