From a5ab9ad99cabdab7f866767d77db0c753e64d56d Mon Sep 17 00:00:00 2001 From: brewertonsantos Date: Sun, 29 Mar 2026 14:13:38 -0300 Subject: [PATCH 1/2] feat(session-import): segment merge menu into category-specific import menus Split the single "Merge Session Into Current" menu into 4 separate menus: - Import Items (cube icon, blue) - Import Outfits (shirt icon, green) - Import Effects (wand icon, yellow) - Import Missiles (arrow icon, pink) Each menu dynamically lists available session DAT files and imports only the selected category, making it easier to selectively merge assets. Changes: - MainWindow.axaml: 4 new MenuItems with icons and category colors - MainWindow.axaml.cs: WireImportMenu helper for dynamic submenu wiring - MainWindowViewModel.cs: MergeSessionAsync accepts ThingCategory filter --- src/App/MainWindow.axaml | 16 ++++- src/App/MainWindow.axaml.cs | 82 +++++++++++++---------- src/App/ViewModels/MainWindowViewModel.cs | 16 +++-- 3 files changed, 70 insertions(+), 44 deletions(-) diff --git a/src/App/MainWindow.axaml b/src/App/MainWindow.axaml index c6b31f4..6b40eaa 100644 --- a/src/App/MainWindow.axaml +++ b/src/App/MainWindow.axaml @@ -303,8 +303,20 @@ - - + + + + + + + + + + + + + + diff --git a/src/App/MainWindow.axaml.cs b/src/App/MainWindow.axaml.cs index 4200c43..450f030 100644 --- a/src/App/MainWindow.axaml.cs +++ b/src/App/MainWindow.axaml.cs @@ -60,43 +60,11 @@ public MainWindow() await vm.TryLoadLastSessionAsync(); - // Wire Merge Session menu (dynamic submenu listing other sessions) - var mergeMenuItem = this.FindControl("MergeSessionMenuItem"); - if (mergeMenuItem != null) - { - mergeMenuItem.SubmenuOpened += (_, _) => - { - mergeMenuItem.Items.Clear(); - var sources = vm.Sessions - .Where(s => s != vm.ActiveSession && s.DatData != null && s.SprFile != null) - .ToList(); - - if (sources.Count == 0 || vm.ActiveSession?.DatData == null) - { - var empty = new Avalonia.Controls.MenuItem - { - Header = vm.ActiveSession?.DatData == null - ? "Current session has no DAT loaded" - : "No other sessions with DAT/SPR loaded", - IsEnabled = false, - }; - mergeMenuItem.Items.Add(empty); - } - else - { - foreach (var source in sources) - { - var mi = new Avalonia.Controls.MenuItem - { - Header = $"{source.Name} ({source.DatData!.Items.Count + source.DatData.Outfits.Count + source.DatData.Effects.Count + source.DatData.Missiles.Count} things)", - Tag = source, - }; - mi.Click += async (_, _) => await vm.MergeSessionAsync(source); - mergeMenuItem.Items.Add(mi); - } - } - }; - } + // Wire per-category Import menus (dynamic submenus listing other sessions) + WireImportMenu("ImportItemsMenuItem", ThingCategory.Item, vm); + WireImportMenu("ImportOutfitsMenuItem", ThingCategory.Outfit, vm); + WireImportMenu("ImportEffectsMenuItem", ThingCategory.Effect, vm); + WireImportMenu("ImportMissilesMenuItem", ThingCategory.Missile, vm); // Wire confirmation dialog for palette delete operations if (vm.Palette != null) @@ -133,6 +101,46 @@ public MainWindow() }; } + private void WireImportMenu(string controlName, ThingCategory category, MainWindowViewModel vm) + { + var menuItem = this.FindControl(controlName); + if (menuItem == null) return; + + menuItem.SubmenuOpened += (_, _) => + { + menuItem.Items.Clear(); + var sources = vm.Sessions + .Where(s => s != vm.ActiveSession && s.DatData != null && s.SprFile != null) + .ToList(); + + if (sources.Count == 0 || vm.ActiveSession?.DatData == null) + { + var empty = new Avalonia.Controls.MenuItem + { + Header = vm.ActiveSession?.DatData == null + ? "Current session has no DAT loaded" + : "No other sessions with DAT/SPR loaded", + IsEnabled = false, + }; + menuItem.Items.Add(empty); + } + else + { + foreach (var source in sources) + { + var dict = MainWindowViewModel.GetCategoryDict(source.DatData!, category); + var mi = new Avalonia.Controls.MenuItem + { + Header = $"{source.Name} ({dict.Count} {category.ToString().ToLowerInvariant()}s)", + Tag = source, + }; + mi.Click += async (_, _) => await vm.MergeSessionAsync(source, category); + menuItem.Items.Add(mi); + } + } + }; + } + private bool _closeConfirmed; private async void OnWindowClosing(object? sender, WindowClosingEventArgs e) diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index 714421b..6a04047 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -570,10 +570,11 @@ private static void StripUnsupportedFlags(DatThingType thing, int targetProtocol // ── Full session merge (DAT/SPR) ── /// - /// Merge all items from a source session into the current (active) session. + /// Import things from a source session into the current (active) session. + /// When is specified, only that category is imported. /// Detects duplicates by comparing sprite images and shows a batch preview dialog. /// - public async Task MergeSessionAsync(SessionViewModel sourceSession) + public async Task MergeSessionAsync(SessionViewModel sourceSession, ThingCategory? categoryFilter = null) { if (_datData == null || _sprFile == null) { @@ -591,7 +592,7 @@ public async Task MergeSessionAsync(SessionViewModel sourceSession) var sourceProtocol = sourceDat.ProtocolVersion; var targetProtocol = _datData.ProtocolVersion; - var categories = new[] + var allCategories = new[] { (ThingCategory.Item, sourceDat.Items, _datData.Items), (ThingCategory.Outfit, sourceDat.Outfits, _datData.Outfits), @@ -599,8 +600,13 @@ public async Task MergeSessionAsync(SessionViewModel sourceSession) (ThingCategory.Missile, sourceDat.Missiles, _datData.Missiles), }; + var categories = categoryFilter.HasValue + ? allCategories.Where(c => c.Item1 == categoryFilter.Value).ToArray() + : allCategories; + int totalSource = categories.Sum(c => c.Item2.Count); - StatusText = $"Analyzing {totalSource} source things for duplicates…"; + var label = categoryFilter?.ToString().ToLowerInvariant() ?? "thing"; + StatusText = $"Analyzing {totalSource} source {label}s for duplicates…"; // Analyze each category var entries = new List(); @@ -5192,7 +5198,7 @@ private Dictionary GetDatDictForCategory(ThingCategory cat return GetCategoryDict(_datData!, category); } - private static Dictionary GetCategoryDict(DatData data, ThingCategory category) + public static Dictionary GetCategoryDict(DatData data, ThingCategory category) { return category switch { From f12340511cb278da7b7d28639ccd88878606dc13 Mon Sep 17 00:00:00 2001 From: brewertonsantos Date: Sun, 29 Mar 2026 14:13:47 -0300 Subject: [PATCH 2/2] feat(dat-parser): decouple extended, enhancedAnimations and frameGroups as independent feature flags The DAT parser previously derived all three texture features (U32 sprites, enhanced animations, frame groups) from the protocol version number. This failed for custom DAT files (e.g. PStory) that use hybrid combinations like V4 flags (proto 854) with U32 sprites + enhanced animations + frame groups - a mix no standard Tibia version uses. Changes: - ParseThing now accepts separate bool parameters for each feature - Load() tries 8 feature flag combinations per protocol (like PStory client tryLoadDatWithFallbacks) and picks the first perfect parse - DatData model stores EnhancedAnimations and FrameGroups properties - Save/WriteThing respects feature flags for sprite size and animations This fixes Effects (2208) and Missiles (223) showing as 0 when loading PStory DAT files that require proto=854+ext+anim+fg. --- src/OTB/DatFile.cs | 178 +++++++++++++++++++++++++++++---------------- 1 file changed, 115 insertions(+), 63 deletions(-) diff --git a/src/OTB/DatFile.cs b/src/OTB/DatFile.cs index 31da6b4..1b47e47 100644 --- a/src/OTB/DatFile.cs +++ b/src/OTB/DatFile.cs @@ -21,49 +21,69 @@ public static DatData Load(string path, int protocolHint = 0) DiagLog?.Invoke($"[DAT] File={Path.GetFileName(path)}, size={raw.Length}, sig=0x{sig:X8}, detectedProto={detected}, hint={protocolHint}"); int primary = protocolHint > 0 ? protocolHint : detected; - bool primaryExtended = primary >= 960; - // Try primary protocol with default extended setting - try - { - var result = Parse(raw, primary, primaryExtended); - DiagLog?.Invoke($"[DAT] Parse OK: proto={primary}, extended={primaryExtended}, items={result.ItemCount}"); - return result; - } - catch (Exception ex) - { - DiagLog?.Invoke($"[DAT] Parse FAILED: proto={primary}, ext={primaryExtended}: {ex.Message}"); - } + // Build protocol list: primary first, then fallbacks. + var protocols = new List { primary }; + int[] allProtocols = [1098, 1076, 1057, 1050, 960, 860, 854, 810, 800, 790, 780, 770, 760, 750, 740]; + foreach (var p in allProtocols) + if (p != primary) protocols.Add(p); + + // Feature flag combinations (like PStory's tryLoadDatWithFallbacks): + // extended (U32 sprites), enhancedAnimations, frameGroups — all independent. + var featureCombos = new (bool ext, bool anim, bool fg)[] + { + (false, false, false), // version-default for <=854 + (true, false, false), // SpritesU32 only + (true, true, false), // SpritesU32 + EnhancedAnimations + (true, true, true), // SpritesU32 + EnhancedAnimations + IdleAnimations + (false, true, false), // EnhancedAnimations only + (false, false, true), // IdleAnimations only + (false, true, true), // EnhancedAnimations + IdleAnimations + (true, false, true), // SpritesU32 + IdleAnimations + }; - // Try primary protocol with opposite extended setting - try - { - var result = Parse(raw, primary, !primaryExtended); - DiagLog?.Invoke($"[DAT] Parse OK: proto={primary}, extended={!primaryExtended}, items={result.ItemCount}"); - return result; - } - catch (Exception ex) - { - DiagLog?.Invoke($"[DAT] Parse FAILED: proto={primary}, ext={!primaryExtended}: {ex.Message}"); - } + DatData? bestResult = null; + int bestTotal = -1; - // Fallback: try every other protocol with both extended modes - int[] allProtocols = [1098, 1076, 1057, 1050, 960, 860, 854, 810, 800, 790, 780, 770, 760, 750, 740]; - foreach (var proto in allProtocols) + foreach (var proto in protocols) { - if (proto == primary) continue; - foreach (var ext in new[] { true, false }) + foreach (var (ext, anim, fg) in featureCombos) { + DatData result; try { - var result = Parse(raw, proto, ext); - DiagLog?.Invoke($"[DAT] Fallback OK: proto={proto}, extended={ext}, items={result.ItemCount}"); + result = Parse(raw, proto, ext, anim, fg); + } + catch (Exception ex) + { + DiagLog?.Invoke($"[DAT] Parse FAILED: proto={proto}, ext={ext}, anim={anim}, fg={fg}: {ex.Message}"); + continue; + } + + int total = result.Items.Count + result.Outfits.Count + result.Effects.Count + result.Missiles.Count; + int expected = result.ItemCount + result.OutfitCount + result.EffectCount + result.MissileCount; + + DiagLog?.Invoke($"[DAT] Parse OK: proto={proto}, ext={ext}, anim={anim}, fg={fg}, things={total}/{expected} (items={result.Items.Count}, outfits={result.Outfits.Count}, effects={result.Effects.Count}, missiles={result.Missiles.Count})"); + + // Perfect parse — return immediately + if (total == expected) return result; + + // Track the best partial result + if (total > bestTotal) + { + bestTotal = total; + bestResult = result; } - catch { } } } + if (bestResult != null) + { + DiagLog?.Invoke($"[DAT] Using best partial result: {bestTotal} things parsed."); + return bestResult; + } + throw new InvalidOperationException( $"Failed to parse {Path.GetFileName(path)} (sig=0x{sig:X8}, size={raw.Length}). No protocol/extended combination worked."); } @@ -97,7 +117,7 @@ public static int DetectProtocol(uint signature) }; } - private static DatData Parse(byte[] raw, int protocolHint, bool extended) + private static DatData Parse(byte[] raw, int protocolHint, bool extended, bool enhancedAnimations, bool frameGroups) { var r = new DatReader(raw); @@ -124,7 +144,7 @@ private static DatData Parse(byte[] raw, int protocolHint, bool extended) { try { - var thing = ParseThing(r, (ushort)id, ThingCategory.Item, protocol, extended); + var thing = ParseThing(r, (ushort)id, ThingCategory.Item, protocol, extended, enhancedAnimations, frameGroups); items[(ushort)id] = thing; } catch (Exception ex) @@ -134,26 +154,47 @@ private static DatData Parse(byte[] raw, int protocolHint, bool extended) } } - // Parse outfits/effects/missiles independently — don't let failures block items - bool secondaryFailed = false; + // Parse outfits/effects/missiles independently — each category gets its own + // try/catch so a failure in one doesn't blank out the others. + // Partial results are KEPT (e.g. 4970/5030 outfits is better than 0). + try { for (int id = 1; id <= numOutfits; id++) - outfits[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Outfit, protocol, extended); + outfits[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Outfit, protocol, extended, enhancedAnimations, frameGroups); + DiagLog?.Invoke($"[DAT] Outfits OK: {outfits.Count}/{numOutfits}, readerPos={r.Position}"); + } + catch (Exception ex) + { + DiagLog?.Invoke($"[DAT] Outfits FAILED at {outfits.Count}/{numOutfits}, readerPos={r.Position}: {ex.Message}"); + } + try + { for (int id = 1; id <= numEffects; id++) - effects[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Effect, protocol, extended); + effects[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Effect, protocol, extended, enhancedAnimations, frameGroups); + DiagLog?.Invoke($"[DAT] Effects OK: {effects.Count}/{numEffects}, readerPos={r.Position}"); + } + catch (Exception ex) + { + DiagLog?.Invoke($"[DAT] Effects FAILED at {effects.Count}/{numEffects}, readerPos={r.Position}: {ex.Message}"); + } + try + { for (int id = 1; id <= numMissiles; id++) - missiles[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Missile, protocol, extended); + missiles[(ushort)id] = ParseThing(r, (ushort)id, ThingCategory.Missile, protocol, extended, enhancedAnimations, frameGroups); + DiagLog?.Invoke($"[DAT] Missiles OK: {missiles.Count}/{numMissiles}, readerPos={r.Position}"); } - catch + catch (Exception ex) { - secondaryFailed = true; + DiagLog?.Invoke($"[DAT] Missiles FAILED at {missiles.Count}/{numMissiles}, readerPos={r.Position}: {ex.Message}"); } - // If outfit/effect/missile parsing failed AND most of the file is unread, + // If ALL secondary categories failed AND most of the file is unread, // the protocol is almost certainly wrong — reject so fallback tries the next one. + bool secondaryFailed = outfits.Count == 0 && effects.Count == 0 && missiles.Count == 0 + && (numOutfits + numEffects + numMissiles) > 0; if (secondaryFailed) { int remaining = raw.Length - r.Position; @@ -168,6 +209,8 @@ private static DatData Parse(byte[] raw, int protocolHint, bool extended) Signature = signature, ProtocolVersion = protocol, Extended = extended, + EnhancedAnimations = enhancedAnimations, + FrameGroups = frameGroups, ItemCount = (ushort)numItems, OutfitCount = (ushort)numOutfits, EffectCount = (ushort)numEffects, @@ -179,7 +222,7 @@ private static DatData Parse(byte[] raw, int protocolHint, bool extended) }; } - private static DatThingType ParseThing(DatReader r, ushort id, ThingCategory category, int protocol, bool extended) + private static DatThingType ParseThing(DatReader r, ushort id, ThingCategory category, int protocol, bool extended, bool enhancedAnimations, bool frameGroups) { var thing = new DatThingType { Id = id, Category = category }; @@ -187,15 +230,15 @@ private static DatThingType ParseThing(DatReader r, ushort id, ThingCategory cat ParseFlags(r, thing, protocol); // ── Parse frame groups ── - // Frame groups exist for outfits in protocol >= 1050 + // Frame groups only apply to outfits/creatures when the feature is enabled. bool isOutfit = category == ThingCategory.Outfit; - int groupCount = (isOutfit && protocol >= 1050) ? r.U8() : 1; + int groupCount = (isOutfit && frameGroups) ? r.U8() : 1; var groups = new FrameGroup[groupCount]; for (int g = 0; g < groupCount; g++) { var fg = new FrameGroup(); - if (isOutfit && protocol >= 1050) + if (isOutfit && frameGroups) fg.Type = (FrameGroupType)r.U8(); fg.Width = r.U8(); @@ -209,8 +252,8 @@ private static DatThingType ParseThing(DatReader r, ushort id, ThingCategory cat fg.PatternZ = r.U8(); fg.Frames = r.U8(); - // Improved animations (protocol >= 1050) - if (fg.Frames > 1 && protocol >= 1050) + // Enhanced/improved animations — independent feature flag + if (fg.Frames > 1 && enhancedAnimations) { fg.AnimationMode = (AnimationMode)r.U8(); fg.LoopCount = r.S32(); @@ -475,64 +518,64 @@ public static void Save(string path, DatData data) for (int id = 100; id <= lastItemId; id++) { if (data.Items.TryGetValue((ushort)id, out var thing)) - WriteThing(w, thing); + WriteThing(w, thing, data.Extended, data.EnhancedAnimations, data.FrameGroups); else - WriteEmptyThing(w); + WriteEmptyThing(w, data.Extended); } // Outfits: 1..lastOutfitId for (int id = 1; id <= lastOutfitId; id++) { if (data.Outfits.TryGetValue((ushort)id, out var thing)) - WriteThing(w, thing); + WriteThing(w, thing, data.Extended, data.EnhancedAnimations, data.FrameGroups); else - WriteEmptyThing(w); + WriteEmptyThing(w, data.Extended); } // Effects: 1..lastEffectId for (int id = 1; id <= lastEffectId; id++) { if (data.Effects.TryGetValue((ushort)id, out var thing)) - WriteThing(w, thing); + WriteThing(w, thing, data.Extended, data.EnhancedAnimations, data.FrameGroups); else - WriteEmptyThing(w); + WriteEmptyThing(w, data.Extended); } // Missiles: 1..lastMissileId for (int id = 1; id <= lastMissileId; id++) { if (data.Missiles.TryGetValue((ushort)id, out var thing)) - WriteThing(w, thing); + WriteThing(w, thing, data.Extended, data.EnhancedAnimations, data.FrameGroups); else - WriteEmptyThing(w); + WriteEmptyThing(w, data.Extended); } File.WriteAllBytes(path, w.ToArray()); } - private static void WriteEmptyThing(DatWriter w) + private static void WriteEmptyThing(DatWriter w, bool extended) { w.U8(0xFF); // end flags - // 1 frame group: 1x1, exactSize=32, 1 layer, 1x1x1 pattern, 1 frame, 1 sprite (id=0) + // 1 frame group: 1x1, 1 layer, 1x1x1 pattern, 1 frame, 1 sprite (id=0) w.U8(1); w.U8(1); // width, height w.U8(1); // layers w.U8(1); w.U8(1); w.U8(1); // patternX/Y/Z w.U8(1); // frames - w.U32(0); // sprite id + if (extended) w.U32(0); else w.U16(0); // sprite id } - private static void WriteThing(DatWriter w, DatThingType thing) + private static void WriteThing(DatWriter w, DatThingType thing, bool extended, bool enhancedAnimations, bool frameGroups) { WriteFlags(w, thing); bool isOutfit = thing.Category == ThingCategory.Outfit; - if (isOutfit) + if (isOutfit && frameGroups) w.U8((byte)thing.FrameGroups.Length); for (int g = 0; g < thing.FrameGroups.Length; g++) { var fg = thing.FrameGroups[g]; - if (isOutfit) + if (isOutfit && frameGroups) w.U8((byte)fg.Type); w.U8(fg.Width); @@ -546,7 +589,7 @@ private static void WriteThing(DatWriter w, DatThingType thing) w.U8(fg.PatternZ); w.U8(fg.Frames); - if (fg.Frames > 1) + if (fg.Frames > 1 && enhancedAnimations) { w.U8((byte)fg.AnimationMode); w.S32(fg.LoopCount); @@ -563,7 +606,10 @@ private static void WriteThing(DatWriter w, DatThingType thing) int totalSprites = fg.SpriteCount; for (int i = 0; i < totalSprites; i++) - w.U32(i < fg.SpriteIndex.Length ? fg.SpriteIndex[i] : 0); + { + uint sid = i < fg.SpriteIndex.Length ? fg.SpriteIndex[i] : 0; + if (extended) w.U32(sid); else w.U16((ushort)sid); + } } } @@ -670,6 +716,8 @@ internal sealed class DatReader(byte[] data) public int Remaining => data.Length - _pos; public int Position => _pos; + public void Seek(int position) => _pos = position; + private void EnsureAvailable(int bytes) { if (_pos + bytes > data.Length) @@ -741,6 +789,10 @@ public sealed class DatData public int ProtocolVersion { get; init; } /// True if sprite indices are U32 (extended). False if U16. public bool Extended { get; init; } + /// True if enhanced animation durations are present in the DAT. + public bool EnhancedAnimations { get; init; } + /// True if outfit/creature frame groups are present in the DAT. + public bool FrameGroups { get; init; } public ushort ItemCount { get; init; } public ushort OutfitCount { get; init; } public ushort EffectCount { get; init; }