started items

This commit is contained in:
Boki 2026-03-06 09:59:16 -05:00
parent d124f2c288
commit 4424f4c3a8
12 changed files with 698 additions and 12 deletions

View file

@ -39,7 +39,6 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
// Compute delta between fresh player position (60Hz) and snapshot position (10Hz) // Compute delta between fresh player position (60Hz) and snapshot position (10Hz)
// Entities near the snapshot player get shifted by this delta to compensate for movement // Entities near the snapshot player get shifted by this delta to compensate for movement
var freshPos = cache.PlayerPosition; var freshPos = cache.PlayerPosition;
var playerZ = freshPos.HasPosition ? freshPos.Z : data.SnapshotPlayerZ;
var dx = freshPos.HasPosition ? freshPos.X - data.SnapshotPlayerPosition.X : 0f; var dx = freshPos.HasPosition ? freshPos.X - data.SnapshotPlayerPosition.X : 0f;
var dy = freshPos.HasPosition ? freshPos.Y - data.SnapshotPlayerPosition.Y : 0f; var dy = freshPos.HasPosition ? freshPos.Y - data.SnapshotPlayerPosition.Y : 0f;
var snapshotPx = data.SnapshotPlayerPosition.X; var snapshotPx = data.SnapshotPlayerPosition.X;
@ -57,8 +56,8 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
ey += dy; ey += dy;
} }
// WorldToScreen using camera's view-projection matrix // WorldToScreen using camera's view-projection matrix with per-entity Z + label offset
var worldPos = new Vector4(ex, ey, playerZ, 1f); var worldPos = new Vector4(ex, ey, entry.Z + entry.LabelOffset, 1f);
var clip = Vector4.Transform(worldPos, mat); var clip = Vector4.Transform(worldPos, mat);
// Perspective divide // Perspective divide
@ -86,7 +85,7 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2), new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2),
ctx.LabelBgBrush); ctx.LabelBgBrush);
var brush = entry.Rarity switch var brush = entry.IsQuestItem ? ctx.Green : entry.Rarity switch
{ {
1 => ctx.MagicBrush, 1 => ctx.MagicBrush,
2 => ctx.RareBrush, 2 => ctx.RareBrush,

View file

@ -918,9 +918,10 @@ public partial class MemoryViewModel : ObservableObject
foreach (var e in snap.Entities) foreach (var e in snap.Entities)
{ {
if (!e.HasPosition) continue; if (!e.HasPosition) continue;
if (e.Type is not (EntityType.Player or EntityType.Monster or EntityType.Npc)) continue; if (e.Type is not (EntityType.Player or EntityType.Monster or EntityType.Npc or EntityType.WorldItem)) continue;
if (e.Address == snap.LocalPlayerPtr) continue; if (e.Address == snap.LocalPlayerPtr) continue;
if (e.Type == EntityType.Monster && e.IsDead) continue; if (e.Type == EntityType.Monster && e.IsDead) continue;
if (e.Type == EntityType.WorldItem && !e.IsQuestItem) continue;
// Entity position relative to player in grid coords // Entity position relative to player in grid coords
var dx = e.X * worldToGrid - pgx; var dx = e.X * worldToGrid - pgx;
@ -940,6 +941,8 @@ public partial class MemoryViewModel : ObservableObject
b = 0x00; g = 0x8C; r = 0xFF; break; b = 0x00; g = 0x8C; r = 0xFF; break;
case EntityType.Monster: // red #FF4444 case EntityType.Monster: // red #FF4444
b = 0x44; g = 0x44; r = 0xFF; break; b = 0x44; g = 0x44; r = 0xFF; break;
case EntityType.WorldItem: // quest items — green #3FB950
b = 0x50; g = 0xB9; r = 0x3F; break;
default: continue; default: continue;
} }
@ -1033,7 +1036,7 @@ public partial class MemoryViewModel : ObservableObject
var e = sorted[i]; var e = sorted[i];
var label = FormatEntityName(e); var label = FormatEntityName(e);
var value = FormatEntityValue(e); var value = FormatEntityValue(e);
var color = RarityColor(e.Rarity); var color = e.IsQuestItem ? "#3fb950" : RarityColor(e.Rarity);
if (i < groupNode.Children.Count) if (i < groupNode.Children.Count)
{ {
@ -1608,4 +1611,28 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.Diagnostics!.ProbeQuestAddresses(addresses, 4); ScanResult = _reader.Diagnostics!.ProbeQuestAddresses(addresses, 4);
} }
[RelayCommand]
private void ScanWorldItemExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanWorldItemComponents();
}
[RelayCommand]
private void ScanWorldItemLabelsExecute()
{
if (_reader is null || !_reader.IsAttached)
{
ScanResult = "Error: not attached";
return;
}
ScanResult = _reader.Diagnostics!.ScanWorldItemLabelOffsets();
}
} }

View file

@ -28,16 +28,21 @@ public sealed class EntityOverlayData
public readonly struct EntityOverlayEntry public readonly struct EntityOverlayEntry
{ {
public readonly float X, Y; public readonly float X, Y, Z;
public readonly float LabelOffset;
public readonly string Label; public readonly string Label;
public readonly int Rarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique public readonly int Rarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique
public readonly bool IsQuestItem;
public EntityOverlayEntry(float x, float y, string label, int rarity = 0) public EntityOverlayEntry(float x, float y, float z, string label, int rarity = 0, bool isQuestItem = false, float labelOffset = 0f)
{ {
X = x; X = x;
Y = y; Y = y;
Z = z;
LabelOffset = labelOffset;
Label = label; Label = label;
Rarity = rarity; Rarity = rarity;
IsQuestItem = isQuestItem;
} }
} }
@ -52,11 +57,14 @@ public partial class EntityListItem : ObservableObject
public string Distance { get; set; } public string Distance { get; set; }
public float X { get; set; } public float X { get; set; }
public float Y { get; set; } public float Y { get; set; }
public float Z { get; set; }
public float LabelOffset { get; }
public int Rarity { get; } public int Rarity { get; }
public bool IsQuestItem { get; }
[ObservableProperty] private bool _isChecked; [ObservableProperty] private bool _isChecked;
public EntityListItem(uint id, string label, string category, float distance, float x, float y, int rarity = 0) public EntityListItem(uint id, string label, string category, float distance, float x, float y, float z, int rarity = 0, bool isQuestItem = false, float labelOffset = 0f)
{ {
Id = id; Id = id;
Label = label; Label = label;
@ -64,7 +72,10 @@ public partial class EntityListItem : ObservableObject
Distance = $"{distance:F0}"; Distance = $"{distance:F0}";
X = x; X = x;
Y = y; Y = y;
Z = z;
LabelOffset = labelOffset;
Rarity = rarity; Rarity = rarity;
IsQuestItem = isQuestItem;
} }
} }
@ -541,7 +552,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null
? $"AreaTransition — {e.TransitionName}" ? $"AreaTransition — {e.TransitionName}"
: GetShortLabel(e.Path); : GetShortLabel(e.Path);
var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y, (int)e.Rarity); var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y, e.Z, (int)e.Rarity, e.IsQuestItem, e.LabelOffset);
if (checkedIds.Contains(e.Id)) if (checkedIds.Contains(e.Id))
item.IsChecked = true; item.IsChecked = true;
Entities.Add(item); Entities.Add(item);
@ -553,7 +564,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
foreach (var item in Entities) foreach (var item in Entities)
{ {
if (!showAll && !item.IsChecked) continue; if (!showAll && !item.IsChecked) continue;
overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label, item.Rarity)); overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Z, item.Label, item.Rarity, item.IsQuestItem, item.LabelOffset));
} }
if (overlayEntries.Count > 0) if (overlayEntries.Count > 0)

View file

@ -802,6 +802,10 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Probe Quest Addr" Command="{Binding ProbeQuestAddressesExecuteCommand}" <Button Content="Probe Quest Addr" Command="{Binding ProbeQuestAddressesExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" /> Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan WorldItem" Command="{Binding ScanWorldItemExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
<Button Content="Scan WI Labels" Command="{Binding ScanWorldItemLabelsExecuteCommand}"
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
</WrapPanel> </WrapPanel>
<TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas" <TextBox Text="{Binding ScanResult, Mode=OneWay}" FontFamily="Consolas"
FontSize="10" Foreground="#e6edf3" Background="#0d1117" FontSize="10" Foreground="#e6edf3" Background="#0d1117"

View file

@ -47,6 +47,7 @@ public record EntitySnapshot
public EntityCategory Category { get; init; } public EntityCategory Category { get; init; }
public MonsterThreatLevel ThreatLevel { get; init; } public MonsterThreatLevel ThreatLevel { get; init; }
public Vector2 Position { get; init; } public Vector2 Position { get; init; }
public float Z { get; init; }
public float DistanceToPlayer { get; init; } public float DistanceToPlayer { get; init; }
public bool IsAlive { get; init; } public bool IsAlive { get; init; }
public int LifeCurrent { get; init; } public int LifeCurrent { get; init; }
@ -60,6 +61,9 @@ public record EntitySnapshot
public string? TransitionName { get; init; } public string? TransitionName { get; init; }
public int TransitionState { get; init; } = -1; public int TransitionState { get; init; } = -1;
public string? Metadata { get; init; } public string? Metadata { get; init; }
public string? ItemBaseName { get; init; }
public bool IsQuestItem { get; init; }
public float LabelOffset { get; init; }
// Action state (from Actor component) // Action state (from Actor component)
public short ActionId { get; init; } public short ActionId { get; init; }

View file

@ -25,6 +25,7 @@ public static class EntityMapper
ThreatLevel = MapThreatLevel(category, rarity), ThreatLevel = MapThreatLevel(category, rarity),
Rarity = rarity, Rarity = rarity,
Position = pos, Position = pos,
Z = e.Z,
DistanceToPlayer = dist, DistanceToPlayer = dist,
IsAlive = e.IsAlive || !e.HasVitals, IsAlive = e.IsAlive || !e.HasVitals,
LifeCurrent = e.LifeCurrent, LifeCurrent = e.LifeCurrent,
@ -37,6 +38,9 @@ public static class EntityMapper
ActionId = e.ActionId, ActionId = e.ActionId,
IsAttacking = e.IsAttacking, IsAttacking = e.IsAttacking,
IsMoving = e.IsMoving, IsMoving = e.IsMoving,
ItemBaseName = e.ItemBaseName,
IsQuestItem = e.IsQuestItem,
LabelOffset = e.LabelOffset,
}; };
} }

View file

@ -4,7 +4,7 @@ using Roboto.GameOffsets.Natives;
namespace Roboto.GameOffsets.Components; namespace Roboto.GameOffsets.Components;
/// <summary>Render component — world position, bounds, terrain height.</summary> /// <summary>Render component — world position, bounds, terrain height.</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x148)] [StructLayout(LayoutKind.Explicit, Size = 0x1B0)]
public struct Render public struct Render
{ {
[FieldOffset(0x00)] public ComponentHeader Header; [FieldOffset(0x00)] public ComponentHeader Header;
@ -20,4 +20,11 @@ public struct Render
/// <summary>Grid position (float x, y, z). Confirmed offsets: X=0x138, Y=0x13C, Z=0x140.</summary> /// <summary>Grid position (float x, y, z). Confirmed offsets: X=0x138, Y=0x13C, Z=0x140.</summary>
[FieldOffset(0x138)] public StdTuple3D<float> Position; [FieldOffset(0x138)] public StdTuple3D<float> Position;
/// <summary>Bounding box size (float x, y). +0x144, +0x148.</summary>
[FieldOffset(0x144)] public float BoundsX;
[FieldOffset(0x148)] public float BoundsY;
/// <summary>Label height offset — constant 18.0 for items. Used to raise nameplate above ground.</summary>
[FieldOffset(0x1AC)] public float LabelHeightOffset;
} }

View file

@ -0,0 +1,13 @@
using System.Runtime.InteropServices;
namespace Roboto.GameOffsets.Components;
/// <summary>WorldItem component — contains pointer to inner item entity at +0x28 (TBD from diagnostic).</summary>
[StructLayout(LayoutKind.Explicit, Size = 0x30)]
public struct WorldItem
{
[FieldOffset(0x00)] public ComponentHeader Header;
/// <summary>Pointer to the inner item entity (has its own component list with Mods, Base, etc.).</summary>
[FieldOffset(0x28)] public nint ItemEntityPtr;
}

View file

@ -7439,4 +7439,547 @@ public sealed class MemoryDiagnostics
sb.AppendLine($" Total: {count} entries"); sb.AppendLine($" Total: {count} entries");
} }
/// <summary>
/// Diagnostic: find WorldItem entities (ground loot), dump the WorldItem component,
/// and probe for an inner entity pointer that carries Mods/Base components.
/// </summary>
public string ScanWorldItemComponents()
{
if (_ctx.Memory is null) return "Error: not attached";
var sb = new StringBuilder();
var inGameState = _stateReader.ResolveInGameState(new GameStateSnapshot());
if (inGameState == 0) return "Error: can't resolve InGameState";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: can't resolve AreaInstance";
var sentinel = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
if (sentinel == 0) return "Error: can't resolve entity list sentinel";
var root = _ctx.Memory.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
// Walk tree, find entities with WorldItem component (ground loot)
// Path is Metadata/MiscellaneousObjects/WorldItem, NOT Metadata/Items/
var worldItems = new List<(nint entity, string path)>();
_entities.WalkTreeInOrder(sentinel, root, 500, node =>
{
var entityPtr = _ctx.Memory.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
if (entityPtr == 0) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
var path = _entities.TryReadEntityPath(entityPtr);
if (path is not null && path.Contains("WorldItem"))
worldItems.Add((entityPtr, path));
});
sb.AppendLine($"Found {worldItems.Count} WorldItem entities");
if (worldItems.Count == 0)
return sb.Append("No items on ground. Drop some items and retry.").ToString();
// Process up to 3 items
var limit = Math.Min(worldItems.Count, 3);
for (var i = 0; i < limit; i++)
{
var (entityPtr, path) = worldItems[i];
var shortName = path[(path.LastIndexOf('/') + 1)..];
sb.AppendLine($"\n═══ WorldItem[{i}]: {shortName} (0x{entityPtr:X}) ═══");
sb.AppendLine($" Path: {path}");
// Read component lookup for the ground entity
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is null)
{
sb.AppendLine(" ERROR: can't read component lookup");
continue;
}
sb.AppendLine($" Components: {string.Join(", ", lookup.Keys.OrderBy(k => k))}");
if (!lookup.TryGetValue("WorldItem", out var wiIdx))
{
sb.AppendLine(" ERROR: no 'WorldItem' component in lookup");
continue;
}
// Get component pointer array
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (compCount <= 0 || wiIdx < 0 || wiIdx >= compCount)
{
sb.AppendLine($" ERROR: WorldItem index {wiIdx} out of range (count={compCount})");
continue;
}
var wiCompPtr = _ctx.Memory.ReadPointer(compFirst + wiIdx * 8);
if (wiCompPtr == 0)
{
sb.AppendLine(" ERROR: WorldItem component pointer is null");
continue;
}
sb.AppendLine($" WorldItem component: 0x{wiCompPtr:X}");
// Dump first 0x80 bytes of the WorldItem component
const int dumpSize = 0x80;
var dump = _ctx.Memory.ReadBytes(wiCompPtr, dumpSize);
if (dump is null)
{
sb.AppendLine(" ERROR: can't read WorldItem component data");
continue;
}
sb.AppendLine($" Hex dump (0x{dumpSize:X} bytes):");
for (var off = 0; off < dump.Length; off += 16)
{
sb.Append($" +0x{off:X2}: ");
var end = Math.Min(off + 16, dump.Length);
for (var b = off; b < end; b++)
sb.Append($"{dump[b]:X2} ");
sb.AppendLine();
}
// Probe each 8-byte-aligned field for entity-like pointers
sb.AppendLine(" Probing for inner entity pointers:");
var foundInner = false;
for (var off = 0x10; off + 8 <= dump.Length; off += 8)
{
var candidate = (nint)BitConverter.ToInt64(dump, off);
if (candidate == 0) continue;
var cHigh = (ulong)candidate >> 32;
if (cHigh == 0 || cHigh >= 0x7FFF) continue;
if ((candidate & 0x7) != 0) continue;
// Check if it looks like an entity: has EntityDetails at +0x08
var details = _ctx.Memory.ReadPointer(candidate + _ctx.Offsets.EntityDetailsOffset);
if (details == 0) continue;
var dHigh = (ulong)details >> 32;
if (dHigh == 0 || dHigh >= 0x7FFF) continue;
// Check if it has a component list at +0x10
var innerCompList = _components.FindComponentList(candidate);
if (innerCompList.Count <= 0) continue;
// Try to read inner entity path
var innerPath = _entities.TryReadEntityPath(candidate);
sb.AppendLine($" +0x{off:X2}: 0x{candidate:X} → ENTITY (details=0x{details:X}, components={innerCompList.Count})");
if (innerPath is not null)
sb.AppendLine($" Inner path: {innerPath}");
// Read inner entity's component lookup
var innerLookup = _components.ReadComponentLookup(candidate);
if (innerLookup is not null)
{
sb.AppendLine($" Inner components: {string.Join(", ", innerLookup.Keys.OrderBy(k => k))}");
// Try to read rarity from inner entity's Mods component
if (innerLookup.TryGetValue("Mods", out var modsIdx))
{
var (iFirst, iCount) = innerCompList;
if (modsIdx >= 0 && modsIdx < iCount)
{
var modsPtr = _ctx.Memory.ReadPointer(iFirst + modsIdx * 8);
if (modsPtr != 0)
{
var mods = _ctx.Memory.Read<Roboto.GameOffsets.Components.Mods>(modsPtr);
sb.AppendLine($" Mods.Rarity: {mods.Rarity}");
}
}
}
}
// Dump RenderItem from inner entity — looking for screen-space label position
if (innerLookup is not null && innerLookup.TryGetValue("RenderItem", out var riIdx))
{
var (iFirst2, iCount2) = innerCompList;
if (riIdx >= 0 && riIdx < iCount2)
{
var riPtr = _ctx.Memory.ReadPointer(iFirst2 + riIdx * 8);
if (riPtr != 0)
{
sb.AppendLine($" ── RenderItem: 0x{riPtr:X} ──");
var riDump = _ctx.Memory.ReadBytes(riPtr, 0x300);
if (riDump is not null)
{
for (var riOff = 0; riOff < riDump.Length; riOff += 16)
{
sb.Append($" +0x{riOff:X3}: ");
var riEnd = Math.Min(riOff + 16, riDump.Length);
for (var b2 = riOff; b2 < riEnd; b2++)
sb.Append($"{riDump[b2]:X2} ");
sb.Append(" | ");
for (var fi = 0; fi < 4; fi++)
{
if (riOff + fi * 4 + 4 > riDump.Length) break;
var f = BitConverter.ToSingle(riDump, riOff + fi * 4);
if (MathF.Abs(f) > 0.001f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
sb.Append($"f{fi}={f:F2} ");
}
sb.AppendLine();
}
// Follow pointers in RenderItem, look for screen coords
sb.AppendLine(" ── Pointer probes ──");
for (var pOff = 0; pOff + 8 <= riDump.Length; pOff += 8)
{
var ptr = (nint)BitConverter.ToInt64(riDump, pOff);
if (ptr == 0) continue;
var pH = (ulong)ptr >> 32;
if (pH == 0 || pH >= 0x7FFF) continue;
if ((ptr & 0x3) != 0) continue;
if (_ctx.IsModuleAddress(ptr)) continue;
var sub = _ctx.Memory.ReadBytes(ptr, 0x40);
if (sub is null) continue;
// Check for screen-like floats (0-2560 X, 0-1440 Y)
var hasScreenCoord = false;
for (var si = 0; si + 8 <= sub.Length; si += 4)
{
var fx = BitConverter.ToSingle(sub, si);
if (fx > 100 && fx < 2560)
{
var fy = (si + 4 < sub.Length) ? BitConverter.ToSingle(sub, si + 4) : 0f;
if (fy > 50 && fy < 1440)
{
hasScreenCoord = true;
sb.AppendLine($" RI+0x{pOff:X3} → 0x{ptr:X} sub+0x{si:X2}: screen? ({fx:F1}, {fy:F1})");
}
}
}
if (!hasScreenCoord)
{
// Just dump first 0x40 bytes with floats
sb.Append($" RI+0x{pOff:X3} → 0x{ptr:X}: ");
for (var si = 0; si < Math.Min(32, sub.Length); si++)
sb.Append($"{sub[si]:X2} ");
sb.Append(" | ");
for (var fi = 0; fi < 8; fi++)
{
if (fi * 4 + 4 > sub.Length) break;
var f = BitConverter.ToSingle(sub, fi * 4);
if (MathF.Abs(f) > 0.001f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
sb.Append($"f{fi}={f:F1} ");
}
sb.AppendLine();
}
}
}
}
}
}
// Full dump of outer entity's Render component (0x200 bytes) with float annotations
if (lookup.TryGetValue("Render", out var outerRenderIdx) && outerRenderIdx >= 0 && outerRenderIdx < compCount)
{
var outerRenderPtr = _ctx.Memory.ReadPointer(compFirst + outerRenderIdx * 8);
if (outerRenderPtr != 0)
{
sb.AppendLine($" ── Outer Render: 0x{outerRenderPtr:X} ──");
var rDump = _ctx.Memory.ReadBytes(outerRenderPtr, 0x200);
if (rDump is not null)
{
for (var rOff = 0; rOff < rDump.Length; rOff += 16)
{
sb.Append($" +0x{rOff:X3}: ");
var rEnd = Math.Min(rOff + 16, rDump.Length);
for (var b = rOff; b < rEnd; b++)
sb.Append($"{rDump[b]:X2} ");
sb.Append(" | ");
for (var fi = 0; fi < 4; fi++)
{
var f = BitConverter.ToSingle(rDump, rOff + fi * 4);
if (MathF.Abs(f) > 0.001f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
sb.Append($"f{fi}={f:F2} ");
}
sb.AppendLine();
}
}
}
}
foundInner = true;
break; // Found the inner entity, no need to probe further
}
if (!foundInner)
sb.AppendLine(" No inner entity pointer found in first 0x80 bytes");
}
return sb.ToString();
}
/// <summary>
/// Diagnostic: Compare Render/Positioned components of WorldItem entities near each other
/// to find per-entity offsets the game uses for label de-overlap.
/// Diffs component bytes between items at the same location to isolate label offset fields.
/// </summary>
public string ScanWorldItemLabelOffsets()
{
if (_ctx.Memory is null) return "Error: not attached";
var sb = new StringBuilder();
var mem = _ctx.Memory;
var inGameState = _stateReader.ResolveInGameState(new GameStateSnapshot());
if (inGameState == 0) return "Error: can't resolve InGameState";
var ingameData = mem.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: can't resolve AreaInstance";
var sentinel = mem.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
if (sentinel == 0) return "Error: can't resolve entity list sentinel";
var root = mem.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
// Collect all WorldItem entities with their positions
var worldItems = new List<(nint entity, string path, float x, float y, float z)>();
_entities.WalkTreeInOrder(sentinel, root, 500, node =>
{
var entityPtr = mem.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
if (entityPtr == 0) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
var path = _entities.TryReadEntityPath(entityPtr);
if (path is null || !path.Contains("WorldItem")) return;
// Read position from Render +0x138
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is null) return;
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (compCount <= 0) return;
if (lookup.TryGetValue("Render", out var renderIdx) && renderIdx >= 0 && renderIdx < compCount)
{
var renderPtr = mem.ReadPointer(compFirst + renderIdx * 8);
if (renderPtr != 0)
{
var posX = mem.Read<float>(renderPtr + 0x138);
var posY = mem.Read<float>(renderPtr + 0x13C);
var posZ = mem.Read<float>(renderPtr + 0x140);
if (!float.IsNaN(posX) && MathF.Abs(posX) < 100000)
worldItems.Add((entityPtr, path, posX, posY, posZ));
}
}
});
sb.AppendLine($"Found {worldItems.Count} WorldItem entities with positions");
if (worldItems.Count < 2)
return sb.Append("Need 2+ items near each other. Drop items in a pile and retry.").ToString();
// Group by proximity — items within 5 world units of each other
var groups = new List<List<int>>();
var assigned = new HashSet<int>();
for (var i = 0; i < worldItems.Count; i++)
{
if (assigned.Contains(i)) continue;
var group = new List<int> { i };
assigned.Add(i);
for (var j = i + 1; j < worldItems.Count; j++)
{
if (assigned.Contains(j)) continue;
var dx = MathF.Abs(worldItems[i].x - worldItems[j].x);
var dy = MathF.Abs(worldItems[i].y - worldItems[j].y);
if (dx + dy < 5f)
{
group.Add(j);
assigned.Add(j);
}
}
groups.Add(group);
}
// Show all items
sb.AppendLine($"\n── All WorldItems ──");
for (var i = 0; i < worldItems.Count; i++)
{
var (e, p, x, y, z) = worldItems[i];
var name = p[(p.LastIndexOf('/') + 1)..];
sb.AppendLine($" [{i}] {name} pos=({x:F2}, {y:F2}, {z:F2}) entity=0x{e:X}");
}
// For each group with 2+ items, do component diff
const int renderDumpSize = 0x400; // scan beyond 0x200 to find more fields
foreach (var group in groups.Where(g => g.Count >= 2))
{
sb.AppendLine($"\n═══ Cluster of {group.Count} items (within 5 units) ═══");
// Read Render dumps for all items in group
var renders = new List<(int idx, nint renderPtr, byte[] data)>();
foreach (var gi in group)
{
var (entityPtr, _, _, _, _) = worldItems[gi];
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is null) continue;
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (compCount <= 0) continue;
if (lookup.TryGetValue("Render", out var renderIdx) && renderIdx >= 0 && renderIdx < compCount)
{
var renderPtr = mem.ReadPointer(compFirst + renderIdx * 8);
if (renderPtr != 0)
{
var data = mem.ReadBytes(renderPtr, renderDumpSize);
if (data is { Length: > 0 })
renders.Add((gi, renderPtr, data));
}
}
}
if (renders.Count < 2) continue;
// Diff: find offsets where values differ between items
sb.AppendLine($" Diffing Render components ({renders.Count} items, 0x{renderDumpSize:X} bytes each)");
var minLen = renders.Min(r => r.data.Length);
var diffOffsets = new List<int>();
for (var off = 0; off < minLen; off++)
{
var val = renders[0].data[off];
var differs = false;
for (var ri = 1; ri < renders.Count; ri++)
{
if (renders[ri].data[off] != val)
{
differs = true;
break;
}
}
if (differs) diffOffsets.Add(off);
}
sb.AppendLine($" {diffOffsets.Count} bytes differ");
// Group consecutive diff bytes into ranges and show as floats
var ranges = new List<(int start, int end)>();
if (diffOffsets.Count > 0)
{
var rangeStart = diffOffsets[0];
var rangeEnd = diffOffsets[0];
for (var di = 1; di < diffOffsets.Count; di++)
{
if (diffOffsets[di] == rangeEnd + 1)
rangeEnd = diffOffsets[di];
else
{
ranges.Add((rangeStart, rangeEnd));
rangeStart = diffOffsets[di];
rangeEnd = diffOffsets[di];
}
}
ranges.Add((rangeStart, rangeEnd));
}
sb.AppendLine($" Diff ranges ({ranges.Count}):");
foreach (var (start, end) in ranges)
{
var len = end - start + 1;
// Align to 4 bytes for float display
var alignedStart = start & ~3;
var alignedEnd = ((end + 4) & ~3);
sb.Append($" +0x{start:X3}..+0x{end:X3} ({len}B): ");
// Show values from each item as floats at aligned offsets
for (var ri = 0; ri < renders.Count; ri++)
{
var (idx, _, data) = renders[ri];
sb.Append($"item[{idx}]=(");
for (var fOff = alignedStart; fOff < alignedEnd && fOff + 4 <= data.Length; fOff += 4)
{
var f = BitConverter.ToSingle(data, fOff);
var intVal = BitConverter.ToInt32(data, fOff);
if (MathF.Abs(f) > 0.001f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
sb.Append($"+0x{fOff:X3}={f:F3} ");
else
sb.Append($"+0x{fOff:X3}=0x{intVal:X8} ");
}
sb.Append(") ");
}
sb.AppendLine();
}
// Also show exact positions side by side
sb.AppendLine(" Positions:");
foreach (var (idx, _, data) in renders)
{
if (data.Length >= 0x148)
{
var px = BitConverter.ToSingle(data, 0x138);
var py = BitConverter.ToSingle(data, 0x13C);
var pz = BitConverter.ToSingle(data, 0x140);
var bx = BitConverter.ToSingle(data, 0x144);
var by = BitConverter.ToSingle(data, 0x148);
var lbl = data.Length >= 0x1B0 ? BitConverter.ToSingle(data, 0x1AC) : 0;
sb.AppendLine($" item[{idx}]: pos=({px:F4}, {py:F4}, {pz:F4}) bounds=({bx:F2}, {by:F2}) lbl={lbl:F2}");
}
}
// Also scan Positioned component if present
foreach (var gi in group.Take(2))
{
var (entityPtr, _, _, _, _) = worldItems[gi];
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is null) continue;
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (lookup.TryGetValue("Positioned", out var posIdx) && posIdx >= 0 && posIdx < compCount)
{
var posPtr = mem.ReadPointer(compFirst + posIdx * 8);
if (posPtr != 0)
{
sb.AppendLine($" Positioned[{gi}]: 0x{posPtr:X}");
var posDump = mem.ReadBytes(posPtr, 0x200);
if (posDump is not null)
{
for (var off = 0; off < posDump.Length; off += 16)
{
// Only show lines with non-zero interesting floats
var hasInteresting = false;
for (var fi = 0; fi < 4; fi++)
{
if (off + fi * 4 + 4 > posDump.Length) break;
var f = BitConverter.ToSingle(posDump, off + fi * 4);
if (MathF.Abs(f) > 0.1f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
hasInteresting = true;
}
if (!hasInteresting) continue;
sb.Append($" +0x{off:X3}: ");
var lineEnd = Math.Min(off + 16, posDump.Length);
for (var b = off; b < lineEnd; b++)
sb.Append($"{posDump[b]:X2} ");
sb.Append(" | ");
for (var fi = 0; fi < 4; fi++)
{
if (off + fi * 4 + 4 > posDump.Length) break;
var f = BitConverter.ToSingle(posDump, off + fi * 4);
if (MathF.Abs(f) > 0.1f && MathF.Abs(f) < 100000f && !float.IsNaN(f) && !float.IsInfinity(f))
sb.Append($"f{fi}={f:F2} ");
}
sb.AppendLine();
}
}
}
}
else
{
sb.AppendLine($" item[{gi}] has no Positioned component");
}
}
}
// Also check: do items have any unique components we haven't looked at?
if (worldItems.Count > 0)
{
var (firstEntity, _, _, _, _) = worldItems[0];
var firstLookup = _components.ReadComponentLookup(firstEntity);
if (firstLookup is not null)
sb.AppendLine($"\nAll components on first WorldItem: {string.Join(", ", firstLookup.Keys.OrderBy(k => k))}");
}
return sb.ToString();
}
} }

View file

@ -239,6 +239,10 @@ public sealed class GameOffsets
/// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary> /// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary>
public int RenderComponentIndex { get; set; } = -1; public int RenderComponentIndex { get; set; } = -1;
// ── WorldItem component ──
/// <summary>Offset within WorldItem component to the inner item entity pointer. TBD from diagnostic scan.</summary>
public int WorldItemEntityPtrOffset { get; set; } = 0x28;
// ── Life component ── // ── Life component ──
/// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary> /// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary>
public int LifeComponentOffset1 { get; set; } = 0x420; public int LifeComponentOffset1 { get; set; } = 0x420;

View file

@ -14,6 +14,7 @@ public sealed class EntityList : RemoteObject
private readonly ComponentReader _components; private readonly ComponentReader _components;
private readonly MsvcStringReader _strings; private readonly MsvcStringReader _strings;
private bool _loggedMonsterComponents; private bool _loggedMonsterComponents;
private bool _loggedItemComponents;
// Caches: stable per entity within a zone, cleared on zone change // Caches: stable per entity within a zone, cleared on zone change
private readonly Dictionary<nint, int> _renderIndexCache = new(); private readonly Dictionary<nint, int> _renderIndexCache = new();
@ -34,6 +35,9 @@ public sealed class EntityList : RemoteObject
public bool IsTargetable; public bool IsTargetable;
public int Rarity; // -1 = not read public int Rarity; // -1 = not read
public string? TransitionName; public string? TransitionName;
public string? ItemBaseName; // inner item path for WorldItem entities
public bool IsQuestItem;
public float LabelOffset; // Render +0x1AC label height offset
} }
public List<Entity>? Entities { get; private set; } public List<Entity>? Entities { get; private set; }
@ -206,6 +210,62 @@ public sealed class EntityList : RemoteObject
} }
} }
// WorldItem inner entity: read rarity + item path from inner entity
if (entity.Type == EntityType.WorldItem &&
lookup.TryGetValue("WorldItem", out var wiIdx) && wiIdx >= 0 && wiIdx < compPtrs.Length)
{
var wiComp = compPtrs[wiIdx];
if (wiComp != 0)
{
var innerEntityPtr = mem.ReadPointer(wiComp + offsets.WorldItemEntityPtrOffset);
if (innerEntityPtr != 0 && ((ulong)innerEntityPtr >> 32) is > 0 and < 0x7FFF)
{
stable.ItemBaseName = TryReadEntityPath(innerEntityPtr);
var innerLookup = _components.ReadComponentLookup(innerEntityPtr);
if (innerLookup is not null)
{
if (!_loggedItemComponents)
{
_loggedItemComponents = true;
Serilog.Log.Information("WorldItem inner components: {Comps}",
string.Join(", ", innerLookup.Keys.OrderBy(k => k)));
}
stable.IsQuestItem = innerLookup.ContainsKey("Quest");
if (stable.Rarity < 0)
{
var (iFirst, iCount) = _components.FindComponentList(innerEntityPtr);
if (innerLookup.TryGetValue("Mods", out var iModsIdx) &&
iModsIdx >= 0 && iModsIdx < iCount)
{
var iModsPtr = mem.ReadPointer(iFirst + iModsIdx * 8);
if (iModsPtr != 0)
{
var iMods = mem.Read<Mods>(iModsPtr);
if (iMods.Rarity is >= 0 and <= 3)
stable.Rarity = iMods.Rarity;
}
}
}
}
}
}
}
// Read label height offset from Render component
if (lookup.TryGetValue("Render", out var renderIdx) && renderIdx >= 0 && renderIdx < compPtrs.Length)
{
var renderComp = compPtrs[renderIdx];
if (renderComp != 0)
{
var labelOff = mem.Read<float>(renderComp + 0x1AC);
if (labelOff > 0 && labelOff < 200 && !float.IsNaN(labelOff))
stable.LabelOffset = labelOff;
}
}
if (entity.Components.Contains("AreaTransition") && if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compPtrs.Length) lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compPtrs.Length)
{ {
@ -220,6 +280,9 @@ public sealed class EntityList : RemoteObject
entity.IsTargetable = stable.IsTargetable; entity.IsTargetable = stable.IsTargetable;
if (stable.Rarity >= 0) if (stable.Rarity >= 0)
entity.Rarity = stable.Rarity; entity.Rarity = stable.Rarity;
entity.ItemBaseName = stable.ItemBaseName;
entity.IsQuestItem = stable.IsQuestItem;
entity.LabelOffset = stable.LabelOffset;
entity.TransitionName = stable.TransitionName; entity.TransitionName = stable.TransitionName;
// Dynamic component data — re-read every frame // Dynamic component data — re-read every frame
@ -494,6 +557,7 @@ public sealed class EntityList : RemoteObject
case "NPC": return EntityType.Npc; case "NPC": return EntityType.Npc;
case "Effects": return EntityType.Effect; case "Effects": return EntityType.Effect;
case "MiscellaneousObjects": case "MiscellaneousObjects":
if (path.Contains("/WorldItem", StringComparison.OrdinalIgnoreCase)) return EntityType.WorldItem;
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) || if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase)) return EntityType.Chest; path.Contains("/Stash", StringComparison.OrdinalIgnoreCase)) return EntityType.Chest;
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase)) return EntityType.Shrine; if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase)) return EntityType.Shrine;
@ -510,6 +574,7 @@ public sealed class EntityList : RemoteObject
var components = entity.Components; var components = entity.Components;
if (components is null || components.Count == 0) return; if (components is null || components.Count == 0) return;
if (components.Contains("WorldItem")) { entity.Type = EntityType.WorldItem; return; }
if (components.Contains("Monster")) { entity.Type = EntityType.Monster; return; } if (components.Contains("Monster")) { entity.Type = EntityType.Monster; return; }
if (components.Contains("Chest")) { entity.Type = EntityType.Chest; return; } if (components.Contains("Chest")) { entity.Type = EntityType.Chest; return; }
if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; } if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; }

View file

@ -57,6 +57,11 @@ public class Entity
public bool IsAvailable { get; internal set; } public bool IsAvailable { get; internal set; }
public int Rarity { get; internal set; } public int Rarity { get; internal set; }
// WorldItem inner entity
public string? ItemBaseName { get; internal set; } // inner item path (e.g. "Metadata/Items/Weapons/...")
public bool IsQuestItem { get; internal set; }
public float LabelOffset { get; internal set; } // Z offset for label rendering (from Render component +0x1AC)
// Mods (from Mods component) // Mods (from Mods component)
public List<string>? ModNames { get; internal set; } public List<string>? ModNames { get; internal set; }