started items
This commit is contained in:
parent
d124f2c288
commit
4424f4c3a8
12 changed files with 698 additions and 12 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/Roboto.GameOffsets/Components/WorldItem.cs
Normal file
13
src/Roboto.GameOffsets/Components/WorldItem.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue