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 { 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; }