using System; using System.Collections.Generic; using System.IO; using System.Numerics; using System.Threading.Tasks; using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using Lumina.Data.Files; using Lumina.Excel.Sheets; using Lumina.Extensions; using Mappy.Classes; namespace Mappy.MapRenderer; public unsafe partial class MapRenderer : IDisposable { private const int MinimapCacheMaxEntries = 8; private sealed class MinimapCacheEntry { public IDalamudTextureWrap? Texture; public string PathKey = string.Empty; public float ScaleFactor; public float OffsetX; public float OffsetY; } private readonly Dictionary _minimapCache = new(); public static float Scale { get => System.SystemConfig.MapScale; set => System.SystemConfig.MapScale = value; } public Vector2 DrawOffset { get; set; } public Vector2 DrawPosition { get; private set; } private IDalamudTextureWrap? blendedTexture; private string blendedPath = string.Empty; public MapRenderer() { LoadFogHooks(); } public void Dispose() { foreach (var entry in _minimapCache.Values) entry.Texture?.Dispose(); _minimapCache.Clear(); UnloadFogHooks(); } public void CenterOnGameObject(IGameObject obj) => CenterOnCoordinate(new Vector2(obj.Position.X, obj.Position.Z)); public void CenterOnCoordinate(Vector2 coord) => DrawOffset = -coord * DrawHelpers.GetMapScaleFactor() + DrawHelpers.GetMapOffsetVector(); public void DrawBaseTexture() { UpdateScaleLimits(); UpdateDrawOffset(); DrawBackgroundTexture(); } public void DrawDynamicElements() { DrawFogOfWar(); DrawMapMarkers(); } private void UpdateScaleLimits() => Scale = Math.Clamp(Scale, 0.05f, 20.0f); private void UpdateDrawOffset() { var childCenterOffset = ImGui.GetContentRegionAvail() / 2.0f; var mapCenterOffset = new Vector2(1024.0f, 1024.0f) * Scale; DrawPosition = childCenterOffset - mapCenterOffset + DrawOffset * Scale; } private void DrawBackgroundTexture() { if (AgentMap.Instance()->SelectedMapBgPath.Length is 0) { var texture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex").GetWrapOrEmpty(); ImGui.SetCursorPos(DrawPosition); ImGui.Image(texture.Handle, texture.Size * Scale); } else { if (blendedPath != AgentMap.Instance()->SelectedMapBgPath.ToString()) { fogTexture = null; blendedTexture?.Dispose(); blendedTexture = LoadTexture(); blendedPath = AgentMap.Instance()->SelectedMapBgPath.ToString(); } if (blendedTexture is not null) { ImGui.SetCursorPos(DrawPosition); ImGui.Image(blendedTexture.Handle, blendedTexture.Size * Scale); } } } /// /// Draw map texture at a specific position and scale (for minimap). /// private void DrawBackgroundTextureAt(Vector2 drawPosition, float scale) { if (AgentMap.Instance()->SelectedMapBgPath.Length is 0) { var texture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex").GetWrapOrEmpty(); ImGui.SetCursorPos(drawPosition); ImGui.Image(texture.Handle, texture.Size * scale); } else { if (blendedPath != AgentMap.Instance()->SelectedMapBgPath.ToString()) { fogTexture = null; blendedTexture?.Dispose(); blendedTexture = LoadTexture(); blendedPath = AgentMap.Instance()->SelectedMapBgPath.ToString(); } if (blendedTexture is not null) { ImGui.SetCursorPos(drawPosition); ImGui.Image(blendedTexture.Handle, blendedTexture.Size * scale); } } } /// /// Draw cached map texture for minimap (used when area map is closed). /// private void DrawMinimapCachedTextureAt(Vector2 drawPosition, float scale, IDalamudTextureWrap texture) { ImGui.SetCursorPos(drawPosition); ImGui.Image(texture.Handle, texture.Size * scale); } /// /// True if we have cached texture/transform for this map (e.g. after opening the area map once). /// public bool HasMinimapCacheFor(uint mapId) => _minimapCache.ContainsKey(mapId); /// /// Try to load map texture and transform from Lumina (Map sheet) so the minimap can draw without opening the area map. /// Uses game map path conventions (ui/map/...) and Map.SizeFactor, Map.OffsetX/Y. On success, fills the cache for this map. /// public bool TryEnsureLuminaCacheFor(uint mapId) { if (mapId == 0 || _minimapCache.ContainsKey(mapId)) return true; var map = Service.DataManager.GetExcelSheet().GetRow(mapId); if (map.RowId == 0) return false; var idStr = map.Id.ExtractText()?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(idStr)) return false; // Try several path conventions so the minimap can show without ever requiring the user to open the Area Map. var fileName = idStr.Replace("/", ""); var pathsToTry = new[] { $"ui/map/{idStr}/{fileName}_m.tex", $"ui/map/{idStr}/{fileName}_l.tex", $"ui/map/{idStr}/{fileName}.tex", $"ui/uld/areamap/{mapId:D4}.tex", $"ui/uld/areamap/{fileName}.tex", }; IDalamudTextureWrap? texture = null; foreach (var path in pathsToTry) { texture = LoadSingleTexture(path); if (texture is not null) break; } if (texture is null) return false; TrimMinimapCacheToLimit(); var entry = _minimapCache[mapId] = new MinimapCacheEntry(); entry.PathKey = $"lumina:{mapId}"; entry.Texture = texture; entry.ScaleFactor = map.SizeFactor / 100f; entry.OffsetX = map.OffsetX; entry.OffsetY = map.OffsetY; return true; } private static IDalamudTextureWrap? LoadSingleTexture(string path) { var file = GetTexFile(path); if (file is null) return null; var bytes = file.GetRgbaImageData(); var w = file.Header.Width; var h = file.Header.Height; return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(w, h), bytes); } /// /// Draw minimap view (current map only, centered on player) into the current ImGui cursor region. /// Prefers Lumina-loaded data so the minimap shows automatically without opening the Area Map. /// If the user opens the main map (M), we cache that for a higher-quality display; otherwise we use Lumina when available. /// public void DrawMinimapContents(Vector2 size) { var agent = AgentMap.Instance(); if (Service.ObjectTable.LocalPlayer is not { } localPlayer) return; var currentMapId = agent->CurrentMapId; if (currentMapId == 0) return; // Do not call OpenMapByMapId/RefreshMapMarkers from here: it opens the area map repeatedly. // Markers are drawn from whatever the game has already populated (e.g. after opening the map once). // When the game has the current map loaded (area map open or just closed), update our cache for this map. if (agent->SelectedMapId == currentMapId && agent->SelectedMapPath.Length > 0) { var bgPath = $"{agent->SelectedMapBgPath}.tex"; var fgPath = $"{agent->SelectedMapPath}.tex"; var pathKey = bgPath + "|" + fgPath; if (!_minimapCache.TryGetValue(currentMapId, out var entry) || entry.PathKey != pathKey) { TrimMinimapCacheToLimit(); entry = _minimapCache[currentMapId] = new MinimapCacheEntry(); entry.PathKey = pathKey; entry.Texture?.Dispose(); entry.Texture = LoadTextureFromPaths(bgPath, fgPath); } if (entry.Texture is not null) { entry.ScaleFactor = agent->SelectedMapSizeFactorFloat; entry.OffsetX = agent->SelectedOffsetX; entry.OffsetY = agent->SelectedOffsetY; } } // If no cache yet, try loading from Lumina (Map sheet) so minimap works without opening the area map. TryEnsureLuminaCacheFor(currentMapId); // Draw from cache if we have it for the current map. if (!_minimapCache.TryGetValue(currentMapId, out var cached) || cached.Texture is null) return; // Use the size passed by the minimap window (window size) so zoom/center is stable. if (size.X <= 0 || size.Y <= 0) return; var zoom = Math.Clamp(System.SystemConfig.MinimapZoom, 0.03f, 0.112f); var mapSize = cached.Texture.Size.X; // Scale so the map COVERS the view at zoom=1 (use max so no black bands at max zoom out). var fitScale = Math.Max(size.X, size.Y) / mapSize; var scale = fitScale / zoom; // Ensure map always covers the view at any zoom (no black edges when zoomed in). var minScale = Math.Max(size.X, size.Y) / mapSize; scale = Math.Clamp(scale, minScale, 20.0f); var playerCoord = new Vector2(localPlayer.Position.X, localPlayer.Position.Z); var drawOffset = (-playerCoord + new Vector2(cached.OffsetX, cached.OffsetY)) * cached.ScaleFactor; var centerOffset = size / 2.0f; var mapCenterOffset = (cached.Texture.Size / 2f) * scale; var drawPosition = centerOffset - mapCenterOffset + drawOffset * scale; // Clamp so the map always fills the view (no black), but when zoomed in allow full pan so the player tracks. var texSize = cached.Texture.Size * scale; if (texSize.X > size.X && texSize.Y > size.Y) { // Zoomed in: allow full pan range so the map can shift in any direction and the player stays at center. drawPosition.X = Math.Clamp(drawPosition.X, size.X - texSize.X, texSize.X - size.X); drawPosition.Y = Math.Clamp(drawPosition.Y, size.Y - texSize.Y, texSize.Y - size.Y); } else { // Zoomed out or exact fit: clamp so the map rect covers (0,0)-(size) to avoid black edges. drawPosition.X = Math.Clamp(drawPosition.X, size.X - texSize.X, 0f); drawPosition.Y = Math.Clamp(drawPosition.Y, size.Y - texSize.Y, 0f); } // Content top-left in screen space so player is drawn at the true center of the minimap (not offset by title bar). var contentTopLeft = ImGui.GetCursorScreenPos(); DrawMinimapCachedTextureAt(drawPosition, scale, cached.Texture); var centerScreen = contentTopLeft + centerOffset; // Draw cone under markers so quest/FATE/POI markers stay visible on top of the cone. DrawMinimapConeAtCenter(centerScreen, scale); DrawMinimapMarkers(contentTopLeft, drawPosition, scale, size, cached.OffsetX, cached.OffsetY, cached.ScaleFactor); DrawPlayerAtCenter(centerScreen, scale); if (System.SystemConfig.MinimapShowQuestDirectionArrow) DrawMinimapQuestDirectionArrow(contentTopLeft, drawPosition, scale, size, centerOffset, cached.OffsetX, cached.OffsetY, cached.ScaleFactor, currentMapId); if (System.SystemConfig.MinimapShowFateDirectionArrows) DrawMinimapFateDirectionArrows(contentTopLeft, drawPosition, scale, size, centerOffset, cached.OffsetX, cached.OffsetY, cached.ScaleFactor); if (System.SystemConfig.MinimapShowFlagDirectionArrow) DrawMinimapFlagDirectionArrow(contentTopLeft, drawPosition, scale, size, centerOffset, cached.OffsetX, cached.OffsetY, cached.ScaleFactor); } private void TrimMinimapCacheToLimit() { while (_minimapCache.Count >= MinimapCacheMaxEntries) { var first = default(uint); foreach (var k in _minimapCache.Keys) { first = k; break; } if (_minimapCache.Remove(first, out var entry)) entry.Texture?.Dispose(); } } private static IDalamudTextureWrap? LoadTextureFromPaths(string vanillaBgPath, string vanillaFgPath) { var bgFile = GetTexFile(vanillaBgPath); var fgFile = GetTexFile(vanillaFgPath); if (bgFile is null || fgFile is null) return null; var backgroundBytes = bgFile.GetRgbaImageData(); var foregroundBytes = fgFile.GetRgbaImageData(); Parallel.For(0, 2048 * 2048, i => { var index = i * 4; backgroundBytes[index + 0] = (byte)(backgroundBytes[index + 0] * foregroundBytes[index + 0] / 255); backgroundBytes[index + 1] = (byte)(backgroundBytes[index + 1] * foregroundBytes[index + 1] / 255); backgroundBytes[index + 2] = (byte)(backgroundBytes[index + 2] * foregroundBytes[index + 2] / 255); }); return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), backgroundBytes); } private static IDalamudTextureWrap? LoadTexture() { var vanillaBgPath = $"{AgentMap.Instance()->SelectedMapBgPath.ToString()}.tex"; var vanillaFgPath = $"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex"; var bgFile = GetTexFile(vanillaBgPath); var fgFile = GetTexFile(vanillaFgPath); if (bgFile is null || fgFile is null) { Service.Log.Warning("Failed to load map textures"); return null; } var backgroundBytes = bgFile.GetRgbaImageData(); var foregroundBytes = fgFile.GetRgbaImageData(); // Blend textures together Parallel.For(0, 2048 * 2048, i => { var index = i * 4; // Blend, R, G, B, skip A. backgroundBytes[index + 0] = (byte)(backgroundBytes[index + 0] * foregroundBytes[index + 0] / 255); backgroundBytes[index + 1] = (byte)(backgroundBytes[index + 1] * foregroundBytes[index + 1] / 255); backgroundBytes[index + 2] = (byte)(backgroundBytes[index + 2] * foregroundBytes[index + 2] / 255); }); return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), backgroundBytes); } private static TexFile? GetTexFile(string rawPath) { var path = Service.TextureSubstitutionProvider.GetSubstitutedPath(rawPath); if (Path.IsPathRooted(path)) { return Service.DataManager.GameData.GetFileFromDisk(path); } return Service.DataManager.GetFile(path); } private void DrawMapMarkers() { DrawStaticMapMarkers(); DrawDynamicMarkers(); DrawGameObjects(); DrawGroupMembers(); DrawTemporaryMarkers(); DrawGatheringMarkers(); DrawFieldMarkers(); DrawPlayer(); DrawStaticTextMarkers(); DrawFlag(); } }