402 lines
14 KiB
C++
402 lines
14 KiB
C++
#include "../CFramework/Cheat/SDK/GameSDK/GameSDK.h"
|
|
#include "../CFramework/Cheat/SnapshotHealth.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <exception>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <type_traits>
|
|
#include <unordered_map>
|
|
|
|
class SparseMemoryReader final : public IGameMemoryReader
|
|
{
|
|
public:
|
|
explicit SparseMemoryReader(const uintptr_t baseAddress = 0x10000000ULL)
|
|
: m_baseAddress(baseAddress)
|
|
{
|
|
}
|
|
|
|
bool TryReadBuffer(const uintptr_t address, void* buffer, const std::size_t size) const noexcept override
|
|
{
|
|
++m_readCounts[address];
|
|
auto failure = m_failuresRemaining.find(address);
|
|
if (failure != m_failuresRemaining.end() && failure->second > 0)
|
|
{
|
|
--failure->second;
|
|
return false;
|
|
}
|
|
|
|
if (!Memory::IsUserAddress(address) || !buffer || size == 0)
|
|
return false;
|
|
|
|
for (std::size_t i = 0; i < size; ++i)
|
|
{
|
|
if (m_bytes.find(address + i) == m_bytes.end())
|
|
return false;
|
|
}
|
|
|
|
auto* output = static_cast<std::uint8_t*>(buffer);
|
|
for (std::size_t i = 0; i < size; ++i)
|
|
output[i] = m_bytes.at(address + i);
|
|
return true;
|
|
}
|
|
|
|
bool ReadString(const uintptr_t address, void* buffer, const std::size_t size) const override
|
|
{
|
|
++m_readCounts[address];
|
|
if (!Memory::IsUserAddress(address) || !buffer || size == 0)
|
|
return false;
|
|
|
|
const auto value = m_strings.find(address);
|
|
if (value == m_strings.end())
|
|
return false;
|
|
|
|
std::memset(buffer, 0, size);
|
|
const std::size_t copyLength = (std::min)(size - 1, value->second.size());
|
|
std::memcpy(buffer, value->second.data(), copyLength);
|
|
return true;
|
|
}
|
|
|
|
uintptr_t GetBaseAddress() const noexcept override
|
|
{
|
|
return m_baseAddress;
|
|
}
|
|
|
|
template <typename T>
|
|
void Write(const uintptr_t address, const T& value)
|
|
{
|
|
static_assert(std::is_trivially_copyable_v<T>, "synthetic memory values must be trivially copyable");
|
|
const auto* bytes = reinterpret_cast<const std::uint8_t*>(&value);
|
|
for (std::size_t i = 0; i < sizeof(T); ++i)
|
|
m_bytes[address + i] = bytes[i];
|
|
}
|
|
|
|
void WriteString(const uintptr_t address, std::string value)
|
|
{
|
|
m_strings[address] = std::move(value);
|
|
}
|
|
|
|
void FailNextReads(const uintptr_t address, const std::size_t count)
|
|
{
|
|
m_failuresRemaining[address] = count;
|
|
}
|
|
|
|
std::size_t ReadCount(const uintptr_t address) const
|
|
{
|
|
const auto count = m_readCounts.find(address);
|
|
return count == m_readCounts.end() ? 0 : count->second;
|
|
}
|
|
|
|
private:
|
|
uintptr_t m_baseAddress;
|
|
std::unordered_map<uintptr_t, std::uint8_t> m_bytes;
|
|
std::unordered_map<uintptr_t, std::string> m_strings;
|
|
mutable std::unordered_map<uintptr_t, std::size_t> m_failuresRemaining;
|
|
mutable std::unordered_map<uintptr_t, std::size_t> m_readCounts;
|
|
};
|
|
|
|
namespace
|
|
{
|
|
constexpr uintptr_t kNodeA = 0x30000000ULL;
|
|
constexpr uintptr_t kNodeB = 0x30010000ULL;
|
|
constexpr uintptr_t kObjectA = 0x31000000ULL;
|
|
constexpr uintptr_t kObjectB = 0x31010000ULL;
|
|
constexpr uintptr_t kUnreadableNode = 0x32000000ULL;
|
|
|
|
void WriteNode(SparseMemoryReader& memory, const uintptr_t node, const uintptr_t next,
|
|
const uintptr_t object, const char* name)
|
|
{
|
|
BaseObject value{};
|
|
value.nextObjectLink = next;
|
|
value.object = object;
|
|
memory.Write(node, value);
|
|
|
|
const uintptr_t nameAddress = object + 0x800;
|
|
memory.Write(object + offset::UnityCamera::ObjectName, nameAddress);
|
|
if (name)
|
|
memory.WriteString(nameAddress, name);
|
|
}
|
|
|
|
enum class GomLayout
|
|
{
|
|
ActiveNodesIndirect,
|
|
ActiveNodesDirect,
|
|
ActiveObjectsDirect,
|
|
ActiveObjectsIndirect
|
|
};
|
|
|
|
struct ReadyWorldFixture
|
|
{
|
|
uintptr_t gom{ 0 };
|
|
uintptr_t activeNode{ 0 };
|
|
uintptr_t gameWorld{ 0 };
|
|
uintptr_t localGameWorld{ 0 };
|
|
};
|
|
|
|
ReadyWorldFixture ConfigureReadyWorld(SparseMemoryReader& memory, const GomLayout layout)
|
|
{
|
|
const uintptr_t base = memory.GetBaseAddress();
|
|
const uintptr_t gomAddress = 0x40000000ULL;
|
|
const uintptr_t activeNode = 0x41000000ULL;
|
|
const uintptr_t activeSlot = 0x42000000ULL;
|
|
const uintptr_t lastSlot = 0x42010000ULL;
|
|
const uintptr_t gameWorld = 0x43000000ULL;
|
|
const uintptr_t component0 = 0x44000000ULL;
|
|
const uintptr_t component1 = 0x44010000ULL;
|
|
const uintptr_t localGameWorld = 0x45000000ULL;
|
|
|
|
memory.Write(base + offset::dwGameObjectManager, gomAddress);
|
|
|
|
GameObjectManager gom{};
|
|
switch (layout)
|
|
{
|
|
case GomLayout::ActiveNodesIndirect:
|
|
gom.ActiveNodes = activeSlot;
|
|
gom.LastActiveNode = lastSlot;
|
|
memory.Write(activeSlot, activeNode);
|
|
memory.Write(lastSlot, activeNode);
|
|
break;
|
|
case GomLayout::ActiveNodesDirect:
|
|
gom.ActiveNodes = activeNode;
|
|
gom.LastActiveNode = activeNode;
|
|
break;
|
|
case GomLayout::ActiveObjectsDirect:
|
|
gom.activeObjects = activeNode;
|
|
gom.lastActiveObject = activeNode;
|
|
break;
|
|
case GomLayout::ActiveObjectsIndirect:
|
|
gom.activeObjects = activeSlot;
|
|
gom.lastActiveObject = lastSlot;
|
|
memory.Write(activeSlot, activeNode);
|
|
memory.Write(lastSlot, activeNode);
|
|
break;
|
|
}
|
|
memory.Write(gomAddress, gom);
|
|
|
|
WriteNode(memory, activeNode, 0, gameWorld, "GameWorld");
|
|
memory.Write(gameWorld + offset::UnityGameWorld::Component0, component0);
|
|
memory.Write(component0 + offset::UnityGameWorld::Component1, component1);
|
|
memory.Write(component1 + offset::UnityGameWorld::LocalGameWorld, localGameWorld);
|
|
|
|
return { gomAddress, activeNode, gameWorld, localGameWorld };
|
|
}
|
|
|
|
bool VerifyReadyLayout(const GomLayout layout)
|
|
{
|
|
SparseMemoryReader memory;
|
|
const ReadyWorldFixture fixture = ConfigureReadyWorld(memory, layout);
|
|
CGame game(memory);
|
|
if (!game.Refresh())
|
|
return false;
|
|
|
|
const GameStateSnapshot state = game.GetState();
|
|
return state.refreshStatus == GameRefreshStatus::Ready &&
|
|
state.initialized && state.inRaid && !state.cameraValid &&
|
|
state.refreshAttempts == 1 && state.consecutiveRefreshFailures == 0 &&
|
|
state.gameObjectManager == fixture.gom &&
|
|
state.activeNodeList == fixture.activeNode &&
|
|
state.gameWorld == fixture.gameWorld &&
|
|
state.localGameWorld == fixture.localGameWorld;
|
|
}
|
|
|
|
bool Near(const float lhs, const float rhs)
|
|
{
|
|
return std::fabs(lhs - rhs) < 0.001f;
|
|
}
|
|
|
|
class TestRunner
|
|
{
|
|
public:
|
|
template <typename Test>
|
|
void Run(const char* name, Test&& test)
|
|
{
|
|
bool passed = false;
|
|
try
|
|
{
|
|
passed = test();
|
|
}
|
|
catch (const std::exception& error)
|
|
{
|
|
std::cout << "[FAIL] " << name << " (exception: " << error.what() << ")\n";
|
|
++m_failed;
|
|
return;
|
|
}
|
|
catch (...)
|
|
{
|
|
std::cout << "[FAIL] " << name << " (unknown exception)\n";
|
|
++m_failed;
|
|
return;
|
|
}
|
|
|
|
std::cout << (passed ? "[PASS] " : "[FAIL] ") << name << '\n';
|
|
passed ? ++m_passed : ++m_failed;
|
|
}
|
|
|
|
int Finish() const
|
|
{
|
|
std::cout << "\nSummary: " << m_passed << " PASS, " << m_failed << " FAIL\n";
|
|
return m_failed == 0 ? 0 : 1;
|
|
}
|
|
|
|
private:
|
|
int m_passed{ 0 };
|
|
int m_failed{ 0 };
|
|
};
|
|
}
|
|
|
|
int main()
|
|
{
|
|
TestRunner tests;
|
|
|
|
tests.Run("ObjectList target at first node", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, 0, kObjectA, "GameWorld");
|
|
return GetObjectFromList(memory, kNodeA, kNodeA, "GameWorld") == kObjectA;
|
|
});
|
|
|
|
tests.Run("ObjectList accepts last=0", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeB, kObjectA, "Other");
|
|
WriteNode(memory, kNodeB, 0, kObjectB, "GameWorld");
|
|
return GetObjectFromList(memory, kNodeA, 0, "GameWorld") == kObjectB;
|
|
});
|
|
|
|
tests.Run("ObjectList accepts unreadable last sentinel", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeB, kObjectA, "Other");
|
|
WriteNode(memory, kNodeB, 0, kObjectB, "GameWorld");
|
|
return GetObjectFromList(memory, kNodeA, kUnreadableNode, "GameWorld") == kObjectB;
|
|
});
|
|
|
|
tests.Run("ObjectList continues after name read failure", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeB, kObjectA, nullptr);
|
|
WriteNode(memory, kNodeB, 0, kObjectB, "GameWorld");
|
|
return GetObjectFromList(memory, kNodeA, 0, "GameWorld") == kObjectB;
|
|
});
|
|
|
|
tests.Run("ObjectList stops self-loop", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeA, kObjectA, "Other");
|
|
return GetObjectFromList(memory, kNodeA, 0, "GameWorld") == 0 &&
|
|
memory.ReadCount(kNodeA) == 1;
|
|
});
|
|
|
|
tests.Run("ObjectList stops two-node cycle", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeB, kObjectA, "OtherA");
|
|
WriteNode(memory, kNodeB, kNodeA, kObjectB, "OtherB");
|
|
return GetObjectFromList(memory, kNodeA, 0, "GameWorld") == 0 &&
|
|
memory.ReadCount(kNodeA) + memory.ReadCount(kNodeB) == 2;
|
|
});
|
|
|
|
tests.Run("ObjectList target absent", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
WriteNode(memory, kNodeA, kNodeB, kObjectA, "OtherA");
|
|
WriteNode(memory, kNodeB, 0, kObjectB, "OtherB");
|
|
return GetObjectFromList(memory, kNodeA, 0, "GameWorld") == 0;
|
|
});
|
|
|
|
tests.Run("GOM ActiveNodes indirect", [] { return VerifyReadyLayout(GomLayout::ActiveNodesIndirect); });
|
|
tests.Run("GOM ActiveNodes direct", [] { return VerifyReadyLayout(GomLayout::ActiveNodesDirect); });
|
|
tests.Run("GOM activeObjects direct", [] { return VerifyReadyLayout(GomLayout::ActiveObjectsDirect); });
|
|
tests.Run("GOM activeObjects indirect", [] { return VerifyReadyLayout(GomLayout::ActiveObjectsIndirect); });
|
|
|
|
tests.Run("CGame recovers after initial GOM failures", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
ConfigureReadyWorld(memory, GomLayout::ActiveNodesIndirect);
|
|
const uintptr_t gomRoot = memory.GetBaseAddress() + offset::dwGameObjectManager;
|
|
memory.FailNextReads(gomRoot, 3);
|
|
|
|
CGame game(memory);
|
|
for (uint64_t attempt = 1; attempt <= 3; ++attempt)
|
|
{
|
|
if (game.Refresh())
|
|
return false;
|
|
const GameStateSnapshot failed = game.GetState();
|
|
if (failed.refreshStatus != GameRefreshStatus::GomUnavailable ||
|
|
failed.refreshAttempts != attempt ||
|
|
failed.consecutiveRefreshFailures != attempt)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!game.Refresh())
|
|
return false;
|
|
const GameStateSnapshot recovered = game.GetState();
|
|
return recovered.refreshStatus == GameRefreshStatus::Ready && recovered.inRaid &&
|
|
recovered.refreshAttempts == 4 && recovered.consecutiveRefreshFailures == 0;
|
|
});
|
|
|
|
tests.Run("Snapshot generation and freshness liveness", []
|
|
{
|
|
using namespace std::chrono;
|
|
const auto start = steady_clock::time_point(milliseconds(1000));
|
|
const bool worldReady[] = { false, false, true, false };
|
|
SnapshotLiveness liveness{};
|
|
for (std::size_t i = 0; i < std::size(worldReady); ++i)
|
|
{
|
|
(void)worldReady[i];
|
|
liveness = AdvanceSnapshotLiveness(liveness.generation, start + milliseconds(33 * i));
|
|
}
|
|
|
|
return liveness.generation == 4 &&
|
|
IsSnapshotFresh(liveness.capturedAt, liveness.capturedAt + milliseconds(250)) &&
|
|
!IsSnapshotFresh(liveness.capturedAt, liveness.capturedAt + milliseconds(251)) &&
|
|
GetSnapshotAgeMs(liveness.capturedAt, liveness.capturedAt + milliseconds(251)) == 251 &&
|
|
!IsSnapshotFresh(steady_clock::time_point{}, start);
|
|
});
|
|
|
|
tests.Run("Camera unavailable keeps world Ready", []
|
|
{
|
|
SparseMemoryReader memory;
|
|
ConfigureReadyWorld(memory, GomLayout::ActiveNodesDirect);
|
|
CGame game(memory);
|
|
if (!game.Refresh() || !game.Refresh())
|
|
return false;
|
|
|
|
const GameStateSnapshot state = game.GetState();
|
|
return state.refreshStatus == GameRefreshStatus::Ready && state.inRaid &&
|
|
!state.cameraValid && state.refreshAttempts == 2 &&
|
|
state.consecutiveRefreshFailures == 0;
|
|
});
|
|
|
|
tests.Run("W2S projects visible point", []
|
|
{
|
|
const Matrix projection(
|
|
1.f, 0.f, 0.f, 0.f,
|
|
0.f, 1.f, 0.f, 0.f,
|
|
0.f, 0.f, 0.f, 1.f,
|
|
0.f, 0.f, 0.f, 0.f);
|
|
Vector2 output{};
|
|
return WorldToScreen(projection, Vector2(1920.f, 1080.f), Vector3(0.f, 0.f, 1.f), output) &&
|
|
Near(output.x, 960.f) && Near(output.y, 540.f);
|
|
});
|
|
|
|
tests.Run("W2S rejects point behind camera", []
|
|
{
|
|
const Matrix projection(
|
|
1.f, 0.f, 0.f, 0.f,
|
|
0.f, 1.f, 0.f, 0.f,
|
|
0.f, 0.f, 0.f, 1.f,
|
|
0.f, 0.f, 0.f, 0.f);
|
|
Vector2 output{};
|
|
return !WorldToScreen(projection, Vector2(1920.f, 1080.f), Vector3(0.f, 0.f, -1.f), output);
|
|
});
|
|
|
|
return tests.Finish();
|
|
}
|