diff --git a/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs b/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
index de046de..b5fcf77 100644
--- a/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
+++ b/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
@@ -39,7 +39,6 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
// 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
var freshPos = cache.PlayerPosition;
- var playerZ = freshPos.HasPosition ? freshPos.Z : data.SnapshotPlayerZ;
var dx = freshPos.HasPosition ? freshPos.X - data.SnapshotPlayerPosition.X : 0f;
var dy = freshPos.HasPosition ? freshPos.Y - data.SnapshotPlayerPosition.Y : 0f;
var snapshotPx = data.SnapshotPlayerPosition.X;
@@ -57,8 +56,8 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
ey += dy;
}
- // WorldToScreen using camera's view-projection matrix
- var worldPos = new Vector4(ex, ey, playerZ, 1f);
+ // WorldToScreen using camera's view-projection matrix with per-entity Z + label offset
+ var worldPos = new Vector4(ex, ey, entry.Z + entry.LabelOffset, 1f);
var clip = Vector4.Transform(worldPos, mat);
// Perspective divide
@@ -86,7 +85,7 @@ internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2),
ctx.LabelBgBrush);
- var brush = entry.Rarity switch
+ var brush = entry.IsQuestItem ? ctx.Green : entry.Rarity switch
{
1 => ctx.MagicBrush,
2 => ctx.RareBrush,
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index 1c9ac12..86093a3 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -918,9 +918,10 @@ public partial class MemoryViewModel : ObservableObject
foreach (var e in snap.Entities)
{
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.Type == EntityType.Monster && e.IsDead) continue;
+ if (e.Type == EntityType.WorldItem && !e.IsQuestItem) continue;
// Entity position relative to player in grid coords
var dx = e.X * worldToGrid - pgx;
@@ -940,6 +941,8 @@ public partial class MemoryViewModel : ObservableObject
b = 0x00; g = 0x8C; r = 0xFF; break;
case EntityType.Monster: // red #FF4444
b = 0x44; g = 0x44; r = 0xFF; break;
+ case EntityType.WorldItem: // quest items — green #3FB950
+ b = 0x50; g = 0xB9; r = 0x3F; break;
default: continue;
}
@@ -1033,7 +1036,7 @@ public partial class MemoryViewModel : ObservableObject
var e = sorted[i];
var label = FormatEntityName(e);
var value = FormatEntityValue(e);
- var color = RarityColor(e.Rarity);
+ var color = e.IsQuestItem ? "#3fb950" : RarityColor(e.Rarity);
if (i < groupNode.Children.Count)
{
@@ -1608,4 +1611,28 @@ public partial class MemoryViewModel : ObservableObject
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();
+ }
}
diff --git a/src/Automata.Ui/ViewModels/RobotoViewModel.cs b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
index f6a1251..6bd17a1 100644
--- a/src/Automata.Ui/ViewModels/RobotoViewModel.cs
+++ b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
@@ -28,16 +28,21 @@ public sealed class EntityOverlayData
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 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;
Y = y;
+ Z = z;
+ LabelOffset = labelOffset;
Label = label;
Rarity = rarity;
+ IsQuestItem = isQuestItem;
}
}
@@ -52,11 +57,14 @@ public partial class EntityListItem : ObservableObject
public string Distance { get; set; }
public float X { get; set; }
public float Y { get; set; }
+ public float Z { get; set; }
+ public float LabelOffset { get; }
public int Rarity { get; }
+ public bool IsQuestItem { get; }
[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;
Label = label;
@@ -64,7 +72,10 @@ public partial class EntityListItem : ObservableObject
Distance = $"{distance:F0}";
X = x;
Y = y;
+ Z = z;
+ LabelOffset = labelOffset;
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
? $"AreaTransition — {e.TransitionName}"
: 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))
item.IsChecked = true;
Entities.Add(item);
@@ -553,7 +564,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable
foreach (var item in Entities)
{
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)
diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml
index cb37b84..b47aa9e 100644
--- a/src/Automata.Ui/Views/MainWindow.axaml
+++ b/src/Automata.Ui/Views/MainWindow.axaml
@@ -802,6 +802,10 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+
+
Render component — world position, bounds, terrain height.
-[StructLayout(LayoutKind.Explicit, Size = 0x148)]
+[StructLayout(LayoutKind.Explicit, Size = 0x1B0)]
public struct Render
{
[FieldOffset(0x00)] public ComponentHeader Header;
@@ -20,4 +20,11 @@ public struct Render
/// Grid position (float x, y, z). Confirmed offsets: X=0x138, Y=0x13C, Z=0x140.
[FieldOffset(0x138)] public StdTuple3D Position;
+
+ /// Bounding box size (float x, y). +0x144, +0x148.
+ [FieldOffset(0x144)] public float BoundsX;
+ [FieldOffset(0x148)] public float BoundsY;
+
+ /// Label height offset — constant 18.0 for items. Used to raise nameplate above ground.
+ [FieldOffset(0x1AC)] public float LabelHeightOffset;
}
diff --git a/src/Roboto.GameOffsets/Components/WorldItem.cs b/src/Roboto.GameOffsets/Components/WorldItem.cs
new file mode 100644
index 0000000..ed39760
--- /dev/null
+++ b/src/Roboto.GameOffsets/Components/WorldItem.cs
@@ -0,0 +1,13 @@
+using System.Runtime.InteropServices;
+
+namespace Roboto.GameOffsets.Components;
+
+/// WorldItem component — contains pointer to inner item entity at +0x28 (TBD from diagnostic).
+[StructLayout(LayoutKind.Explicit, Size = 0x30)]
+public struct WorldItem
+{
+ [FieldOffset(0x00)] public ComponentHeader Header;
+
+ /// Pointer to the inner item entity (has its own component list with Mods, Base, etc.).
+ [FieldOffset(0x28)] public nint ItemEntityPtr;
+}
diff --git a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
index 15f1028..95c9df4 100644
--- a/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
+++ b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs
@@ -7439,4 +7439,547 @@ public sealed class MemoryDiagnostics
sb.AppendLine($" Total: {count} entries");
}
+
+ ///
+ /// Diagnostic: find WorldItem entities (ground loot), dump the WorldItem component,
+ /// and probe for an inner entity pointer that carries Mods/Base components.
+ ///
+ 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(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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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(renderPtr + 0x138);
+ var posY = mem.Read(renderPtr + 0x13C);
+ var posZ = mem.Read(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>();
+ var assigned = new HashSet();
+ for (var i = 0; i < worldItems.Count; i++)
+ {
+ if (assigned.Contains(i)) continue;
+ var group = new List { 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();
+ 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();
+ }
}
diff --git a/src/Roboto.Memory/GameOffsets.cs b/src/Roboto.Memory/GameOffsets.cs
index ecfe469..283783d 100644
--- a/src/Roboto.Memory/GameOffsets.cs
+++ b/src/Roboto.Memory/GameOffsets.cs
@@ -239,6 +239,10 @@ public sealed class GameOffsets
/// Index of Render/Position component in entity's component list. -1 = unknown.
public int RenderComponentIndex { get; set; } = -1;
+ // ── WorldItem component ──
+ /// Offset within WorldItem component to the inner item entity pointer. TBD from diagnostic scan.
+ public int WorldItemEntityPtrOffset { get; set; } = 0x28;
+
// ── Life component ──
/// First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.
public int LifeComponentOffset1 { get; set; } = 0x420;
diff --git a/src/Roboto.Memory/Objects/EntityList.cs b/src/Roboto.Memory/Objects/EntityList.cs
index 7617fb0..c7dc3f4 100644
--- a/src/Roboto.Memory/Objects/EntityList.cs
+++ b/src/Roboto.Memory/Objects/EntityList.cs
@@ -14,6 +14,7 @@ public sealed class EntityList : RemoteObject
private readonly ComponentReader _components;
private readonly MsvcStringReader _strings;
private bool _loggedMonsterComponents;
+ private bool _loggedItemComponents;
// Caches: stable per entity within a zone, cleared on zone change
private readonly Dictionary _renderIndexCache = new();
@@ -34,6 +35,9 @@ public sealed class EntityList : RemoteObject
public bool IsTargetable;
public int Rarity; // -1 = not read
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? 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(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(renderComp + 0x1AC);
+ if (labelOff > 0 && labelOff < 200 && !float.IsNaN(labelOff))
+ stable.LabelOffset = labelOff;
+ }
+ }
+
if (entity.Components.Contains("AreaTransition") &&
lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compPtrs.Length)
{
@@ -220,6 +280,9 @@ public sealed class EntityList : RemoteObject
entity.IsTargetable = stable.IsTargetable;
if (stable.Rarity >= 0)
entity.Rarity = stable.Rarity;
+ entity.ItemBaseName = stable.ItemBaseName;
+ entity.IsQuestItem = stable.IsQuestItem;
+ entity.LabelOffset = stable.LabelOffset;
entity.TransitionName = stable.TransitionName;
// Dynamic component data — re-read every frame
@@ -494,6 +557,7 @@ public sealed class EntityList : RemoteObject
case "NPC": return EntityType.Npc;
case "Effects": return EntityType.Effect;
case "MiscellaneousObjects":
+ if (path.Contains("/WorldItem", StringComparison.OrdinalIgnoreCase)) return EntityType.WorldItem;
if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/Stash", StringComparison.OrdinalIgnoreCase)) return EntityType.Chest;
if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase)) return EntityType.Shrine;
@@ -510,6 +574,7 @@ public sealed class EntityList : RemoteObject
var components = entity.Components;
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("Chest")) { entity.Type = EntityType.Chest; return; }
if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; }
diff --git a/src/Roboto.Memory/Snapshots/Entity.cs b/src/Roboto.Memory/Snapshots/Entity.cs
index 62fe3fb..782acd6 100644
--- a/src/Roboto.Memory/Snapshots/Entity.cs
+++ b/src/Roboto.Memory/Snapshots/Entity.cs
@@ -57,6 +57,11 @@ public class Entity
public bool IsAvailable { 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)
public List? ModNames { get; internal set; }