170 lines
5.6 KiB
C#
170 lines
5.6 KiB
C#
using Roboto.GameOffsets.States;
|
|
using Serilog;
|
|
using Terrain = Roboto.GameOffsets.States.Terrain;
|
|
|
|
namespace Roboto.Memory;
|
|
|
|
/// <summary>
|
|
/// Reads terrain walkability grid from AreaInstance, with caching and loading edge detection.
|
|
/// </summary>
|
|
public sealed class TerrainReader
|
|
{
|
|
private readonly MemoryContext _ctx;
|
|
private uint _cachedTerrainAreaHash;
|
|
private WalkabilityGrid? _cachedTerrain;
|
|
private bool _wasLoading;
|
|
|
|
public TerrainReader(MemoryContext ctx)
|
|
{
|
|
_ctx = ctx;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalidates the terrain cache (called when LocalPlayer changes on zone change).
|
|
/// </summary>
|
|
public void InvalidateCache()
|
|
{
|
|
_cachedTerrain = null;
|
|
_cachedTerrainAreaHash = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads terrain data from AreaInstance into the snapshot.
|
|
/// Handles both inline and pointer-based terrain layouts.
|
|
/// </summary>
|
|
public void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
|
|
{
|
|
var mem = _ctx.Memory;
|
|
var offsets = _ctx.Offsets;
|
|
|
|
if (!offsets.TerrainInline)
|
|
{
|
|
// Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
|
|
var terrainListPtr = mem.ReadPointer(areaInstance + offsets.TerrainListOffset);
|
|
if (terrainListPtr == 0) return;
|
|
|
|
var terrainPtr = mem.ReadPointer(terrainListPtr);
|
|
if (terrainPtr == 0) return;
|
|
|
|
var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
|
|
if (dimsPtr == 0) return;
|
|
|
|
snap.TerrainCols = mem.Read<int>(dimsPtr);
|
|
snap.TerrainRows = mem.Read<int>(dimsPtr + 4);
|
|
if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 &&
|
|
snap.TerrainRows > 0 && snap.TerrainRows < 1000)
|
|
{
|
|
snap.TerrainWidth = snap.TerrainCols * offsets.SubTilesPerCell;
|
|
snap.TerrainHeight = snap.TerrainRows * offsets.SubTilesPerCell;
|
|
}
|
|
else
|
|
{
|
|
snap.TerrainCols = 0;
|
|
snap.TerrainRows = 0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset
|
|
// Single Read<Terrain> (0x1B0 = 432 bytes) replaces 5 individual reads
|
|
var terrainBase = areaInstance + offsets.TerrainListOffset;
|
|
var t = mem.Read<Terrain>(terrainBase);
|
|
|
|
var cols = (int)t.Dimensions.X;
|
|
var rows = (int)t.Dimensions.Y;
|
|
|
|
if (cols <= 0 || cols >= 1000 || rows <= 0 || rows >= 1000)
|
|
return;
|
|
|
|
snap.TerrainCols = cols;
|
|
snap.TerrainRows = rows;
|
|
snap.TerrainWidth = cols * offsets.SubTilesPerCell;
|
|
snap.TerrainHeight = rows * offsets.SubTilesPerCell;
|
|
|
|
// While loading, clear cached terrain and don't read (data is stale/invalid)
|
|
if (snap.IsLoading)
|
|
{
|
|
_cachedTerrain = null;
|
|
_cachedTerrainAreaHash = 0;
|
|
return;
|
|
}
|
|
|
|
// Loading just finished — clear cache to force a fresh read
|
|
if (_wasLoading)
|
|
{
|
|
_cachedTerrain = null;
|
|
_cachedTerrainAreaHash = 0;
|
|
}
|
|
|
|
// Return cached grid if same area
|
|
if (_cachedTerrain != null && _cachedTerrainAreaHash == snap.AreaHash)
|
|
{
|
|
snap.Terrain = _cachedTerrain;
|
|
snap.TerrainWalkablePercent = CalcWalkablePercent(_cachedTerrain);
|
|
return;
|
|
}
|
|
|
|
// Grid vector pointers already available from the Terrain struct read
|
|
var gridBegin = t.WalkableGrid.First;
|
|
var gridEnd = t.WalkableGrid.Last;
|
|
if (gridBegin == 0 || gridEnd <= gridBegin)
|
|
return;
|
|
|
|
var gridDataSize = (int)(gridEnd - gridBegin);
|
|
if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
|
|
return;
|
|
|
|
var bytesPerRow = t.BytesPerRow;
|
|
if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
|
|
return;
|
|
|
|
var gridWidth = cols * offsets.SubTilesPerCell;
|
|
var gridHeight = rows * offsets.SubTilesPerCell;
|
|
|
|
var rawData = mem.ReadBytes(gridBegin, gridDataSize);
|
|
if (rawData is null)
|
|
return;
|
|
|
|
// Unpack 4-bit nibbles: each byte → 2 cells
|
|
var data = new byte[gridWidth * gridHeight];
|
|
for (var row = 0; row < gridHeight; row++)
|
|
{
|
|
var rowStart = row * bytesPerRow;
|
|
for (var col = 0; col < gridWidth; col++)
|
|
{
|
|
var byteIndex = rowStart + col / 2;
|
|
if (byteIndex >= rawData.Length) break;
|
|
|
|
data[row * gridWidth + col] = (col % 2 == 0)
|
|
? (byte)(rawData[byteIndex] & 0x0F)
|
|
: (byte)((rawData[byteIndex] >> 4) & 0x0F);
|
|
}
|
|
}
|
|
|
|
var grid = new WalkabilityGrid(gridWidth, gridHeight, data);
|
|
snap.Terrain = grid;
|
|
snap.TerrainWalkablePercent = CalcWalkablePercent(grid);
|
|
|
|
_cachedTerrain = grid;
|
|
_cachedTerrainAreaHash = snap.AreaHash;
|
|
|
|
Log.Information("Terrain grid read: {W}x{H} ({Cols}x{Rows} cells), {Pct}% walkable",
|
|
gridWidth, gridHeight, cols, rows, snap.TerrainWalkablePercent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the loading edge detection state. Call after ReadTerrain.
|
|
/// </summary>
|
|
public void UpdateLoadingEdge(bool isLoading)
|
|
{
|
|
_wasLoading = isLoading;
|
|
}
|
|
|
|
public static int CalcWalkablePercent(WalkabilityGrid grid)
|
|
{
|
|
var walkable = 0;
|
|
for (var i = 0; i < grid.Data.Length; i++)
|
|
if (grid.Data[i] != 0) walkable++;
|
|
return grid.Data.Length > 0 ? (int)(100L * walkable / grid.Data.Length) : 0;
|
|
}
|
|
}
|