using System; using System.Collections.Generic; using System.IO; using System.Numerics; using System.Threading; using Dalamud.Plugin.Services; using LLib.GameData; using Lumina.Data.Files; using Lumina.Data.Parsing.Layer; using Lumina.Excel; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; using SmartNav.Model.Navigation; namespace SmartNav.Data; public sealed class LgbZoneBoundarySource : IZoneBoundarySource { private const uint BoundaryFileMagic = 1112425562u; private const int BoundaryFileFormatVersion = 1; private const string CacheFileName = "zone-boundary-cache.bin"; private readonly IDataManager _dataManager; private readonly ILogger _logger; private readonly Lazy> _boundaries; private readonly string? _cacheDirectory; private readonly string? _gameVersion; public LgbZoneBoundarySource(IDataManager dataManager, ILogger logger, SmartNavDataOptions? dataOptions = null) { _dataManager = dataManager; _logger = logger; _cacheDirectory = dataOptions?.UserOverrideDirectory?.FullName; _gameVersion = dataOptions?.GameVersion; _boundaries = new Lazy>(Build, LazyThreadSafetyMode.ExecutionAndPublication); } public IReadOnlyList GetBoundaries() { return _boundaries.Value; } private IReadOnlyList Build() { try { (List, Dictionary<(ushort, uint), PopRangeEntry>)? tuple = TryLoadFromDisk(); if (!tuple.HasValue) { _logger.LogInformation("No boundary cache found, falling back to in-process LGB scan"); } (List, Dictionary<(ushort, uint), PopRangeEntry>) obj = tuple ?? CollectLgbData(); List item = obj.Item1; Dictionary<(ushort, uint), PopRangeEntry> item2 = obj.Item2; List list = ZoneBoundaryDerivation.Derive(item, item2, _logger); ZoneBoundaryDerivation.ApplyOverrides(list, ZoneBoundaryOverrides.FlyingPairs, ZoneBoundaryOverrides.PositionOverrides, _logger); _logger.LogDebug("Derived {Count} zone boundaries from {ExitCount} ExitRanges{Source}", list.Count, item.Count, tuple.HasValue ? " (from cache)" : ""); return list; } catch (Exception exception) { _logger.LogError(exception, "Zone boundary derivation failed - building graph without boundary edges"); return Array.Empty(); } } private (List ExitRanges, Dictionary<(ushort TerritoryId, uint InstanceId), PopRangeEntry> PopRangeIndex)? TryLoadFromDisk() { if (string.IsNullOrEmpty(_cacheDirectory) || string.IsNullOrEmpty(_gameVersion)) { return null; } string path = Path.Combine(_cacheDirectory, "zone-boundary-cache.bin"); if (!File.Exists(path)) { return null; } try { using BinaryReader binaryReader = new BinaryReader(File.OpenRead(path)); if (binaryReader.ReadUInt32() != 1112425562 || binaryReader.ReadInt32() != 1) { _logger.LogDebug("Zone boundary cache has unknown header, discarding"); return null; } if (binaryReader.ReadString() != _gameVersion) { _logger.LogDebug("Zone boundary cache is for a different game version, discarding"); return null; } int num = binaryReader.ReadInt32(); List list = new List(num); for (int i = 0; i < num; i++) { list.Add(new ExitRangeEntry(binaryReader.ReadUInt16(), binaryReader.ReadUInt32(), new Vector3(binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()), binaryReader.ReadUInt16(), binaryReader.ReadUInt32(), binaryReader.ReadUInt32(), new Vector3(binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()), new Vector3(binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()))); } int num2 = binaryReader.ReadInt32(); Dictionary<(ushort, uint), PopRangeEntry> dictionary = new Dictionary<(ushort, uint), PopRangeEntry>(num2); for (int j = 0; j < num2; j++) { ushort num3 = binaryReader.ReadUInt16(); uint num4 = binaryReader.ReadUInt32(); Vector3 position = new Vector3(binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()); dictionary[(num3, num4)] = new PopRangeEntry(num3, num4, position); } _logger.LogDebug("Zone boundary cache loaded from disk ({ExitCount} exits, {PopCount} pops)", list.Count, dictionary.Count); return (list, dictionary); } catch (Exception exception) { _logger.LogWarning(exception, "Failed to load zone boundary cache, falling back to LGB scan"); } try { File.Delete(path); } catch { } return null; } private (List ExitRanges, Dictionary<(ushort TerritoryId, uint InstanceId), PopRangeEntry> PopRangeIndex) CollectLgbData() { List list = new List(); Dictionary<(ushort, uint), PopRangeEntry> dictionary = new Dictionary<(ushort, uint), PopRangeEntry>(); HashSet hashSet = new HashSet(); foreach (Aetheryte item in _dataManager.GetExcelSheet()) { hashSet.Add(item.Territory.RowId); } ExcelSheet excelSheet = _dataManager.GetExcelSheet(); HashSet visitedLgbPaths = new HashSet(); bool cacheFileResources = _dataManager.GameData.Options.CacheFileResources; _dataManager.GameData.Options.CacheFileResources = false; try { foreach (TerritoryType item2 in excelSheet) { if (hashSet.Contains(item2.RowId)) { ScanTerritory(list, dictionary, visitedLgbPaths, item2); } } foreach (TerritoryType item3 in excelSheet) { ScanTerritory(list, dictionary, visitedLgbPaths, item3); } } finally { _dataManager.GameData.Options.CacheFileResources = cacheFileResources; } return (ExitRanges: list, PopRangeIndex: dictionary); } private static bool IsNavigableOverworld(ETerritoryIntendedUse use) { switch (use) { case ETerritoryIntendedUse.Town: case ETerritoryIntendedUse.Overworld: case ETerritoryIntendedUse.OpeningArea: case ETerritoryIntendedUse.HousingOutdoor: case ETerritoryIntendedUse.Firmament: case ETerritoryIntendedUse.SanctumOfTheTwelve: case ETerritoryIntendedUse.GoldSaucer: case ETerritoryIntendedUse.Eureka: case ETerritoryIntendedUse.Bozja: case ETerritoryIntendedUse.IslandSanctuary: case ETerritoryIntendedUse.CosmicExploration: case ETerritoryIntendedUse.OccultCrescent: return true; default: return false; } } private void ScanTerritory(List exitRanges, Dictionary<(ushort TerritoryId, uint InstanceId), PopRangeEntry> popRangeIndex, HashSet visitedLgbPaths, TerritoryType territory) { if (territory.RowId == 0 || !IsNavigableOverworld((ETerritoryIntendedUse)territory.TerritoryIntendedUse.RowId)) { return; } string text = territory.Bg.ExtractText(); if (string.IsNullOrEmpty(text)) { return; } int num = text.IndexOf("/level/", StringComparison.Ordinal); if (num < 0) { return; } string text2 = "bg/" + text.Substring(0, num + 1) + "level/planmap.lgb"; if (!visitedLgbPaths.Add(text2)) { return; } ushort num2 = (ushort)territory.RowId; LgbFile file; try { file = _dataManager.GetFile(text2); } catch (Exception exception) { _logger.LogTrace(exception, "Failed to load {Path}", text2); return; } if (file == null) { return; } LayerCommon.Layer[] layers = file.Layers; for (int i = 0; i < layers.Length; i++) { LayerCommon.InstanceObject[] instanceObjects = layers[i].InstanceObjects; for (int j = 0; j < instanceObjects.Length; j++) { LayerCommon.InstanceObject instanceObject = instanceObjects[j]; if (instanceObject.AssetType == LayerEntryType.ExitRange) { LayerCommon.ExitRangeInstanceObject exitRangeInstanceObject = (LayerCommon.ExitRangeInstanceObject)(object)instanceObject.Object; exitRanges.Add(new ExitRangeEntry(num2, instanceObject.InstanceId, new Vector3(instanceObject.Transform.Translation.X, instanceObject.Transform.Translation.Y, instanceObject.Transform.Translation.Z), exitRangeInstanceObject.TerritoryType, exitRangeInstanceObject.DestInstanceId, exitRangeInstanceObject.ReturnInstanceId, new Vector3(instanceObject.Transform.Rotation.X, instanceObject.Transform.Rotation.Y, instanceObject.Transform.Rotation.Z), new Vector3(instanceObject.Transform.Scale.X, instanceObject.Transform.Scale.Y, instanceObject.Transform.Scale.Z))); } else if (instanceObject.AssetType == LayerEntryType.PopRange) { popRangeIndex[(num2, instanceObject.InstanceId)] = new PopRangeEntry(num2, instanceObject.InstanceId, new Vector3(instanceObject.Transform.Translation.X, instanceObject.Transform.Translation.Y, instanceObject.Transform.Translation.Z)); } } } } }