using System; using System.Collections.Generic; using System.IO; using System.Numerics; using System.Text; using System.Threading; using Dalamud.Plugin.Services; using Lumina.Data.Files; using Lumina.Data.Parsing.Layer; using Lumina.Excel; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; namespace LLib.Shop; public sealed class NpcPositionCache { private static readonly string[] LgbFileNames = new string[4] { "planevent", "planmap", "planner", "planlive" }; private const uint FileMagic = 1129336401u; private const int FileFormatVersion = 1; private const string FileName = "npc-position-cache.bin"; private const int MaxPreallocatedEntries = 131072; private readonly IDataManager _dataManager; private readonly ILogger _logger; private readonly NpcPositionCacheOptions _options; private readonly object _buildLock = new object(); private volatile Dictionary? _cache; public bool IsBuilt => _cache != null; private string? CacheFilePath { get { if (string.IsNullOrEmpty(_options.CacheDirectory) || string.IsNullOrEmpty(_options.GameVersion)) { return null; } return Path.Combine(_options.CacheDirectory, "npc-position-cache.bin"); } } public NpcPositionCache(IDataManager dataManager, ILogger logger, NpcPositionCacheOptions? options = null) { _dataManager = dataManager; _logger = logger; _options = options ?? new NpcPositionCacheOptions(); } public (ushort TerritoryId, Vector3 Position)? TryGetPosition(uint npcId) { Dictionary cache = _cache; if (cache == null || !cache.TryGetValue(npcId, out var value)) { return null; } return (value.Item1, value.Item2); } public (ushort TerritoryId, Vector3 Position, bool FestivalOnly)? TryGetPlacement(uint npcId) { Dictionary cache = _cache; if (cache == null || !cache.TryGetValue(npcId, out var value)) { return null; } return value; } public bool TryLoadFromDisk() { if (_cache != null) { return true; } lock (_buildLock) { if (_cache == null) { _cache = LoadFromDisk(); } return _cache != null; } } public bool TryLoadFromStream(Stream stream, bool acceptStaleVersion) { ArgumentNullException.ThrowIfNull(stream, "stream"); if (_cache != null) { return true; } lock (_buildLock) { if (_cache == null) { _cache = ReadCache(stream, acceptStaleVersion, "stream"); } return _cache != null; } } public void WarmUp(CancellationToken cancellationToken = default(CancellationToken)) { EnsureBuilt(cancellationToken); } public (ushort TerritoryId, Vector3 Position)? GetPosition(uint npcId) { EnsureBuilt(); if (!_cache.TryGetValue(npcId, out (ushort, Vector3, bool) value)) { return null; } return (value.Item1, value.Item2); } public (ushort TerritoryId, Vector3 Position, bool FestivalOnly)? GetPlacement(uint npcId) { EnsureBuilt(); if (!_cache.TryGetValue(npcId, out (ushort, Vector3, bool) value)) { return null; } return value; } public IReadOnlyCollection GetAllNpcIds() { EnsureBuilt(); return _cache.Keys; } private void EnsureBuilt(CancellationToken cancellationToken = default(CancellationToken)) { if (_cache != null) { return; } lock (_buildLock) { if (_cache == null) { Dictionary dictionary = LoadFromDisk(); if (dictionary == null) { dictionary = BuildNpcPositionCache(cancellationToken); SaveToDisk(dictionary); _logger.LogDebug("NPC position cache built with {Count} entries", dictionary.Count); } _cache = dictionary; } } } private Dictionary? LoadFromDisk() { string cacheFilePath = CacheFilePath; if (cacheFilePath == null || !File.Exists(cacheFilePath)) { return null; } Dictionary dictionary; try { using FileStream stream = File.OpenRead(cacheFilePath); dictionary = ReadCache(stream, acceptStaleVersion: false, "disk"); } catch (Exception exception) { _logger.LogWarning(exception, "Failed to open NPC position cache file, discarding"); dictionary = null; } if (dictionary != null) { return dictionary; } try { File.Delete(cacheFilePath); } catch (Exception) { } return null; } private Dictionary? ReadCache(Stream stream, bool acceptStaleVersion, string source) { try { using BinaryReader binaryReader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); if (binaryReader.ReadUInt32() != 1129336401 || binaryReader.ReadInt32() != 1) { _logger.LogDebug("NPC position cache data has an unknown header, discarding"); return null; } string text = binaryReader.ReadString(); if (text != _options.GameVersion) { if (!acceptStaleVersion) { _logger.LogDebug("NPC position cache data is for a different game version, discarding"); return null; } _logger.LogInformation("NPC position cache data was built for game version {CachedVersion} instead of {ExpectedVersion}, using it anyway", text, _options.GameVersion); } int num = binaryReader.ReadInt32(); if (num < 0) { _logger.LogDebug("NPC position cache data has a negative entry count, discarding"); return null; } Dictionary dictionary = new Dictionary(Math.Min(num, 131072)); for (int i = 0; i < num; i++) { uint key = binaryReader.ReadUInt32(); ushort item = binaryReader.ReadUInt16(); Vector3 item2 = new Vector3(binaryReader.ReadSingle(), binaryReader.ReadSingle(), binaryReader.ReadSingle()); bool item3 = binaryReader.ReadBoolean(); dictionary[key] = (item, item2, item3); } _logger.LogDebug("NPC position cache loaded from {Source} with {Count} entries", source, dictionary.Count); return dictionary; } catch (Exception exception) { _logger.LogWarning(exception, "Failed to read NPC position cache data, discarding"); return null; } } private void SaveToDisk(Dictionary cache) { string cacheFilePath = CacheFilePath; if (cacheFilePath == null) { return; } try { Directory.CreateDirectory(_options.CacheDirectory); string text = cacheFilePath + ".tmp"; using (BinaryWriter binaryWriter = new BinaryWriter(File.Create(text))) { binaryWriter.Write(1129336401u); binaryWriter.Write(1); binaryWriter.Write(_options.GameVersion); binaryWriter.Write(cache.Count); foreach (var (value, tuple2) in cache) { binaryWriter.Write(value); binaryWriter.Write(tuple2.Item1); binaryWriter.Write(tuple2.Item2.X); binaryWriter.Write(tuple2.Item2.Y); binaryWriter.Write(tuple2.Item2.Z); binaryWriter.Write(tuple2.Item3); } } File.Move(text, cacheFilePath, overwrite: true); } catch (Exception exception) { _logger.LogWarning(exception, "Failed to save NPC position cache file"); } } private Dictionary BuildNpcPositionCache(CancellationToken cancellationToken) { Dictionary dictionary = new Dictionary(); 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)) { ScanLgbFiles(dictionary, visitedLgbPaths, item2, cancellationToken); } } foreach (TerritoryType item3 in excelSheet) { ScanLgbFiles(dictionary, visitedLgbPaths, item3, cancellationToken); } } finally { _dataManager.GameData.Options.CacheFileResources = cacheFileResources; } ExcelSheet excelSheet2 = _dataManager.GetExcelSheet(); ExcelSheet excelSheet3 = _dataManager.GetExcelSheet(); foreach (Level item4 in _dataManager.GetExcelSheet()) { if (item4.RowId != 0 && item4.Object.RowId != 0 && item4.Territory.IsValid && (excelSheet2.HasRow(item4.Object.RowId) || excelSheet3.HasRow(item4.Object.RowId))) { dictionary.TryAdd(item4.Object.RowId, ((ushort)item4.Territory.RowId, new Vector3(item4.X, item4.Y, item4.Z), false)); } } return dictionary; } private void ScanLgbFiles(Dictionary cache, HashSet visitedLgbPaths, TerritoryType territory, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); string text = territory.Bg.ExtractText(); if (string.IsNullOrEmpty(text)) { return; } int num = text.IndexOf("/level/", StringComparison.Ordinal); if (num < 0) { return; } int num2 = 0; string[] lgbFileNames = LgbFileNames; foreach (string text2 in lgbFileNames) { string text3 = "bg/" + text.Substring(0, num + 1) + "level/" + text2 + ".lgb"; if (!visitedLgbPaths.Add(text3)) { continue; } LgbFile file; try { file = _dataManager.GetFile(text3); } catch (Exception exception) { _logger.LogTrace(exception, "Failed to load LGB file {Path}", text3); continue; } if (file == null) { continue; } num2++; LayerCommon.Layer[] layers = file.Layers; for (int j = 0; j < layers.Length; j++) { LayerCommon.Layer layer = layers[j]; bool flag = layer.FestivalID != 0 || layer.IsTemporary != 0; LayerCommon.InstanceObject[] instanceObjects = layer.InstanceObjects; for (int k = 0; k < instanceObjects.Length; k++) { LayerCommon.InstanceObject instanceObject = instanceObjects[k]; uint baseId; if (instanceObject.AssetType == LayerEntryType.EventNPC) { baseId = ((LayerCommon.ENPCInstanceObject)instanceObject.Object).ParentData.ParentData.BaseId; } else { if (instanceObject.AssetType != LayerEntryType.EventObject) { continue; } baseId = ((LayerCommon.EventInstanceObject)instanceObject.Object).ParentData.BaseId; } if (baseId != 0 && (!cache.TryGetValue(baseId, out (ushort, Vector3, bool) value) || !(!value.Item3 || flag))) { cache[baseId] = ((ushort)territory.RowId, new Vector3(instanceObject.Transform.Translation.X, instanceObject.Transform.Translation.Y, instanceObject.Transform.Translation.Z), flag); } } } } if (num2 > 0 && _options.ScanThrottle > TimeSpan.Zero) { Thread.Sleep(_options.ScanThrottle); } } }