optimizations
This commit is contained in:
parent
419e2eb4a4
commit
d124f2c288
44 changed files with 1663 additions and 639 deletions
|
|
@ -957,7 +957,8 @@ public sealed class MemoryDiagnostics
|
|||
var path = _entities.TryReadEntityPath(entityPtr);
|
||||
var entity = new Entity(entityPtr, entityId, path);
|
||||
|
||||
if (_entities.TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
|
||||
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
|
||||
if (_entities.TryReadEntityPosition(entityPtr, compFirst, compCount, out var x, out var y, out var z))
|
||||
{
|
||||
entity.HasPosition = true;
|
||||
entity.X = x;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ public class GameMemoryReader : IDisposable
|
|||
private FilesContainer? _filesContainer;
|
||||
private QuestStateLookup? _questStateLookup;
|
||||
|
||||
// Cached quest linked list / UI quest groups — re-read only when quest states change
|
||||
private List<QuestLinkedEntry>? _cachedQuestLinkedList;
|
||||
private List<UiQuestGroup>? _cachedUiQuestGroups;
|
||||
|
||||
public ObjectRegistry Registry => _registry;
|
||||
public MemoryDiagnostics? Diagnostics { get; private set; }
|
||||
public MemoryContext? Context => _ctx;
|
||||
|
|
@ -137,6 +141,8 @@ public class GameMemoryReader : IDisposable
|
|||
_strings = null;
|
||||
_rtti = null;
|
||||
// _questNames intentionally kept — reloaded only once
|
||||
_cachedQuestLinkedList = null;
|
||||
_cachedUiQuestGroups = null;
|
||||
_filesContainer = null;
|
||||
_questStateLookup = null;
|
||||
Diagnostics = null;
|
||||
|
|
@ -153,7 +159,11 @@ public class GameMemoryReader : IDisposable
|
|||
return lookup.IsLoaded ? lookup : null;
|
||||
}
|
||||
|
||||
public GameStateSnapshot ReadSnapshot()
|
||||
/// <summary>
|
||||
/// Reads a full game state snapshot. When readSlowData is false, skips expensive
|
||||
/// rarely-changing data (quests, skills, connected areas, character name).
|
||||
/// </summary>
|
||||
public GameStateSnapshot ReadSnapshot(bool readSlowData = true)
|
||||
{
|
||||
var snap = new GameStateSnapshot();
|
||||
|
||||
|
|
@ -277,10 +287,13 @@ public class GameMemoryReader : IDisposable
|
|||
_lastInGameState = gs.InGame.Address;
|
||||
_lastController = gs.ControllerPtr;
|
||||
|
||||
// Diagnostic state slots — GameStateReader still used for MemoryDiagnostics compat
|
||||
_stateReader!.ReadStateSlots(snap);
|
||||
_stateReader.ReadIsLoading(snap);
|
||||
// Diagnostic state slots — expensive, only needed for MemoryDiagnostics UI
|
||||
MemoryProfiler.BeginSection("StateReader");
|
||||
if (readSlowData)
|
||||
_stateReader!.ReadStateSlots(snap);
|
||||
_stateReader!.ReadIsLoading(snap);
|
||||
_stateReader.ReadEscapeState(snap);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Reconcile CurrentGameState with reliable loading/escape detection
|
||||
if (snap.IsLoading)
|
||||
|
|
@ -290,10 +303,11 @@ public class GameMemoryReader : IDisposable
|
|||
|
||||
if (ai.Address != 0)
|
||||
{
|
||||
// Entities — read from hierarchy
|
||||
// Entities — read from hierarchy (cached)
|
||||
snap.Entities = ai.EntityList.Entities;
|
||||
|
||||
// Player vitals & position — still via ComponentReader (ECS)
|
||||
// Player vitals & position — via ComponentReader (ECS, does RPM)
|
||||
MemoryProfiler.BeginSection("Player");
|
||||
if (snap.LocalPlayerPtr != 0)
|
||||
{
|
||||
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
|
||||
|
|
@ -301,19 +315,10 @@ public class GameMemoryReader : IDisposable
|
|||
_components.InvalidateCaches(snap.LocalPlayerPtr);
|
||||
_components.ReadPlayerVitals(snap);
|
||||
_components.ReadPlayerPosition(snap);
|
||||
snap.CharacterName = _components.ReadPlayerName(snap.LocalPlayerPtr);
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Skills & quests — read from hierarchy
|
||||
snap.PlayerSkills = ai.PlayerSkills.Skills;
|
||||
snap.QuestFlags = ai.QuestFlags.Quests;
|
||||
snap.QuestStates = ai.QuestStates;
|
||||
|
||||
// Read state flag bytes
|
||||
if (snap.InGameStatePtr != 0)
|
||||
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
|
||||
|
||||
// Terrain — read from hierarchy
|
||||
// Terrain — read from hierarchy (cached)
|
||||
snap.TerrainCols = ai.Terrain.TerrainCols;
|
||||
snap.TerrainRows = ai.Terrain.TerrainRows;
|
||||
snap.TerrainWidth = ai.Terrain.TerrainWidth;
|
||||
|
|
@ -321,20 +326,44 @@ public class GameMemoryReader : IDisposable
|
|||
snap.Terrain = ai.Terrain.Grid;
|
||||
snap.TerrainWalkablePercent = ai.Terrain.WalkablePercent;
|
||||
|
||||
// UI tree — root pointer only; tree is read lazily on-demand
|
||||
// UI tree — root pointer only
|
||||
snap.GameUiPtr = gs.InGame.UIElements.GameUiPtr;
|
||||
|
||||
// Quest linked lists (all quests + tracked merged)
|
||||
snap.QuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
|
||||
// Skills — read from hierarchy (cached)
|
||||
snap.PlayerSkills = ai.PlayerSkills.Skills;
|
||||
|
||||
// Quest groups from UI element tree
|
||||
snap.UiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
|
||||
// Slow data — quests, character name (1Hz)
|
||||
if (readSlowData)
|
||||
{
|
||||
MemoryProfiler.BeginSection("Quests");
|
||||
snap.QuestStates = ai.QuestStates;
|
||||
snap.CharacterName = _components!.ReadPlayerName(snap.LocalPlayerPtr);
|
||||
|
||||
// Read state flag bytes
|
||||
if (snap.InGameStatePtr != 0)
|
||||
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
|
||||
|
||||
// Quest linked lists + UI groups — only re-read when quest states change
|
||||
if (ai.QuestStatesChanged)
|
||||
{
|
||||
ai.QuestStatesChanged = false;
|
||||
_cachedQuestLinkedList = gs.InGame.UIElements.ReadQuestLinkedLists();
|
||||
_cachedUiQuestGroups = gs.InGame.UIElements.ReadQuestGroups();
|
||||
}
|
||||
snap.QuestLinkedList = _cachedQuestLinkedList;
|
||||
snap.UiQuestGroups = _cachedUiQuestGroups;
|
||||
MemoryProfiler.EndSection();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error reading snapshot");
|
||||
}
|
||||
finally
|
||||
{
|
||||
MemoryProfiler.EndSection();
|
||||
}
|
||||
|
||||
// Update edge detection for next tick
|
||||
_gameStates!.InGame.AreaInstance.Terrain.UpdateLoadingEdge(snap.IsLoading);
|
||||
|
|
|
|||
65
src/Roboto.Memory/Infrastructure/MemoryProfiler.cs
Normal file
65
src/Roboto.Memory/Infrastructure/MemoryProfiler.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Roboto.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-static section profiler for memory reads. When enabled, attributes each
|
||||
/// ProcessMemory.ReadBytes() call to the current section set via BeginSection/EndSection.
|
||||
/// Cost when disabled: single volatile bool check per RPM call.
|
||||
/// </summary>
|
||||
public static class MemoryProfiler
|
||||
{
|
||||
public static volatile bool IsEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Latest profiler snapshot, updated once per second by the poller thread.
|
||||
/// Null when profiler is disabled.
|
||||
/// </summary>
|
||||
public static volatile Dictionary<string, (long Reads, long Bytes)>? LatestData;
|
||||
|
||||
[ThreadStatic]
|
||||
private static string? _currentSection;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, SectionStats> _sections = new();
|
||||
|
||||
public static void BeginSection(string name)
|
||||
{
|
||||
if (IsEnabled)
|
||||
_currentSection = name;
|
||||
}
|
||||
|
||||
public static void EndSection()
|
||||
{
|
||||
_currentSection = null;
|
||||
}
|
||||
|
||||
public static void RecordRead(int bytes)
|
||||
{
|
||||
var section = _currentSection ?? "Other";
|
||||
var stats = _sections.GetOrAdd(section, static _ => new SectionStats());
|
||||
Interlocked.Increment(ref stats.Reads);
|
||||
Interlocked.Add(ref stats.Bytes, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all section stats and resets counters.
|
||||
/// </summary>
|
||||
public static Dictionary<string, (long Reads, long Bytes)> SnapshotAndReset()
|
||||
{
|
||||
var result = new Dictionary<string, (long, long)>();
|
||||
foreach (var kvp in _sections)
|
||||
{
|
||||
var reads = Interlocked.Exchange(ref kvp.Value.Reads, 0);
|
||||
var bytes = Interlocked.Exchange(ref kvp.Value.Bytes, 0);
|
||||
if (reads > 0)
|
||||
result[kvp.Key] = (reads, bytes);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class SectionStats
|
||||
{
|
||||
public long Reads;
|
||||
public long Bytes;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,23 @@ public sealed class ProcessMemory : IDisposable
|
|||
private nint _handle;
|
||||
private bool _disposed;
|
||||
|
||||
// Atomic read counters for stats
|
||||
private long _readCount;
|
||||
private long _bytesRead;
|
||||
|
||||
public string ProcessName { get; }
|
||||
public int ProcessId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns (readCount, bytesRead) since last snapshot and resets counters.
|
||||
/// </summary>
|
||||
public (long Reads, long Bytes) SnapshotAndResetCounters()
|
||||
{
|
||||
var reads = Interlocked.Exchange(ref _readCount, 0);
|
||||
var bytes = Interlocked.Exchange(ref _bytesRead, 0);
|
||||
return (reads, bytes);
|
||||
}
|
||||
|
||||
private ProcessMemory(string processName, nint handle, int processId)
|
||||
{
|
||||
ProcessName = processName;
|
||||
|
|
@ -50,7 +64,14 @@ public sealed class ProcessMemory : IDisposable
|
|||
{
|
||||
fixed (byte* ptr = buffer)
|
||||
{
|
||||
return Native.ReadProcessMemory(_handle, address, (nint)ptr, buffer.Length, out _);
|
||||
var ok = Native.ReadProcessMemory(_handle, address, (nint)ptr, buffer.Length, out _);
|
||||
if (ok)
|
||||
{
|
||||
Interlocked.Increment(ref _readCount);
|
||||
Interlocked.Add(ref _bytesRead, buffer.Length);
|
||||
if (MemoryProfiler.IsEnabled) MemoryProfiler.RecordRead(buffer.Length);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ public sealed class AreaInstance : RemoteObject
|
|||
public AreaTemplate AreaTemplate { get; }
|
||||
public List<QuestStateEntry>? QuestStates { get; private set; }
|
||||
|
||||
/// <summary>True when QuestStates changed since last check. Reset by consumer.</summary>
|
||||
public bool QuestStatesChanged { get; set; }
|
||||
|
||||
// Quest states checked every 1s — cheap vector read used as change detector
|
||||
private long _questNextReadTick;
|
||||
private const long QuestReadIntervalMs = 1000;
|
||||
private int _lastQuestStatesHash;
|
||||
|
||||
public AreaInstance(MemoryContext ctx, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
|
||||
: base(ctx)
|
||||
{
|
||||
|
|
@ -37,6 +45,7 @@ public sealed class AreaInstance : RemoteObject
|
|||
var mem = Ctx.Memory;
|
||||
var offsets = Ctx.Offsets;
|
||||
|
||||
MemoryProfiler.BeginSection("AI.Fields");
|
||||
// Area level
|
||||
if (offsets.AreaLevelIsByte)
|
||||
{
|
||||
|
|
@ -66,7 +75,10 @@ public sealed class AreaInstance : RemoteObject
|
|||
var count = (int)mem.Read<long>(Address + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
|
||||
EntityCount = count is > 0 and < 50000 ? count : 0;
|
||||
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Cascade to children
|
||||
MemoryProfiler.BeginSection("AI.Entities");
|
||||
if (EntityCount > 0)
|
||||
{
|
||||
EntityList.ExpectedCount = EntityCount;
|
||||
|
|
@ -76,6 +88,7 @@ public sealed class AreaInstance : RemoteObject
|
|||
{
|
||||
EntityList.Reset();
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Resolve PSD for skill bar + quest reads
|
||||
nint psdPtr = 0;
|
||||
|
|
@ -86,6 +99,7 @@ public sealed class AreaInstance : RemoteObject
|
|||
psdPtr = mem.ReadPointer(psdVecBegin);
|
||||
}
|
||||
|
||||
MemoryProfiler.BeginSection("AI.Skills");
|
||||
if (LocalPlayerPtr != 0)
|
||||
{
|
||||
PlayerSkills.PsdPtr = psdPtr;
|
||||
|
|
@ -95,14 +109,24 @@ public sealed class AreaInstance : RemoteObject
|
|||
{
|
||||
PlayerSkills.Reset();
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
if (ServerDataPtr != 0)
|
||||
QuestFlags.Update(ServerDataPtr);
|
||||
else
|
||||
QuestFlags.Reset();
|
||||
|
||||
// Quest state container (AI+0x900 → obj → +0x240 vector)
|
||||
QuestStates = ReadQuestStates(mem, offsets);
|
||||
// Quest states — cheap vector read every 1s, used as change detector
|
||||
// for expensive linked list / UI tree reads downstream.
|
||||
var now = Environment.TickCount64;
|
||||
if (now >= _questNextReadTick)
|
||||
{
|
||||
MemoryProfiler.BeginSection("AI.QuestFlags");
|
||||
QuestStates = ReadQuestStates(mem, offsets);
|
||||
var hash = ComputeQuestStatesHash(QuestStates);
|
||||
if (hash != _lastQuestStatesHash)
|
||||
{
|
||||
_lastQuestStatesHash = hash;
|
||||
QuestStatesChanged = true;
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
_questNextReadTick = now + QuestReadIntervalMs;
|
||||
}
|
||||
|
||||
// AreaTemplate — pointer at AreaInstance + AreaTemplateOffset
|
||||
var areaTemplatePtr = mem.ReadPointer(Address + offsets.AreaTemplateOffset);
|
||||
|
|
@ -112,8 +136,10 @@ public sealed class AreaInstance : RemoteObject
|
|||
AreaTemplate.Reset();
|
||||
|
||||
// Terrain — pass loading/area state before update
|
||||
MemoryProfiler.BeginSection("AI.Terrain");
|
||||
Terrain.AreaHash = AreaHash;
|
||||
Terrain.Update(Address);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -134,6 +160,15 @@ public sealed class AreaInstance : RemoteObject
|
|||
Terrain.InvalidateCache();
|
||||
}
|
||||
|
||||
private static int ComputeQuestStatesHash(List<QuestStateEntry>? states)
|
||||
{
|
||||
if (states is null or { Count: 0 }) return 0;
|
||||
var h = states.Count;
|
||||
foreach (var e in states)
|
||||
h = unchecked(h * 31 + e.QuestId * 397 + e.State);
|
||||
return h;
|
||||
}
|
||||
|
||||
private List<QuestStateEntry>? ReadQuestStates(ProcessMemory mem, GameOffsets offsets)
|
||||
{
|
||||
if (offsets.QuestStateObjectOffset <= 0 || offsets.QuestStateVectorOffset <= 0)
|
||||
|
|
@ -181,6 +216,9 @@ public sealed class AreaInstance : RemoteObject
|
|||
LocalPlayerPtr = 0;
|
||||
EntityCount = 0;
|
||||
QuestStates = null;
|
||||
QuestStatesChanged = true; // force full quest read on next tick
|
||||
_questNextReadTick = 0;
|
||||
_lastQuestStatesHash = 0;
|
||||
EntityList.Reset();
|
||||
PlayerSkills.Reset();
|
||||
QuestFlags.Reset();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Roboto.Memory.Objects;
|
|||
public sealed class AreaTemplate : RemoteObject
|
||||
{
|
||||
private readonly MsvcStringReader _strings;
|
||||
private nint _cachedAddress; // skip re-read if Address unchanged
|
||||
|
||||
public string? RawName { get; private set; }
|
||||
public string? Name { get; private set; }
|
||||
|
|
@ -23,6 +24,10 @@ public sealed class AreaTemplate : RemoteObject
|
|||
|
||||
protected override bool ReadData()
|
||||
{
|
||||
// AreaTemplate data is static for the entire zone — only re-read on address change
|
||||
if (Address == _cachedAddress && RawName is not null)
|
||||
return true;
|
||||
|
||||
var mem = Ctx.Memory;
|
||||
var o = Ctx.Offsets;
|
||||
|
||||
|
|
@ -41,6 +46,7 @@ public sealed class AreaTemplate : RemoteObject
|
|||
MonsterLevel = mem.Read<int>(Address + o.AreaTemplateMonsterLevelOffset);
|
||||
WorldAreaId = mem.Read<int>(Address + o.AreaTemplateWorldAreaIdOffset);
|
||||
|
||||
_cachedAddress = Address;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -53,5 +59,6 @@ public sealed class AreaTemplate : RemoteObject
|
|||
HasWaypoint = false;
|
||||
MonsterLevel = 0;
|
||||
WorldAreaId = 0;
|
||||
_cachedAddress = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,28 @@ public sealed class EntityList : RemoteObject
|
|||
{
|
||||
private readonly ComponentReader _components;
|
||||
private readonly MsvcStringReader _strings;
|
||||
private bool _loggedMonsterComponents;
|
||||
|
||||
// Caches: stable per entity within a zone, cleared on zone change
|
||||
private readonly Dictionary<nint, int> _renderIndexCache = new();
|
||||
private readonly Dictionary<nint, string?> _pathCache = new();
|
||||
private readonly Dictionary<nint, Dictionary<string, int>?> _lookupCache = new();
|
||||
private readonly Dictionary<nint, (nint First, int Count)> _compListCache = new();
|
||||
private readonly Dictionary<nint, nint[]> _compPtrsCache = new();
|
||||
private readonly Dictionary<nint, CachedEntityComponents> _stableCompsCache = new();
|
||||
private readonly Dictionary<nint, (int State, long Tick)> _transitionStateCache = new();
|
||||
|
||||
// Cached tree traversal order — re-walk only when entity count changes
|
||||
private List<(nint NodeAddr, nint EntityPtr, uint EntityId)>? _cachedTreeOrder;
|
||||
private int _cachedTreeEntityCount;
|
||||
|
||||
/// <summary>Cached component data that is stable per entity within a zone.</summary>
|
||||
private struct CachedEntityComponents
|
||||
{
|
||||
public bool IsTargetable;
|
||||
public int Rarity; // -1 = not read
|
||||
public string? TransitionName;
|
||||
}
|
||||
|
||||
public List<Entity>? Entities { get; private set; }
|
||||
|
||||
|
|
@ -37,24 +59,47 @@ public sealed class EntityList : RemoteObject
|
|||
var sentinel = mem.ReadPointer(Address + offsets.EntityListOffset);
|
||||
if (sentinel == 0) { Entities = null; return true; }
|
||||
|
||||
var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
|
||||
var entities = new List<Entity>();
|
||||
var maxNodes = Math.Min(ExpectedCount + 10, 500);
|
||||
var hasComponentLookup = offsets.ComponentLookupEntrySize > 0;
|
||||
var dirty = false;
|
||||
|
||||
WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) =>
|
||||
// Build or refresh the tree traversal cache
|
||||
MemoryProfiler.BeginSection("E.Tree");
|
||||
if (_cachedTreeOrder is null || _cachedTreeEntityCount != ExpectedCount)
|
||||
{
|
||||
var entityPtr = treeNode.Data.EntityPtr;
|
||||
if (entityPtr == 0) return;
|
||||
// Full tree walk — only when entity count changes
|
||||
var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
|
||||
var maxNodes = Math.Min(ExpectedCount + 10, 500);
|
||||
var treeOrder = new List<(nint, nint, uint)>(maxNodes);
|
||||
WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) =>
|
||||
{
|
||||
var ep = treeNode.Data.EntityPtr;
|
||||
if (ep == 0) return;
|
||||
var h = (ulong)ep >> 32;
|
||||
if (h == 0 || h >= 0x7FFF || (ep & 0x3) != 0) return;
|
||||
treeOrder.Add((0, ep, treeNode.Data.Key.EntityId));
|
||||
});
|
||||
_cachedTreeOrder = treeOrder;
|
||||
_cachedTreeEntityCount = ExpectedCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fast path — re-read just the entity pointers from cached node addresses
|
||||
// Entity pointers are stable per node, so we can skip this entirely
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
var high = (ulong)entityPtr >> 32;
|
||||
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
|
||||
foreach (var (_, entityPtr, entityId) in _cachedTreeOrder)
|
||||
{
|
||||
MemoryProfiler.BeginSection("E.Path");
|
||||
if (!_pathCache.TryGetValue(entityPtr, out var path))
|
||||
{
|
||||
path = TryReadEntityPath(entityPtr);
|
||||
_pathCache[entityPtr] = path;
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
var entityId = treeNode.Data.Key.EntityId;
|
||||
var path = TryReadEntityPath(entityPtr);
|
||||
|
||||
if (IsDoodadPath(path)) return;
|
||||
if (ShouldSkipEntity(path)) continue;
|
||||
|
||||
var entity = new Entity(entityPtr, entityId, path);
|
||||
entity.Type = ClassifyType(path);
|
||||
|
|
@ -62,17 +107,39 @@ public sealed class EntityList : RemoteObject
|
|||
if (registry["entities"].Register(entity.Metadata))
|
||||
dirty = true;
|
||||
|
||||
if (TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
|
||||
{
|
||||
entity.HasPosition = true;
|
||||
entity.X = x;
|
||||
entity.Y = y;
|
||||
entity.Z = z;
|
||||
}
|
||||
// Skip expensive reads for low-priority entities (effects, terrain, critters)
|
||||
var lowPriority = ShouldSkipComponents(entity.Type);
|
||||
|
||||
if (hasComponentLookup && !IsLowPriorityPath(entity.Type))
|
||||
if (!lowPriority)
|
||||
{
|
||||
var lookup = _components.ReadComponentLookup(entityPtr);
|
||||
// Read component list once — shared across position and component reads
|
||||
MemoryProfiler.BeginSection("E.Position");
|
||||
if (!_compListCache.TryGetValue(entityPtr, out var compList))
|
||||
{
|
||||
compList = _components.FindComponentList(entityPtr);
|
||||
_compListCache[entityPtr] = compList;
|
||||
}
|
||||
var (compFirst, compCount) = compList;
|
||||
|
||||
if (TryReadEntityPosition(entityPtr, compFirst, compCount, out var x, out var y, out var z))
|
||||
{
|
||||
entity.HasPosition = true;
|
||||
entity.X = x;
|
||||
entity.Y = y;
|
||||
entity.Z = z;
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
if (hasComponentLookup)
|
||||
{
|
||||
MemoryProfiler.BeginSection("E.Lookup");
|
||||
if (!_lookupCache.TryGetValue(entityPtr, out var lookup))
|
||||
{
|
||||
lookup = _components.ReadComponentLookup(entityPtr);
|
||||
_lookupCache[entityPtr] = lookup;
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
if (lookup is not null)
|
||||
{
|
||||
entity.Components = new HashSet<string>(lookup.Keys);
|
||||
|
|
@ -81,77 +148,154 @@ public sealed class EntityList : RemoteObject
|
|||
if (registry["components"].Register(lookup.Keys))
|
||||
dirty = true;
|
||||
|
||||
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
|
||||
MemoryProfiler.BeginSection("E.Comps");
|
||||
|
||||
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compCount)
|
||||
// Component pointer array — stable per entity, cache it
|
||||
if (!_compPtrsCache.TryGetValue(entityPtr, out var compPtrs))
|
||||
{
|
||||
var targetComp = mem.ReadPointer(compFirst + targetIdx * 8);
|
||||
if (targetComp != 0)
|
||||
compPtrs = null!;
|
||||
if (compCount > 0 && compCount < 200)
|
||||
{
|
||||
var targetable = mem.Read<Targetable>(targetComp);
|
||||
entity.IsTargetable = targetable.IsTargetable != 0;
|
||||
var ptrBytes = mem.ReadBytes(compFirst, compCount * 8);
|
||||
if (ptrBytes is { Length: > 0 })
|
||||
{
|
||||
compPtrs = new nint[compCount];
|
||||
for (var ci = 0; ci < compCount; ci++)
|
||||
compPtrs[ci] = (nint)BitConverter.ToInt64(ptrBytes, ci * 8);
|
||||
}
|
||||
}
|
||||
if (compPtrs is not null)
|
||||
_compPtrsCache[entityPtr] = compPtrs;
|
||||
}
|
||||
|
||||
if (entity.Components.Contains("Monster"))
|
||||
if (compPtrs is not null)
|
||||
{
|
||||
if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount)
|
||||
// Stable component data — read once, cache
|
||||
if (!_stableCompsCache.TryGetValue(entityPtr, out var stable))
|
||||
{
|
||||
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
|
||||
if (lifeComp != 0)
|
||||
stable = new CachedEntityComponents { Rarity = -1 };
|
||||
|
||||
if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compPtrs.Length)
|
||||
{
|
||||
var life = mem.Read<Life>(lifeComp);
|
||||
if (life.Health.Total > 0 && life.Health.Total < 200000 &&
|
||||
life.Health.Current >= 0 && life.Health.Current <= life.Health.Total + 1000)
|
||||
var targetComp = compPtrs[targetIdx];
|
||||
if (targetComp != 0)
|
||||
{
|
||||
entity.HasVitals = true;
|
||||
entity.LifeCurrent = life.Health.Current;
|
||||
entity.LifeTotal = life.Health.Total;
|
||||
var targetable = mem.Read<Targetable>(targetComp);
|
||||
stable.IsTargetable = targetable.IsTargetable != 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("ObjectMagicProperties", out var ompIdx) && ompIdx >= 0 && ompIdx < compPtrs.Length)
|
||||
{
|
||||
var ompComp = compPtrs[ompIdx];
|
||||
if (ompComp != 0)
|
||||
{
|
||||
var props = mem.Read<ObjectMagicProperties>(ompComp);
|
||||
if (props.Rarity is >= 0 and <= 3)
|
||||
stable.Rarity = props.Rarity;
|
||||
}
|
||||
}
|
||||
else if (lookup.TryGetValue("Mods", out var modsRarityIdx) && modsRarityIdx >= 0 && modsRarityIdx < compPtrs.Length)
|
||||
{
|
||||
var modsComp = compPtrs[modsRarityIdx];
|
||||
if (modsComp != 0)
|
||||
{
|
||||
var mods = mem.Read<Mods>(modsComp);
|
||||
if (mods.Rarity is >= 0 and <= 3)
|
||||
stable.Rarity = mods.Rarity;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.Components.Contains("AreaTransition") &&
|
||||
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compPtrs.Length)
|
||||
{
|
||||
var atComp = compPtrs[atIdx];
|
||||
if (atComp != 0)
|
||||
stable.TransitionName = ReadAreaTransitionName(atComp);
|
||||
}
|
||||
|
||||
_stableCompsCache[entityPtr] = stable;
|
||||
}
|
||||
|
||||
entity.IsTargetable = stable.IsTargetable;
|
||||
if (stable.Rarity >= 0)
|
||||
entity.Rarity = stable.Rarity;
|
||||
entity.TransitionName = stable.TransitionName;
|
||||
|
||||
// Dynamic component data — re-read every frame
|
||||
if (entity.Components.Contains("Monster"))
|
||||
{
|
||||
if (!_loggedMonsterComponents)
|
||||
{
|
||||
_loggedMonsterComponents = true;
|
||||
var name = entity.Path?[(entity.Path.LastIndexOf('/') + 1)..] ?? "?";
|
||||
Serilog.Log.Information("Monster [{Name}] components: {Comps}",
|
||||
name, string.Join(", ", lookup.Keys.OrderBy(k => k)));
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compPtrs.Length)
|
||||
{
|
||||
var lifeComp = compPtrs[lifeIdx];
|
||||
if (lifeComp != 0)
|
||||
{
|
||||
// Read only Health.Total (0x1D4) + Health.Current (0x1D8) = 8 bytes
|
||||
// instead of full Life struct (0x268 = 616 bytes)
|
||||
const int healthTotalOff = 0x1A8 + 0x2C; // Life.Health + VitalStruct.Total
|
||||
var vitals = mem.ReadBytes(lifeComp + healthTotalOff, 8);
|
||||
if (vitals is { Length: 8 })
|
||||
{
|
||||
var hpTotal = BitConverter.ToInt32(vitals, 0);
|
||||
var hpCurrent = BitConverter.ToInt32(vitals, 4);
|
||||
if (hpTotal > 0 && hpTotal < 200000 &&
|
||||
hpCurrent >= 0 && hpCurrent <= hpTotal + 1000)
|
||||
{
|
||||
entity.HasVitals = true;
|
||||
entity.LifeCurrent = hpCurrent;
|
||||
entity.LifeTotal = hpTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compPtrs.Length)
|
||||
{
|
||||
var actorComp = compPtrs[actorIdx];
|
||||
if (actorComp != 0)
|
||||
{
|
||||
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
|
||||
entity.ActionId = (short)(animId & 0xFFFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount)
|
||||
if (entity.Components.Contains("Transitionable") &&
|
||||
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compPtrs.Length)
|
||||
{
|
||||
var actorComp = mem.ReadPointer(compFirst + actorIdx * 8);
|
||||
if (actorComp != 0)
|
||||
var trComp = compPtrs[trIdx];
|
||||
if (trComp != 0)
|
||||
{
|
||||
var animId = mem.Read<int>(actorComp + ActorOffsets.AnimationId);
|
||||
entity.ActionId = (short)(animId & 0xFFFF);
|
||||
var now = Environment.TickCount64;
|
||||
if (_transitionStateCache.TryGetValue(entityPtr, out var cached) && now - cached.Tick < 1000)
|
||||
{
|
||||
entity.TransitionState = cached.State;
|
||||
}
|
||||
else
|
||||
{
|
||||
var state = mem.Read<int>(trComp + 0x120);
|
||||
entity.TransitionState = state;
|
||||
_transitionStateCache[entityPtr] = (state, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.TryGetValue("Mods", out var modsIdx) && modsIdx >= 0 && modsIdx < compCount)
|
||||
{
|
||||
var modsComp = mem.ReadPointer(compFirst + modsIdx * 8);
|
||||
if (modsComp != 0)
|
||||
ReadEntityMods(entity, modsComp);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.Components.Contains("AreaTransition") &&
|
||||
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount)
|
||||
{
|
||||
var atComp = mem.ReadPointer(compFirst + atIdx * 8);
|
||||
if (atComp != 0)
|
||||
entity.TransitionName = ReadAreaTransitionName(atComp);
|
||||
}
|
||||
|
||||
if (entity.Components.Contains("Transitionable") &&
|
||||
lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compCount)
|
||||
{
|
||||
var trComp = mem.ReadPointer(compFirst + trIdx * 8);
|
||||
if (trComp != 0)
|
||||
{
|
||||
var tr = mem.Read<Transitionable>(trComp);
|
||||
entity.TransitionState = tr.CurrentStateEnum;
|
||||
}
|
||||
}
|
||||
MemoryProfiler.EndSection();
|
||||
}
|
||||
}
|
||||
} // !lowPriority
|
||||
|
||||
entities.Add(entity);
|
||||
});
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
registry.Flush();
|
||||
|
|
@ -164,6 +308,15 @@ public sealed class EntityList : RemoteObject
|
|||
{
|
||||
Entities = null;
|
||||
ExpectedCount = 0;
|
||||
_renderIndexCache.Clear();
|
||||
_pathCache.Clear();
|
||||
_lookupCache.Clear();
|
||||
_compListCache.Clear();
|
||||
_compPtrsCache.Clear();
|
||||
_stableCompsCache.Clear();
|
||||
_transitionStateCache.Clear();
|
||||
_cachedTreeOrder = null;
|
||||
_cachedTreeEntityCount = 0;
|
||||
}
|
||||
|
||||
// ── Tree walking ─────────────────────────────────────────────────────
|
||||
|
|
@ -224,21 +377,33 @@ public sealed class EntityList : RemoteObject
|
|||
return _strings.ReadMsvcWString(detailsPtr + offsets.EntityPathStringOffset);
|
||||
}
|
||||
|
||||
public bool TryReadEntityPosition(nint entity, out float x, out float y, out float z)
|
||||
public bool TryReadEntityPosition(nint entity, nint compFirst, int count, out float x, out float y, out float z)
|
||||
{
|
||||
x = y = z = 0;
|
||||
var offsets = Ctx.Offsets;
|
||||
|
||||
var (compFirst, count) = _components.FindComponentList(entity);
|
||||
if (count <= 0) return false;
|
||||
|
||||
// Fast path: cached render component index for this entity
|
||||
if (_renderIndexCache.TryGetValue(entity, out var cachedIdx) && cachedIdx >= 0 && cachedIdx < count)
|
||||
{
|
||||
var renderComp = Ctx.Memory.ReadPointer(compFirst + cachedIdx * 8);
|
||||
if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
|
||||
return true;
|
||||
// Cache miss (component list changed?) — fall through to re-scan
|
||||
}
|
||||
|
||||
// Try configured global index
|
||||
var offsets = Ctx.Offsets;
|
||||
if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
|
||||
{
|
||||
var renderComp = Ctx.Memory.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
|
||||
if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
|
||||
{
|
||||
_renderIndexCache[entity] = offsets.RenderComponentIndex;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan and cache the result
|
||||
var scanLimit = Math.Min(count, 20);
|
||||
for (var i = 0; i < scanLimit; i++)
|
||||
{
|
||||
|
|
@ -249,7 +414,10 @@ public sealed class EntityList : RemoteObject
|
|||
if ((compPtr & 0x3) != 0) continue;
|
||||
|
||||
if (_components.TryReadPositionRaw(compPtr, out x, out y, out z))
|
||||
{
|
||||
_renderIndexCache[entity] = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
@ -284,59 +452,23 @@ public sealed class EntityList : RemoteObject
|
|||
return null;
|
||||
}
|
||||
|
||||
private void ReadEntityMods(Entity entity, nint modsComp)
|
||||
{
|
||||
var mem = Ctx.Memory;
|
||||
|
||||
var mods = mem.Read<Mods>(modsComp);
|
||||
|
||||
if (mods.ObjectMagicPropertiesPtr != 0 &&
|
||||
((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF)
|
||||
{
|
||||
var props = mem.Read<ObjectMagicProperties>(mods.ObjectMagicPropertiesPtr);
|
||||
if (props.Rarity is >= 0 and <= 3)
|
||||
entity.Rarity = props.Rarity;
|
||||
}
|
||||
|
||||
if (mods.AllModsPtr == 0 || ((ulong)mods.AllModsPtr >> 32) is 0 or >= 0x7FFF)
|
||||
return;
|
||||
|
||||
var allMods = mem.Read<AllModsType>(mods.AllModsPtr);
|
||||
var explicitCount = (int)allMods.ExplicitMods.TotalElements(16);
|
||||
if (explicitCount <= 0 || explicitCount > 20) return;
|
||||
|
||||
var modNames = new List<string>();
|
||||
for (var i = 0; i < explicitCount; i++)
|
||||
{
|
||||
var modEntry = mem.Read<ModArrayStruct>(allMods.ExplicitMods.First + i * 16);
|
||||
if (modEntry.ModPtr == 0) continue;
|
||||
if (((ulong)modEntry.ModPtr >> 32) is 0 or >= 0x7FFF) continue;
|
||||
|
||||
var name = _strings.ReadNullTermWString(modEntry.ModPtr);
|
||||
if (name is not null)
|
||||
{
|
||||
modNames.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
name = _strings.ReadMsvcWString(modEntry.ModPtr);
|
||||
if (name is not null)
|
||||
modNames.Add(name);
|
||||
}
|
||||
|
||||
if (modNames.Count > 0)
|
||||
entity.ModNames = modNames;
|
||||
}
|
||||
|
||||
// ── Classification helpers ───────────────────────────────────────────
|
||||
|
||||
private static bool IsDoodadPath(string? path)
|
||||
/// <summary>
|
||||
/// Entities to skip entirely — no Entity object created, no reads at all.
|
||||
/// </summary>
|
||||
private static bool ShouldSkipEntity(string? path)
|
||||
{
|
||||
if (path is null) return false;
|
||||
return path.Contains("Doodad", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsLowPriorityPath(EntityType type)
|
||||
/// <summary>
|
||||
/// Entity types that skip position, component lookup, and component reads.
|
||||
/// Only the path is read and cached. Add new types here to filter them out.
|
||||
/// </summary>
|
||||
private static bool ShouldSkipComponents(EntityType type)
|
||||
=> type is EntityType.Effect or EntityType.Terrain or EntityType.Critter;
|
||||
|
||||
private static EntityType ClassifyType(string? path)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ public sealed class GameStates
|
|||
public AreaLoading AreaLoading { get; }
|
||||
public InGameState InGame { get; }
|
||||
|
||||
/// <summary>Enable to run DumpControllerPreSlots (expensive). Only needed for diagnostics UI.</summary>
|
||||
public bool EnableDiagnostics { get; set; }
|
||||
|
||||
/// <summary>Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.</summary>
|
||||
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
|
||||
|
||||
|
|
@ -48,9 +51,13 @@ public sealed class GameStates
|
|||
if (_ctx.GameStateBase == 0)
|
||||
return false;
|
||||
|
||||
MemoryProfiler.BeginSection("Slots");
|
||||
var controller = mem.ReadPointer(_ctx.GameStateBase);
|
||||
if (controller == 0)
|
||||
{
|
||||
MemoryProfiler.EndSection();
|
||||
return false;
|
||||
}
|
||||
ControllerPtr = controller;
|
||||
|
||||
nint igsPtr = 0;
|
||||
|
|
@ -104,8 +111,11 @@ public sealed class GameStates
|
|||
igsPtr = _slotPointers[offsets.InGameStateIndex];
|
||||
}
|
||||
|
||||
// Dump controller pre-slots region for diagnostics
|
||||
DumpControllerPreSlots(controller);
|
||||
// Dump controller pre-slots region for diagnostics (expensive — skip in production)
|
||||
if (EnableDiagnostics)
|
||||
DumpControllerPreSlots(controller);
|
||||
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Cascade to children FIRST — we need their flags for current state resolution
|
||||
var areaLoadingPtr = StatesCount > 0 ? _slotPointers[0] : (nint)0;
|
||||
|
|
@ -118,10 +128,14 @@ public sealed class GameStates
|
|||
return false;
|
||||
}
|
||||
|
||||
MemoryProfiler.BeginSection("InGame");
|
||||
InGame.Update(igsPtr);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Resolve current state AFTER children have read their flags
|
||||
MemoryProfiler.BeginSection("Slots");
|
||||
ResolveCurrentState(controller, igsPtr);
|
||||
MemoryProfiler.EndSection();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,14 +40,20 @@ public sealed class InGameState : RemoteObject
|
|||
IsEscapeOpen = _data.EscapeStateFlag != 0;
|
||||
|
||||
// Cascade to AreaInstance
|
||||
MemoryProfiler.BeginSection("AreaInstance");
|
||||
AreaInstance.Update(_data.AreaInstanceDataPtr);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Cascade to WorldData — set fallback camera before update
|
||||
MemoryProfiler.BeginSection("WorldData");
|
||||
WorldData.FallbackCameraPtr = _data.CameraPtr;
|
||||
WorldData.Update(_data.WorldDataPtr);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
// Cascade to UIElements — pass InGameState address for UiRootStruct chain
|
||||
MemoryProfiler.BeginSection("UIElements");
|
||||
UIElements.Update(Address);
|
||||
MemoryProfiler.EndSection();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public sealed class PlayerSkills : RemoteObject
|
|||
// Name cache — skill names are static per area, only refresh on actor change
|
||||
private readonly Dictionary<nint, string?> _nameCache = new();
|
||||
private nint _lastActorComp;
|
||||
private nint _cachedActorEntity; // entity ptr the cached actor was resolved for
|
||||
|
||||
public List<SkillSnapshot>? Skills { get; private set; }
|
||||
|
||||
|
|
@ -34,7 +35,17 @@ public sealed class PlayerSkills : RemoteObject
|
|||
if (Address == 0) { Skills = null; return false; }
|
||||
var mem = Ctx.Memory;
|
||||
|
||||
var actorComp = _components.GetComponentAddress(Address, "Actor");
|
||||
// Cache Actor component address — only re-resolve on entity change (zone transition)
|
||||
nint actorComp;
|
||||
if (Address == _cachedActorEntity && _lastActorComp != 0)
|
||||
{
|
||||
actorComp = _lastActorComp;
|
||||
}
|
||||
else
|
||||
{
|
||||
actorComp = _components.GetComponentAddress(Address, "Actor");
|
||||
_cachedActorEntity = Address;
|
||||
}
|
||||
if (actorComp == 0) { Skills = null; return true; }
|
||||
|
||||
// Invalidate name cache if actor component address changed (area transition)
|
||||
|
|
@ -141,6 +152,7 @@ public sealed class PlayerSkills : RemoteObject
|
|||
PsdPtr = 0;
|
||||
_nameCache.Clear();
|
||||
_lastActorComp = 0;
|
||||
_cachedActorEntity = 0;
|
||||
}
|
||||
|
||||
private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ public sealed class QuestFlags : RemoteObject
|
|||
|
||||
public List<QuestSnapshot>? Quests { get; private set; }
|
||||
|
||||
/// <summary>PSD pointer resolved by AreaInstance. Set before calling Update() to avoid duplicate chain walk.</summary>
|
||||
public nint PsdPtr { get; set; }
|
||||
|
||||
public QuestFlags(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
|
||||
: base(ctx)
|
||||
{
|
||||
|
|
@ -30,12 +33,8 @@ public sealed class QuestFlags : RemoteObject
|
|||
var offsets = Ctx.Offsets;
|
||||
if (offsets.QuestFlagEntrySize <= 0) { Quests = null; return true; }
|
||||
|
||||
var mem = Ctx.Memory;
|
||||
|
||||
var psdVecBegin = mem.ReadPointer(Address + offsets.PlayerServerDataOffset);
|
||||
if (psdVecBegin == 0) { Quests = null; return true; }
|
||||
|
||||
var playerServerData = mem.ReadPointer(psdVecBegin);
|
||||
// Use PSD pointer passed from AreaInstance (avoids re-walking ServerData → PSD chain)
|
||||
var playerServerData = PsdPtr;
|
||||
if (playerServerData == 0) { Quests = null; return true; }
|
||||
|
||||
if (playerServerData != _lastPsd)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ public sealed class Terrain : RemoteObject
|
|||
private WalkabilityGrid? _cachedTerrain;
|
||||
private bool _wasLoading;
|
||||
|
||||
// Non-inline: cache intermediate pointers (stable within a zone)
|
||||
private nint _cachedDimsPtr;
|
||||
private nint _cachedTerrainAddr; // Address when cache was built
|
||||
|
||||
public WalkabilityGrid? Grid { get; private set; }
|
||||
public int TerrainWidth { get; private set; }
|
||||
public int TerrainHeight { get; private set; }
|
||||
|
|
@ -32,6 +36,8 @@ public sealed class Terrain : RemoteObject
|
|||
{
|
||||
_cachedTerrain = null;
|
||||
_cachedTerrainAreaHash = 0;
|
||||
_cachedDimsPtr = 0;
|
||||
_cachedTerrainAddr = 0;
|
||||
}
|
||||
|
||||
protected override bool ReadData()
|
||||
|
|
@ -41,14 +47,26 @@ public sealed class Terrain : RemoteObject
|
|||
|
||||
if (!offsets.TerrainInline)
|
||||
{
|
||||
var terrainListPtr = mem.ReadPointer(Address + offsets.TerrainListOffset);
|
||||
if (terrainListPtr == 0) return true;
|
||||
// Cache the 3-hop pointer chain (stable within a zone)
|
||||
nint dimsPtr;
|
||||
if (_cachedDimsPtr != 0 && Address == _cachedTerrainAddr)
|
||||
{
|
||||
dimsPtr = _cachedDimsPtr;
|
||||
}
|
||||
else
|
||||
{
|
||||
var terrainListPtr = mem.ReadPointer(Address + offsets.TerrainListOffset);
|
||||
if (terrainListPtr == 0) return true;
|
||||
|
||||
var terrainPtr = mem.ReadPointer(terrainListPtr);
|
||||
if (terrainPtr == 0) return true;
|
||||
var terrainPtr = mem.ReadPointer(terrainListPtr);
|
||||
if (terrainPtr == 0) return true;
|
||||
|
||||
var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
|
||||
if (dimsPtr == 0) return true;
|
||||
dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
|
||||
if (dimsPtr == 0) return true;
|
||||
|
||||
_cachedDimsPtr = dimsPtr;
|
||||
_cachedTerrainAddr = Address;
|
||||
}
|
||||
|
||||
TerrainCols = mem.Read<int>(dimsPtr);
|
||||
TerrainRows = mem.Read<int>(dimsPtr + 4);
|
||||
|
|
@ -164,6 +182,8 @@ public sealed class Terrain : RemoteObject
|
|||
TerrainCols = 0;
|
||||
TerrainRows = 0;
|
||||
WalkablePercent = 0;
|
||||
_cachedDimsPtr = 0;
|
||||
_cachedTerrainAddr = 0;
|
||||
}
|
||||
|
||||
public static int CalcWalkablePercent(WalkabilityGrid grid)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ public sealed class UIElements : RemoteObject
|
|||
|
||||
private readonly MsvcStringReader _strings;
|
||||
|
||||
// Cached quest parent pointers — resolved once, reused across reads
|
||||
private nint _cachedTrackedPanelAddr; // GameUi[6][1]
|
||||
private nint _cachedQuestParentAddr; // GameUi[6][1][0][0][0]
|
||||
private nint _cachedForGameUi; // GameUiPtr when cache was built
|
||||
|
||||
/// <summary>Optional lookup for resolving quest state IDs to human-readable text.</summary>
|
||||
public QuestStateLookup? QuestStateLookup { get; set; }
|
||||
|
||||
|
|
@ -192,6 +197,38 @@ public sealed class UIElements : RemoteObject
|
|||
return node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves and caches quest parent pointers from the UI tree.
|
||||
/// Called lazily; cache invalidated when GameUiPtr changes.
|
||||
/// </summary>
|
||||
private void EnsureQuestPointerCache()
|
||||
{
|
||||
if (_cachedForGameUi == GameUiPtr && _cachedTrackedPanelAddr != 0)
|
||||
return; // cache is valid
|
||||
|
||||
_cachedForGameUi = GameUiPtr;
|
||||
_cachedTrackedPanelAddr = 0;
|
||||
_cachedQuestParentAddr = 0;
|
||||
|
||||
if (GameUiPtr == 0) return;
|
||||
|
||||
var offsets = Ctx.Offsets;
|
||||
|
||||
// GameUi[6] → [1] for tracked quests
|
||||
var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
|
||||
if (elem6 is not null)
|
||||
{
|
||||
var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
|
||||
if (elem61 is not null)
|
||||
_cachedTrackedPanelAddr = elem61.Address;
|
||||
}
|
||||
|
||||
// GameUi[6][1][0][0][0] for quest groups
|
||||
var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
|
||||
if (questParent is not null)
|
||||
_cachedQuestParentAddr = questParent.Address;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads quest groups from the UI tree.
|
||||
/// Path: GameUi[6][1][0][0][0] → quest_display → [0] → title_layout/quest_info_layout
|
||||
|
|
@ -200,11 +237,10 @@ public sealed class UIElements : RemoteObject
|
|||
{
|
||||
if (GameUiPtr == 0) return null;
|
||||
|
||||
// Navigate to the parent that holds quest_display nodes
|
||||
var questParent = NavigatePath(GameUiPtr, [6, 1, 0, 0, 0]);
|
||||
if (questParent is null) return null;
|
||||
EnsureQuestPointerCache();
|
||||
if (_cachedQuestParentAddr == 0) return null;
|
||||
|
||||
var questDisplays = ReadChildren(questParent.Address);
|
||||
var questDisplays = ReadChildren(_cachedQuestParentAddr);
|
||||
if (questDisplays is null) return null;
|
||||
|
||||
var groups = new List<UiQuestGroup>();
|
||||
|
|
@ -318,18 +354,15 @@ public sealed class UIElements : RemoteObject
|
|||
var mem = Ctx.Memory;
|
||||
var offsets = Ctx.Offsets;
|
||||
|
||||
// ── Tracked quests: [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
|
||||
EnsureQuestPointerCache();
|
||||
|
||||
// ── Tracked quests: cached [6][1]+0x318 — collect into dict keyed by QuestDatPtr ──
|
||||
var trackedMap = new Dictionary<nint, string?>();
|
||||
var elem6 = ReadChildAtIndex(GameUiPtr, offsets.TrackedQuestPanelChildIndex);
|
||||
if (elem6 is not null)
|
||||
if (_cachedTrackedPanelAddr != 0)
|
||||
{
|
||||
var elem61 = ReadChildAtIndex(elem6.Address, offsets.TrackedQuestPanelSubChildIndex);
|
||||
if (elem61 is not null)
|
||||
{
|
||||
var trackedHead = mem.ReadPointer(elem61.Address + offsets.TrackedQuestLinkedListOffset);
|
||||
if (trackedHead != 0)
|
||||
TraverseTrackedQuests(trackedHead, trackedMap);
|
||||
}
|
||||
var trackedHead = mem.ReadPointer(_cachedTrackedPanelAddr + offsets.TrackedQuestLinkedListOffset);
|
||||
if (trackedHead != 0)
|
||||
TraverseTrackedQuests(trackedHead, trackedMap);
|
||||
}
|
||||
|
||||
// ── All quests: GameUi+0x358 ──
|
||||
|
|
@ -563,5 +596,8 @@ public sealed class UIElements : RemoteObject
|
|||
{
|
||||
UiRootPtr = 0;
|
||||
GameUiPtr = 0;
|
||||
_cachedForGameUi = 0;
|
||||
_cachedTrackedPanelAddr = 0;
|
||||
_cachedQuestParentAddr = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
rownum,Quest,Order,FlagsPresent,FlagsMissing,Text,Text (French),Text (German),Text (Japanese),Text (Korean),Text (Portuguese),Text (Russian),Text (Spanish),Text (Thai),Text (Traditional Chinese),bool_60,Message,Message (French),Message (German),Message (Japanese),Message (Korean),Message (Portuguese),Message (Russian),Message (Spanish),Message (Thai),Message (Traditional Chinese),MapPinsKeys,i32_85,MapPinsText,MapPinsText (French),MapPinsText (German),MapPinsText (Japanese),MapPinsText (Korean),MapPinsText (Portuguese),MapPinsText (Russian),MapPinsText (Spanish),MapPinsText (Thai),MapPinsText (Traditional Chinese),MapPinsKey,[rid]_113,bool_129,[i32]_130,[i32]_146,i32_162,SoundEffect,string_182,[rid]_190,bool_206,bool_207
|
||||
0,0,0,[2936],[],Quest Complete - You have slain the Bloated Miller and received a reward from Renly.,Quête terminée — Vous avez tué le Meunier boursouflé et avez reçu une récompense de la part de Renly.,Quest abgeschlossen: Ihr habt den Aufgedunsenen Müller getötet und von Renly eine Belohnung erhalten.,クエスト完了 - 腐乱した粉屋を倒し、レンリーから報酬を受け取った,퀘스트 완료 - 불어 터진 방아꾼을 처치하고 렌리에게 보상을 받았습니다.,Missão Concluída - Você matou o Triturador Inchado e recebeu uma recompensa do Renly.,Задание выполнено - Вы убили Раздувшегося мельника и получили награду от Ренли.,Misión completa - Has derrotado al Molinero hinchado y recibido una recompensa de Renly.,เควสต์เสร็จสิ้น - คุณได้สังหารเจ้าของโรงสีขึ้นอืดและรับรางวัลจากเรนลีย์แล้ว,任務完成——你已經殺掉了浮腫米勒,並且從倫利那裡取得你的任務獎勵。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
|
||||
1,0,1,[2935],[],Renly has offered you a reward for slaying the Bloated Miller. Take it.,Renly vous offre une récompense pour avoir tué le Meunier boursouflé. Prenez-la.,"Renly hat Euch eine Belohnung dafür angeboten, dass Ihr den Aufgedunsenen Müller getötet habt. Nehmt sie an Euch.",レンリーは腐乱した粉屋を倒した報酬を提示した。受け取れ,렌리가 불어 터진 방아꾼을 처치해 준 것에 대한 보상을 준다고 합니다. 받으십시오.,Renly te ofereceu uma recompensa por matar o Triturador Inchado. Aceite.,Ренли предложил вам награду за убийство Раздувшегося мельника. Заберите её.,Renly te ha ofrecido una recompensa por derrotar al Molinero hinchado. Acéptala.,เรนลีย์ได้เสนอรางวัลให้คุณเลือกเพื่อตอบแทนการสังหารเจ้าของโรงสีขึ้นอืด รับรางวัลเสีย,倫利要給你擊殺浮腫米勒的獎勵,收下它。,,Take Renly's reward,Prenez la récompense de Renly,Nehmt Renlys Belohnung,レンリーの報酬を受け取れ,렌리의 보상 받기,Pegue a recompensa do Renly,Заберите награду у Ренли,Acepta la recompensa de Renly,รับรางวัลของเรนลีย์,領取倫利的獎勵,[8],0,Take Renly's reward,Prenez la récompense de Renly,Nehmt Renlys Belohnung.,レンリーの報酬を受け取れ,렌리의 보상 받기,Pegue a recompensa do Renly,Заберите награду у Ренли,Acepta la recompensa de Renly,รับรางวัลของเรนลีย์,領取倫利的獎勵,,[],,[],[],10,,,[],,
|
||||
2,0,2,"[2934,2890]",[],The Blacksmith appears to be in charge in this logging encampment. Talk to him.,Le Forgeron semble être le responsable de ce campement forestier. Parlez-lui.,Der Schmied scheint in diesem Holzfällerlager das Sagen zu haben. Sprecht mit ihm.,鍛冶屋が伐採の野営地を取り仕切っているようだ。彼に話しかけろ,대장장이가 이 벌목 야영지를 책임지고 있는 것 같습니다. 그와 대화하십시오.,O Ferreiro parece estar no comando deste acampamento madeireiro. Fale com ele.,"Похоже, в этом лагере лесорубов кузнец за главного. Поговорите с ним.",Parece que el herrero está al mando de este campamento maderero. Habla con él.,เหมือนว่าช่างตีเหล็กจะเป็นผู้นำค่ายตัดไม้ ลองพูดคุยกับเขาดู,鐵匠似乎是這個伐木營地的負責人。與他交談。,,Talk to the Blacksmith,Parlez au Forgeron,Sprecht mit dem Schmied,鍛冶屋に話しかけろ,대장장이와 대화하기,Fale com o Ferreiro,Поговорите с кузнецом,Habla con el herrero,พูดคุยกับช่างตีเหล็ก,與鐵匠交談,[8],0,Talk to the Blacksmith,Parler au Forgeron,Sprecht mit dem Schmied.,鍛冶屋に話しかけろ,대장장이와 대화하기,Fale com o Ferreiro,Поговорите с кузнецом,Habla con el herrero,พูดคุยกับช่างตีเหล็ก,與鐵匠交談,,[],,[],[],10,,,[],,
|
||||
3,0,3,[2934],"[281,368]",You have slain the Bloated Miller and levelled up. Open the Passive Skill Screen and spend a Passive Skill Point to upgrade your character.,Vous avez tué le Meunier boursouflé et êtes monté de niveau. Ouvrez l'arbre des Talents et dépensez un point de Talent pour améliorer votre personnage.,"Ihr habt den Aufgedunsenen Müller getötet und eine neue Stufe erreicht. Öffnet den Passiven Fertigkeitenbaum und weist einen Passiven Fertigkeitspunkt zu, um Euren Charakter zu verbessern.",腐乱した粉屋を倒しレベルが上がった。パッシブスキル画面を開いてパッシブスキルポイントを消費し、キャラクターをアップグレードしろ,불어 터진 방아꾼을 처치하고 레벨을 올렸습니다. 패시브 스킬 창을 열고 패시브 스킬 포인트를 투자해 캐릭터를 강화하십시오.,Você matou o Triturador Inchado e subiu de nível. Abra a Tela de Habilidades Passivas e gaste um Ponto de Habilidade Passiva para melhorar seu personagem.,Вы убили Раздувшегося мельника и повысили свой уровень. Откройте экран пассивных умений и потратьте очко умения на улучшение персонажа.,Has derrotado al Molinero hinchado y subido de nivel. Abre la ventana de habilidades pasivas y gasta un punto de habilidad pasiva para mejorar tu personaje.,คุณได้สังหารเจ้าของโรงสีขึ้นอืดและได้ขึ้นเลเวลใหม่แล้ว อัพเกรดตัวละครของคุณด้วยการเปิดหน้าต่างพาสซีฟ แล้วใช้แต้มพาสซีฟ 1 แต้ม,你已經擊殺浮腫米勒並升等了,開啟天賦樹畫面使用天賦點數升級你的角色。,,,,,,,,,,,,[8],0,Open the Passive Skill Screen\nSpend your Passive Skill Point,Ouvrez votre Arbre des Talents\nDépensez-y votre point de Talent,Öffnet den Passiven Fertigkeitsbaum\nWeist den Passiven Fertigkeitspunkt zu,パッシブスキル画面を開け\nパッシブスキルポイントを使用しろ,패시브 스킬 창 열기\n패시브 스킬 포인트 투자하기,Abra a Tela de Habilidades Passivas\nGaste o seu Ponto de Habilidade Passiva,Откройте экран пассивных умений\nИспользуйте очко пассивного умения,Abre la pantalla de habilidades pasivas\nGasta tu punto de habilidad pasiva,เปิดหน้าจอพาสซีฟ\nใช้แต้มพาสซีฟของคุณ,開啟天賦樹畫面\n使用天賦點,,[],,[],[],10,,,[],,
|
||||
4,0,4,[2934],[],You have slain the Bloated Miller. Enter the logging encampment.,Vous avez tué le Meunier boursouflé. Entrez dans le campement forestier.,Ihr habt den Aufgedunsenen Müller getötet. Betretet das Holzfällerlager.,腐乱した粉屋を倒した。伐採の野営地に入れ,불어 터진 방아꾼을 처치했습니다. 벌목 야영지로 들어가십시오.,Você matou o Triturador Inchado. Entre no acampamento madeireiro.,Вы убили Раздувшегося мельника. Войдите в лагерь лесорубов.,Has derrotado al Molinero hinchado. Entra al campamento maderero.,คุณได้สังหารเจ้าของโรงสีขึ้นอืดแล้ว เข้าไปในค่ายตัดไม้,你已擊殺浮腫米勒。進入伐木營地。,,Enter town,Entrez dans la ville,Betretet die Stadt,街に入れ,마을 들어가기,Entre na cidade,Войдите в лагерь,Entra al pueblo,เข้าไปในเมือง,進入城鎮,[8],0,Enter the logging encampment,Entrez dans le campement forestier,Betretet das Holzfällerlager.,伐採の野営地に入れ,벌목 야영지 들어가기,Entre no acampamento de exploração madeireira,Войдите в лагерь лесорубов,Entra al campamento maderero,เข้าไปในค่ายตัดไม้,進入伐木營地,,[],,[],[],10,,,[],,
|
||||
5,0,5,[2933],[],A logging encampment is under attack by a diseased monstrosity that was once human. Kill it.,Un campement forestier subit l'attaque d'une monstruosité malade autrefois humaine. Tuez-la.,"Ein Holzfällerlager wird von einer krankhaften Monstrosität heimgesucht, die einst ein Mensch war. Tötet sie.",伐採の野営地がかつて人間だった蝕まれた怪物の攻撃を受けている。その怪物を倒せ,벌목 야영지가 한때 인간이었던 질병 걸린 거수에게 공격받고 있습니다. 처치하십시오.,"Um acampamento madeireiro está sob ataque de algo que já foi humano, mas agora é uma monstruosidade adoecida. Mate-a.","На лагерь лесорубов напало чумное чудовище, некогда бывшее человеком. Убейте его.",Un campamento maderero está recibiendo un ataque de una monstruosidad enfermiza que una vez fue humana. Mátala.,ค่ายตัดไม้ถูกรุกรานด้วยน้ำมือของอสุรกายอาบโรคที่เคยเป็นมนุษย์มาก่อน สังหารมันเสีย,伐木營地被原為人類的染病怪物襲擊。殺死牠。,,Slay the Bloated Miller,Tuez le Meunier boursouflé,Tötet den Aufgedunsenen Müller,腐乱した粉屋を倒せ,불어 터진 방아꾼 처치하기,Mate o Triturador Inchado,Убейте Раздувшегося мельника,Derrota al Molinero hinchado,สังหารเจ้าของโรงสีขึ้นอืด,殺死浮腫米勒,[5],0,Kill the Bloated Miller and end his rage,Tuez le Meunier boursouflé et mettez fin à sa rage,Tötet den Aufgedunsenen Müller und setzt seinem Wüten ein Ende.,腐乱した粉屋を倒し、彼の怒りを終わらせろ,불어 터진 방아꾼을 처치해 그의 격노를 잠재우기,Mate o Triturador Inchado e acabe com sua raiva.,Убейте Раздувшегося мельника и покончите с его яростью,Mata al Molinero hinchado y acaba con su furia,สังหารเจ้าของโรงสีขึ้นอืดแล้วยุติความคลั่งของเขา,擊殺浮腫米勒並終止他的怒火,,[],,[],[],10,,,[],,
|
||||
6,0,6,[2801],[],The wounded man mentioned his chief Miller went to warn Clearfell about a sickness plaguing their men. Track down the Miller and find safety in Clearfell.,L'homme blessé a mentionné que son Meunier en chef était parti avertir la Clairière d'une maladie frappant leurs hommes. Suivez la piste du Meunier et trouvez refuge dans la Clairière.,"Der Verwundete erwähnte, dass sein Anführer, der Müller, Lichtfall vor einer Krankheit warnen wollte, die ihre Männer plagt. Spürt den Müller auf und bringt Euch in Lichtfall in Sicherheit.",負傷した男は、彼の親方である粉屋が彼の部下たちを苦しめている疫病についてクリアフェルに警告しに行ったことを話していた。粉屋を追い、クリアフェルで安全な場所を見つけろ,다친 남자가 말하길 수석 방아꾼이 일꾼들 사이에서 돌고 있는 병에 대해 경고하기 위해 클리어펠로 향했다고 합니다. 방아꾼을 찾고 클리어펠에 피신하십시오.,O homem ferido mencionou que o Triturador foi avisar Clearfell sobre uma doença que assola seus homens. Rastreie o Triturador e fique em segurança em Clearfell.,"Раненый мужчина упомянул, что его начальник-мельник отправился в Клирфелл предупредить о болезни. Найдите мельника и безопасное убежище в Клирфелле.",El hombre herido ha mencionado que el jefe del molino ha ido a Sierraclara para advertirles sobre una enfermedad que está azotando a sus hombres. Busca al Molinero y encuentra refugio en Sierraclara.,ชายที่บาดเจ็บบอกว่าเจ้าของโรงสีออกไปเตือนเคลียร์เฟลเกี่ยวกับโรคภัยที่ระบาดไปตามคนของพวกเขา ตามหาเจ้าของโรงสีแล้วหาที่ปลอดภัยในเคลียร์เฟลเสีย,找到皆伐。,,Find Clearfell,Trouvez la Clairière,Findet Lichtfall,クリアフェルを見つけろ,클리어펠 찾기,Encontre Clearfell.,Найдите Клирфелл,Encuentra Sierraclara,ค้นหาเคลียร์เฟล,找到皆伐,[5],0,Search for the Miller and find safety in Clearfell,Cherchez le Meunier et trouvez refuge dans la Clairière,Sucht nach dem Müller und findet Sicherheit in Lichtfall.,粉屋を探し、クリアフェルで安全な場所を見つけろ,방아꾼을 찾고 클리어펠에 피신하기,Procure pelo Triturador e fique em segurança em Clearfell,Найдите мельника и безопасное убежище в Клирфелле,Busca al Molinero y encuentra refugio en Sierraclara,ตามหาเจ้าของโรงสี แล้วหาที่ปลอดภัยในเคลียร์เฟล,尋找米勒,並在皆伐尋求庇護,,[],,[],[],10,,,[],,
|
||||
7,1,0,[2929],[],Quest Complete - You have slain the Devourer and have received a reward from Renly.,Quête terminée — Vous avez tué le Dévoreur et avez reçu une récompense de la part de Renly.,Quest abgeschlossen: Ihr habt den Verschlinger getötet und von Renly eine Belohnung erhalten.,クエスト完了 - デヴァウラーを倒し、レンリーから報酬を受け取った,퀘스트 완료 - 포식자를 처치하고 렌리에게 보상을 받았습니다.,Missão Cumprida - Você matou o Devorador e recebeu uma recompensa do Renly.,Задание выполнено - Вы убили Пожирателя и получили награду от Ренли.,Misión completa - Has derrotado al Devorador y recibido una recompensa de Renly.,เควสต์เสร็จสิ้น - คุณได้สังหารตัวสวาปามและรับรางวัลจากเรนลีย์แล้ว,任務完成——你已經殺掉了吞噬獸,並且從倫利那裡取得你的任務獎勵。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
|
||||
8,1,1,[2931],[],You have slain the Devourer. Talk to Renly in Clearfell for your reward.,Vous avez tué le Dévoreur. Parlez à Renly à la Clairière pour obtenir votre récompense.,Ihr habt den Verschlinger getötet. Sprecht Renly in Lichtfall auf Eure Belohnung an.,デヴァウラーを倒した。クリアフェルのレンリーに話しかけて報酬を受け取れ,포식자를 처치했습니다. 클리어펠에 있는 렌리와 대화해서 보상을 받으십시오.,Você matou o Devorador. Fale com Renly em Clearfell para receber sua recompensa.,Вы убили Пожирателя. Поговорите с Ренли в Клирфелле по поводу награды.,Has derrotado al Devorador. Habla con Renly en Sierraclara para recibir tu recompensa.,คุณได้สังหารตัวสวาปามแล้ว พูดคุยกับเรนลีย์ภายในค่ายเคลียร์เฟลเพื่อรับรางวัล,你已殺掉吞噬獸,在皆伐與倫利交談並領取你的獎勵。,,Talk to Renly for your reward,Parlez à Renly pour obtenir votre récompense,Sprecht Renly auf Eure Belohnung an,レンリーに話しかけて報酬を受け取れ,렌리와 대화해서 보상 받기,Fale com Renly para pegar sua recompensa,Поговорите с Ренли о награде,Habla con Renly para recibir tu recompensa,พูดคุยกับเรนลีย์เพื่อรับรางวัล,與倫利交談以獲得獎勵,[8],0,Talk to Renly for your reward,Parlez à Renly pour obtenir votre récompense,Sprecht Renly auf Eure Belohnung an.,レンリーに話しかけて報酬を受け取れ,렌리와 대화해서 보상 받기,Fale com Renly para receber sua recompensa,Поговорите с Ренли о награде,Habla con Renly para recibir tu recompensa,พูดคุยกับเรนลีย์เพื่อรับรางวัล,與倫利交談以獲得獎勵,,[],,[],[],10,,,[],,
|
||||
9,1,2,[2932],[],You have cornered the Devourer. Kill it.,Vous avez acculé le Dévoreur. Tuez-le.,Ihr habt den Verschlinger aufgespürt. Tötet ihn.,デヴァウラーを追い詰めた。やつを倒せ,포식자를 막다른 길로 몰았습니다. 처치하십시오.,Você encurralou o Devorador. Mate-o.,Вы загнали Пожирателя в угол. Убейте его.,Has acorralado al Devorador. Mátalo.,คุณต้อนตัวสวาปามให้จนมุมแล้ว สังหารมันเสีย,你已將吞噬獸逼入死角。殺死牠。,,Kill the Devourer,Tuez le Dévoreur,Tötet den Verschlinger,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Mata al Devorador,สังหารตัวสวาปาม,殺死吞噬獸,[14],0,Kill the Devourer,Tuez le Dévoreur,Tötet den Verschlinger.,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Mata al Devorador,สังหารตัวสวาปาม,殺死吞噬獸,14,[],,[],[],10,,,[],,
|
||||
10,1,3,"[2925,2892]",[],You have found the Mud Burrow. Search the tunnels for the Devourer.,Vous avez trouvé la Tanière boueuse. Fouillez les tunnels à la recherche du Dévoreur.,Ihr habt die Schlammgrube gefunden. Durchsucht die Tunnel nach dem Verschlinger.,泥の巣穴を見つけたトンネルを探索しデヴァウラーを見つけろ,진흙 토굴을 찾았습니다. 굴을 수색해서 포식자를 찾으십시오.,Você encontrou a Toca Lamacenta. Procure pelo Devorador nos túneis.,"Вы нашли Грязевую нору. Обыщите туннели, чтобы найти Пожирателя.",Has encontrado el Lodazal. Registra los túneles para encontrar al Devorador.,คุณได้พบโพรงโคลนแล้ว ตามหาตัวสวาปามภายในโพรงเหล่านี้,你已找到泥沼陋居,在坑道中搜尋吞噬獸的蹤跡。,,Search the Mud Burrow,Fouillez la Tanière Boueuse,Durchsucht die Schlammgrube,泥の巣穴を探索しろ,진흙 토굴 수색하기,Procure na Toca Lamacenta,Обыщите Грязевую нору,Investiga el Lodazal,ค้นหาภายในโพรงโคลน,在泥沼陋居進行搜索,[14],0,Find the Devourer and slay it,Trouvez le Dévoreur et tuez-le,Findet den Verschlinger und tötet ihn.,デヴァウラーを見つけて倒せ,포식자를 찾아서 처치하기,Encontre o Devorador e mate-o,Найдите Пожирателя и убейте его,Encuentra al Devorador y mátalo,ตามหาและสังหารตัวสวาปาม,找出吞噬獸並加以消滅,14,[],,[],[],10,,,[],,
|
||||
11,1,4,[2925],[],The Devourer lives underground in a Mud Burrow. Find it.,Le Dévoreur vit sous terre dans une Tanière boueuse. Trouvez-la.,Der Verschlinger verweilt in einer Schlammgrube unter der Erde. Findet sie.,デヴァウラーは泥の巣穴の地下に棲んでいる。見つけ出せ,포식자는 진흙 토굴 지하에 살고 있습니다. 찾으십시오.,O Devorador vive no subterrâneo em uma Toca Lamacenta. Encontre-o.,Пожиратель живёт под землёй в Грязевой норе. Найдите её.,El Devorador vive bajo tierra en un Lodazal. Encuéntralo.,ตัวสวาปามอาศัยอยู่ใต้ดินในโพรงโคลน ตามหามันให้เจอ,吞噬獸住在地底下的泥沼陋居,想辦法找到牠。,,Find the Mud Burrow,Trouvez la Tanière boueuse,Findet die Schlammgrube,泥の巣穴を見つけろ,진흙 토굴 찾기,Encontre a Toca Lamacenta,Найдите Грязевую нору,Encuentra el Lodazal,ค้นหาโพรงโคลน,尋找泥沼陋居,[9],0,Search Clearfell to find the Mud Burrow entrance\nSlay the Devourer in its lair,Fouillez la Clairière pour trouver l'entrée de la Tanière boueuse\nTuez le Dévoreur dans son antre,Durchsucht Lichtfall nach dem Eingang zur Schlammgrube\nTötet den Verschlinger in seinem Versteck.,クリアフェルを探索し泥の巣穴の入口を見つけろ\nデヴァウラーをその巣で倒せ,클리어펠을 수색해서 진흙 토굴 입구 찾기\n소굴에 있는 포식자 처치하기,Procure em Clearfell para encontrar a entrada da Toca Lamacenta\nMate o Devorador em seu covil,Найдите на Вырубке вход в Грязевую нору\nУбейте Пожирателя в его логове,Registra Sierraclara para encontrar la entrada al Lodazal\nDerrota al Devorador en su guarida,ค้นหาทางเข้าโพรงโคลนภายในเคลียร์เฟล\nสังหารตัวสวาปามในรังของมัน,搜尋皆伐,找出泥沼陋居的入口\n在吞噬獸的巢穴擊殺吞噬獸,,[94],,[],[],10,,,[],,
|
||||
12,1,5,[2925],[],Find the Devourer in its Mud Burrow and slay it so that the Ezomytes can safely leave the walls of Clearfell once more.,Trouvez le Dévoreur dans sa Tanière boueuse et tuez-le pour que les Ézomytes puissent à nouveau quitter les murs de la Clairière en toute sécurité.,"Spürt den Verschlinger in seiner Schlammgrube auf und tötet ihn, damit die Ezomyten die Mauern von Lichtfall endlich wieder sicher verlassen können.",エゾマイト人が再び安全にクリアフェルの壁から離れることができるように、泥の巣穴でデヴァウラーを見つけて倒せ,에조미어인들이 다시 안전하게 클리어펠 밖으로 떠날 수 있도록 진흙 토굴에 있는 포식자를 찾아 처치하십시오.,Encontre o Devorador em sua Toca Lamacenta e mate-o para que os Ezomitas possam sair de Clearfell em segurança.,"Найдите Пожирателя в его Грязевой норе и убейте его, чтобы эзомиты могли вновь без опаски выходить за стены Клирфелла.",Encuentra al Devorador en el Lodazal y mátalo para que los ezomitas puedan salir con seguridad de los muros de Sierraclara.,ตามหาและสังหารตัวสวาปามภายในโพรงโคลน เพื่อให้เหล่าเอโซไมต์ได้ออกจากเคลียร์เฟลอย่างปลอดภัยอีกครั้ง,在泥沼陋居中找到吞噬獸並加以擊殺,讓艾茲麥人能再次安全地踏出皆伐城牆的保護範圍。,,Slay the Devourer,Tuez le Dévoreur,Tötet den Verschlinger,デヴァウラーを倒せ,포식자 처치하기,Mate o Devorador,Убейте Пожирателя,Derrota al Devorador,สังหารตัวสวาปาม,擊殺吞噬獸,[9],0,Search Clearfell for the entrance to the Mud Burrow\nSlay the Devourer in its lair,Fouillez la Clairière à la recherche de l'entrée de la Tanière boueuse\nTuez le Dévoreur dans son antre,Sucht in Lichtfall nach dem Eingang zur Schlammgrube\nTötet den Verschlinger in seinem Versteck.,クリアフェルを探索し泥の巣穴の入口を見つけろ\nデヴァウラーをその巣で倒せ,클리어펠을 수색해서 진흙 토굴 입구 찾기\n소굴에 있는 포식자 처치하기,Procure em Clearfell para encontrar a entrada da Toca Lamacenta\nMate o Devorador em seu covil,Найдите на Вырубке вход в Грязевую нору\nУбейте Пожирателя в его логове,Registra Sierraclara para encontrar la entrada al Lodazal\nDerrota al Devorador en su guarida,ค้นหาทางเข้าโพรงโคลนภายในเคลียร์เฟล\nสังหารตัวสวาปามในรังของมัน,在伐木場尋找泥沼陋居的入口\n在吞噬獸的巢穴擊殺吞噬獸,,[],,[],[],10,,,[],,
|
||||
13,2,0,[4701],[],Quest Complete - You have released a dark entity called the Hooded One from the Tree of Souls.,Quête terminée — Vous avez libéré de l'Arbre des âmes une entité sombre appelée l'Encapuchonné.,Quest abgeschlossen: Ihr habt ein dunkles Wesen namens der Verhüllte aus dem Baum der Seelen befreit.,クエスト完了 - フードをかぶった者と呼ばれる闇の存在を魂の木から解放した,퀘스트 완료 - 영혼의 나무에서 두건 쓴 자라는 어둠의 존재를 풀어줬습니다.,"Missão Concluída - Você libertou uma entidade sombria da Árvore das Almas chamada O Encapuzado ",Задание выполнено - Вы освободили тёмную сущность по имений Скрытный от Дерева Душ.,Misión completa - Has liberado a una entidad oscura llamada el Encapuchado del Árbol de las almas.,เควสต์เสร็จสิ้น - คุณได้ปลดปล่อยบุคคลมืดมนที่มีชื่อว่าผู้คลุมกายออกมาจากต้นตรึงวิญญาณแล้ว,任務完成——你從攝魂之樹釋放出一個名為黑衣幽魂的黑暗生物。,1,Quest Complete,Quête terminée,Quest abgeschlossen,クエスト完了,퀘스트 완료,Missão Concluída,Задание выполнено,Misión completa,เควสต์เสร็จสิ้น,任務完成,[],0,,,,,,,,,,,,[],,[],[],10,,,[],,
|
||||
14,2,1,[4696],[],You have released a dark entity called the Hooded One from the Tree of Souls. Return to Clearfell Encampment and speak to Una.,Vous avez libéré de l'Arbre des âmes une entité sombre appelée l'Encapuchonné. Retournez au Campement de la Clairière et parlez à Una.,Ihr habt ein dunkles Wesen namens der Verhüllte aus dem Baum der Seelen befreit. Kehrt zum Lichtfall-Lager zurück und sprecht mit Una.,フードをかぶった者と呼ばれる闇の存在を魂の木から解放した。クリアフェルの野営地に戻りウーナと話せ,영혼의 나무에서 두건 쓴 자라는 어둠의 존재를 풀어줬습니다. 클리어펠 야영지로 돌아가서 우나와 대화하십시오.,Você libertou uma entidade sombria da Árvore das Almas chamada O Encapuzado. Volte ao Acampamento Clearfell e fale com a Una.,Вы освободили тёмную сущность по имений Скрытный от Дерева Душ. Вернитесь в Лагерь Клирфелл и поговорите с Уной.,Has liberado a una entidad oscura llamada el Encapuchado en el Árbol de las almas. Regresa al Campamento de Sierraclara y habla con Una.,คุณได้ปลดปล่อยบุคคลมืดมนที่มีชื่อว่าผู้คลุมกายออกมาจากต้นตรึงวิญญาณแล้ว กลับไปพูดคุยกับอูน่าที่ค่ายเคลียร์เฟล,你從攝魂之樹釋放出一個名為黑衣幽魂的黑暗生物。返回皆伐營地與烏娜交談。,,Meet Una in Clearfell,Retrouvez Una à la Clairière,Trefft Una in Lichtfall,クリアフェルでウーナに会え,클리어펠에 있는 우나 만나기,Encontre Una em Clearfell,Встретьтесь с Уной в Клирфелле,Reúnete con Una en Sierraclara,ไปพบกับอูน่าในเคลียร์เฟล,在皆伐與烏娜碰面,[8],0,Return to Clearfell Encampment and speak to Una,Retournez au Campement de la Clairière et parlez à Una,Kehrt zum Lichtfall-Lager zurück und sprecht mit Una.,クリアフェルの野営地に戻りウーナと話せ,클리어펠 야영지로 돌아가서 우나와 대화하기,Volte ao Acampamento Clearfell e fale com Una,Вернитесь в Лагерь Клирфелл и поговорите с Уной,Regresa al Campamento de Sierraclara y habla con Una,กลับไปยังค่ายเคลียร์เฟลแล้วพูดคุยกับอูน่า,返回皆伐營地與烏娜交談,,[],,[],[],10,,,[],,
|
||||
38,2,25,[2894],[2943],This vale is littered with the debris of countless battles. Search it for anything that may still be useful.,Cette vallée est jonchée des débris d'innombrables batailles. Cherchez-y tout ce qui pourrait encore être utile.,"\r\nDieses Tal ist mit den Trümmern unzähliger Schlachten übersät. Durchsucht es nach allem, was noch nützlich sein könnte.",この谷には数え切れない戦いの残骸が散らばっている。まだ役に立ちそうなものを探せ,이 계곡에는 헤아릴 수 없이 많은 전투의 흔적이 흩어져 있습니다. 잔해를 수색해서 아직 쓸 수 있는 걸 뭐든 찾아내십시오.,Este vale é coberto por detritos de inúmeras batalhas. Procure coisas que possam ser úteis,"Эта долина щетинится останками бесчисленных битв. Обыщите её на предмет того, что ещё может быть полезно.",El valle está lleno de deshechos de innumerables batallas. Regístralo en busca de objetos que puedan resultar útiles.,ห้วยนี้เกลื่อนกลาดไปด้วยซากของศึกเหนือคณานับ ค้นหาสิ่งที่ยังพอมีประโยชน์ภายในนี้,殘餘的魔法能量還在赤谷中縈繞。調查鐵鏽方尖碑,尋找有關力量魔符的情報。,,Search the Red Vale,Inspectez la Vallée rouge,Durchsucht das Rote Tal,赤き谷を探索しろ,붉은 계곡 수색하기,Procure no Vale Vermelho,Обыщите Красную Долину,Registra el Valle rojo,ค้นหาภายในห้วยสีชาด,調查鐵鏽方尖碑,[27],0,Search the Red Vale for Obelisks of Rust containing Runes of Power,Cherchez dans la Vallée rouge les Obélisques de Rouille qui contiennent les Runes de pouvoir,Durchsucht das Rote Tal nach Obelisken aus Rost,die Runen der Macht enthalten.,赤き谷で力のルーンが含まれる錆びたオベリスクを探せ,붉은 계곡을 수색해서 힘의 룬을 지닌 녹의 오벨리스크 찾기,Procure no Vale Vermelho por Obeliscos da Ferrugem que contenham Runas de Poder,Отыщите в Красной Долине ржавые обелиски с Рунами силы,Registra el Valle rojo para encontrar los obeliscos oxidados que contienen las runas de poder,ค้นหาเสาหินสนิมที่มีอักขระแห่งพลังภายในห้วยสีชาด,調查鐵鏽方尖碑\n收集力量魔符,27,[],,[],[],10,,,"[2974,2975,2976]"
|
||||
|
Can't render this file because it has a wrong number of fields in line 17.
|
Loading…
Add table
Add a link
Reference in a new issue