mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 11:57:01 +02:00
776 lines
37 KiB
C++
776 lines
37 KiB
C++
#include "ai_skaters.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Core/Platform/memory.h"
|
|
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
|
#include "Extension/Multiplayer/Remote/native_skater.h"
|
|
#include "Engine/Game/Abi/native_data.h"
|
|
#include "Engine/Game/Build/supported_build.h"
|
|
#include "Engine/Game/Build/addresses.h"
|
|
#include "Engine/Game/Build/20260929/ai_skaters.h"
|
|
#include "Engine/Game/Build/20260929/engine.h"
|
|
#include "Engine/Game/Build/20260929/skater_entities.h"
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <format>
|
|
#include <intrin.h>
|
|
#include <mutex>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace dingosdk::ai_skaters {
|
|
namespace {
|
|
namespace ai = addr::ai_skaters;
|
|
namespace entities = addr::skater_entities;
|
|
constexpr std::uintptr_t highest = memory::highest_user_address;
|
|
constexpr std::string_view player_blueprint = "Gameplay/Characters/CharacterBlueprint_Physics_Skater_RSP_CAS";
|
|
constexpr std::string_view ai_blueprint = "Gameplay/Characters/CharacterBlueprint_Physics_Skater_AI";
|
|
constexpr std::size_t max_skaters = 16;
|
|
// Milliseconds after creation at which each skater's native state is logged.
|
|
constexpr std::array<ULONGLONG, 6> sample_ms{500, 1000, 2000, 4000, 8000, 15000};
|
|
|
|
struct Unavailable : std::runtime_error {
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
void require(bool value, const std::string &reason) {
|
|
if (!value)
|
|
throw Unavailable(reason);
|
|
}
|
|
bool readable(std::uintptr_t address, void *out, std::size_t size) noexcept {
|
|
if (address < 0x10000 || size > highest || address > highest - size)
|
|
return false;
|
|
__try {
|
|
std::memcpy(out, reinterpret_cast<const void *>(address), size);
|
|
return true;
|
|
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
|
return false;
|
|
}
|
|
}
|
|
bool write(std::uintptr_t address, const void *data, std::size_t size) noexcept {
|
|
if (address < 0x10000 || size > highest || address > highest - size)
|
|
return false;
|
|
__try {
|
|
std::memcpy(reinterpret_cast<void *>(address), data, size);
|
|
return true;
|
|
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
|
return false;
|
|
}
|
|
}
|
|
template <class T> T read(std::uintptr_t object, std::uintptr_t offset = 0) {
|
|
T value{};
|
|
// The message is formatted only on failure, not for every successful read.
|
|
if (!(object >= 0x10000 && object <= highest && offset <= highest - object &&
|
|
readable(object + offset, &value, sizeof(value))))
|
|
throw Unavailable(std::format("AI skater read failed at {:#x}+{:#x}.", object, offset));
|
|
return value;
|
|
}
|
|
std::uintptr_t ptr(std::uintptr_t object, std::uintptr_t offset = 0) {
|
|
return read<std::uintptr_t>(object, offset);
|
|
}
|
|
bool code(std::uintptr_t base, std::uintptr_t address) {
|
|
return address >= base && address < base + supported_build::game_image_size;
|
|
}
|
|
template <std::size_t N>
|
|
void prefix(std::uintptr_t base, std::uintptr_t rva, const std::array<std::uint8_t, N> &expected) {
|
|
std::array<std::uint8_t, 32> bytes{};
|
|
require(expected.size() <= bytes.size() && readable(base + rva, bytes.data(), expected.size()) &&
|
|
std::equal(expected.begin(), expected.end(), bytes.begin()),
|
|
std::format("AI skater native function {:#x} fingerprint changed.", rva));
|
|
}
|
|
std::string name_at(std::uintptr_t address) {
|
|
std::string result;
|
|
for (std::size_t i = 0; i < 256; ++i) {
|
|
char c{};
|
|
require(readable(address + i, &c, 1), "Asset name unreadable.");
|
|
if (!c)
|
|
return result;
|
|
result += c;
|
|
}
|
|
throw Unavailable("Asset name exceeds 255 bytes.");
|
|
}
|
|
bool same_name(std::string_view a, std::string_view b) {
|
|
return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) {
|
|
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
|
|
});
|
|
}
|
|
bool derives(std::uintptr_t object, std::uintptr_t expected) {
|
|
auto type = ptr(object, 8);
|
|
for (int depth = 0; depth < 16 && type; ++depth) {
|
|
if (type == expected)
|
|
return true;
|
|
type = ptr(type, 0x20);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
using Matrix = std::array<float, 16>;
|
|
Matrix root_matrix(std::uintptr_t entity) {
|
|
const auto collection = ptr(entity, 0x70);
|
|
require(ptr(collection) == entity, "Skater transform owner differs.");
|
|
const auto first = read<std::uint8_t>(collection, 9), extra = read<std::uint8_t>(collection, 10);
|
|
require(first <= 128 && extra <= 32, "Skater transform layout exceeds bounds.");
|
|
return read<Matrix>(collection, 0x10 + (std::uintptr_t{first} + 2 * std::uintptr_t{extra}) * 0x20);
|
|
}
|
|
std::string position_text(const Matrix &m) {
|
|
return std::format("({:.2f}, {:.2f}, {:.2f})", m[12], m[13], m[14]);
|
|
}
|
|
|
|
struct Local {
|
|
std::uintptr_t context{}, player{}, entity{};
|
|
Matrix root{};
|
|
};
|
|
Local local_skater(std::uintptr_t base, std::uintptr_t client) {
|
|
require(ptr(client) == base + addr::engine::client_vtable, "Local client unavailable.");
|
|
require(read<std::uint32_t>(client, 0xc0) <= 1, "AI skaters need a local single-player or Hosted map.");
|
|
Local local;
|
|
local.context = ptr(client, 8);
|
|
const auto offset = read<std::uint32_t>(base, addr::engine::context_player_manager_offset);
|
|
require(offset <= 0x1000000, "Player manager offset changed.");
|
|
const auto manager = ptr(local.context, offset);
|
|
require(ptr(manager) == base + addr::engine::local_player_manager_vtable, "Local player manager unavailable.");
|
|
const auto begin = ptr(manager, 0x4c8), end = ptr(manager, 0x4d0);
|
|
require(end >= begin && end - begin == 8, "Expected one local player.");
|
|
local.player = ptr(begin);
|
|
require(ptr(local.player) == base + addr::engine::local_player_vtable &&
|
|
read<std::uint8_t>(local.player, 0x45) == 1 &&
|
|
!read<std::uint8_t>(local.player, 0x44),
|
|
"Local human player unavailable.");
|
|
local.entity = ptr(local.player, 0xb8);
|
|
require(local.entity && ptr(local.entity) == base + addr::engine::skater_entity_vtable &&
|
|
ptr(local.entity, 0xf8) == local.player &&
|
|
ptr(local.entity, 0x20) == local.context,
|
|
"Local skater is not spawned.");
|
|
local.root = root_matrix(local.entity);
|
|
return local;
|
|
}
|
|
|
|
// Client skater sources (engine::client_skater_source_vtable). Spawn creates +0x68
|
|
// through client_source_spawn::source_create from data+0xd0; the local player's source
|
|
// provides the parent bus.
|
|
struct Source {
|
|
std::uintptr_t address{}, data{}, parent{}, blueprint{}, entity{};
|
|
std::string name;
|
|
};
|
|
std::vector<Source> read_sources(std::uintptr_t base, std::uintptr_t context) {
|
|
const auto partition_offset = read<std::uint32_t>(base, addr::engine::context_partition_offset);
|
|
require(partition_offset <= 0x1000000, "Source partition offset changed.");
|
|
const auto partition = read<std::uint32_t>(context, partition_offset);
|
|
require(partition <= 4, "Source partition unavailable.");
|
|
std::vector<Source> result;
|
|
for (unsigned kind = 0; kind < 7; ++kind) {
|
|
const auto manager = ptr(base, addr::engine::source_managers + (std::uintptr_t{partition} * 7 + kind) * 8);
|
|
if (!manager)
|
|
continue;
|
|
require(ptr(manager) == base + addr::engine::source_manager_vtable && ptr(manager, 0x18) == context,
|
|
"Source manager differs.");
|
|
const auto begin = ptr(manager, 0xa0), end = ptr(manager, 0xa8);
|
|
require(begin <= end && (end - begin) % 0x18 == 0 && (end - begin) / 0x18 <= 64,
|
|
"Source collection exceeds bounds.");
|
|
for (auto at = begin; at < end && result.size() < 64; at += 0x18) {
|
|
const auto address = ptr(at, 8);
|
|
if (!address || ptr(address) != base + addr::engine::client_skater_source_vtable)
|
|
continue;
|
|
const auto data = ptr(address, 0x38), parent = ptr(address, 0x30);
|
|
if (!data || !parent || ptr(address, 0x20) != context || ptr(parent, 0x20) != context)
|
|
continue;
|
|
const auto blueprint = ptr(data, 0xd0) & ~std::uintptr_t{4};
|
|
if (!blueprint)
|
|
continue;
|
|
result.push_back(
|
|
{address, data, parent, blueprint, ptr(address, 0x68), name_at(ptr(blueprint, 0x18))});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Loaded named assets are registered per asset domain. Search the player
|
|
// blueprint's domain and its parents, as the native lookup (ai::asset_domain_lookup) does.
|
|
std::uintptr_t find_blueprint(std::uintptr_t base, std::uintptr_t anchor, std::string_view name) {
|
|
const auto find = game::native_data().find_asset;
|
|
require(find != nullptr, "Native asset lookup is unavailable.");
|
|
const std::string text(name);
|
|
auto domain = read<std::uint16_t>(anchor, 0x16);
|
|
std::set<std::uint16_t> visited;
|
|
while (domain < 0xbbf && visited.size() < 32 && visited.insert(domain).second) {
|
|
const auto owner = ptr(base, addr::engine::domain_owners + domain * std::uintptr_t{8});
|
|
if (!owner)
|
|
return 0;
|
|
if (const auto asset = find(domain, text.c_str())) {
|
|
require(derives(asset, base + entities::blueprint_type), "Named asset is not a blueprint.");
|
|
require(same_name(name_at(ptr(asset, 0x18)), name), "Named blueprint differs.");
|
|
return asset;
|
|
}
|
|
domain = read<std::uint16_t>(owner, 0x42);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
struct Skater {
|
|
unsigned id{};
|
|
std::uintptr_t entity{}, component{}, parent{}, context{};
|
|
Matrix spawn{};
|
|
ULONGLONG created{};
|
|
std::size_t next_sample{};
|
|
};
|
|
struct Request {
|
|
enum class Kind { spawn, clear, report, brain } kind{};
|
|
unsigned count{};
|
|
bool physics{}, brain{};
|
|
};
|
|
struct State {
|
|
std::mutex mutex;
|
|
std::vector<Skater> skaters;
|
|
std::vector<Request> requests;
|
|
std::uintptr_t context{};
|
|
unsigned next_id = 1;
|
|
bool listening{};
|
|
// Read by the destruction hook without the mutex; updated under it.
|
|
std::atomic<std::size_t> tracked{};
|
|
void publish() noexcept { tracked.store(skaters.size(), std::memory_order_release); }
|
|
};
|
|
State &state() {
|
|
static auto *s = new State;
|
|
return *s;
|
|
}
|
|
void entity_destroyed(std::uintptr_t entity) noexcept {
|
|
auto &s = state();
|
|
if (!s.tracked.load(std::memory_order_acquire))
|
|
return; // Every native entity destruction passes through here.
|
|
std::lock_guard lock(s.mutex);
|
|
const auto found = std::find_if(s.skaters.begin(), s.skaters.end(),
|
|
[&](const Skater &skater) { return skater.entity == entity; });
|
|
if (found == s.skaters.end())
|
|
return;
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI skater #{} was destroyed by the engine.",
|
|
found->id);
|
|
s.skaters.erase(found);
|
|
s.publish();
|
|
}
|
|
|
|
std::uintptr_t create_actor(std::uintptr_t base, std::uintptr_t parent, std::uintptr_t blueprint) {
|
|
// Mirrors the source creator (client_source_spawn::source_create). Each skater gets its own child bus:
|
|
// the entity constructor binds properties such as +0x578 on that bus, and
|
|
// the shared parent bus leaves them null (a crash once physics runs).
|
|
using CreateBus = std::uintptr_t (*)(std::uintptr_t, std::uintptr_t, std::uintptr_t, std::uintptr_t,
|
|
std::uint8_t, std::uintptr_t);
|
|
const auto bus =
|
|
reinterpret_cast<CreateBus>(base + ai::create_bus)(ptr(ptr(parent, 8), 0x48), parent, 0, 0, 0, 0);
|
|
require(bus != 0, "Engine did not create an AI skater entity bus.");
|
|
reinterpret_cast<int (*)(std::uintptr_t)>(base + ai::acquire_bus)(bus);
|
|
struct BusScope {
|
|
std::uintptr_t base, bus;
|
|
~BusScope() { reinterpret_cast<void (*)(std::uintptr_t)>(base + entities::release_reference)(bus); }
|
|
} bus_scope{base, bus};
|
|
// Local id 255 (no player association), no creation list and an identity
|
|
// creation transform; placement applies the real one. The descriptor owns
|
|
// internal list state and must be constructed and destroyed in place.
|
|
alignas(16) std::array<std::uint8_t, 0x190> descriptor{};
|
|
alignas(16) constexpr Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
|
using Init = void *(*)(void *, std::uintptr_t, std::uintptr_t, const void *);
|
|
reinterpret_cast<Init>(base + entities::descriptor_init)(descriptor.data(), 0, bus, identity.data());
|
|
struct DescriptorScope {
|
|
std::uintptr_t base;
|
|
void *data;
|
|
~DescriptorScope() {
|
|
reinterpret_cast<void (*)(void *)>(base + entities::descriptor_destroy)(
|
|
static_cast<std::uint8_t *>(data) + 0x10);
|
|
}
|
|
} scope{base, descriptor.data()};
|
|
const std::uint32_t id = 255;
|
|
const std::uintptr_t no_list = 0;
|
|
std::memcpy(descriptor.data() + 0x38, &id, sizeof(id));
|
|
descriptor[0x151] = 0;
|
|
std::memcpy(descriptor.data() + 0x158, &no_list, sizeof(no_list));
|
|
std::array<std::uintptr_t, 3> result{};
|
|
using Create = void *(*)(void *, void *, std::uintptr_t, std::uintptr_t, std::uintptr_t);
|
|
reinterpret_cast<Create>(base + entities::create_entity)(result.data(), descriptor.data(), blueprint, 0, 0);
|
|
if (result[1])
|
|
reinterpret_cast<void (*)(std::uintptr_t)>(base + entities::release_reference)(result[1]);
|
|
return result[0];
|
|
}
|
|
// Mode 1 is the default appearance, as requested by entities::source_apply_appearance when the source
|
|
// data has no recipe; the multiplayer remote actors request the same mode.
|
|
void initialize_appearance(std::uintptr_t base, std::uintptr_t entity, std::uintptr_t local_entity) {
|
|
const auto getter = reinterpret_cast<std::uintptr_t (*)(std::uintptr_t)>(base + entities::customization_component);
|
|
const auto customization = getter(entity), local_customization = getter(local_entity);
|
|
require(customization && local_customization && customization != local_customization &&
|
|
ptr(customization) == ptr(local_customization) && code(base, ptr(customization)) &&
|
|
ptr(ptr(customization, 0x18)) == entity,
|
|
"AI customization component unavailable.");
|
|
const std::uint8_t enabled = 1;
|
|
const std::uint32_t mode = 1;
|
|
require(write(customization + 0x110, &enabled, 1), "Cannot enable AI customization.");
|
|
if (ptr(customization, 0x120))
|
|
reinterpret_cast<void (*)(std::uintptr_t, const void *, std::uint32_t)>(
|
|
base + addr::engine::set_customization_flag)(customization + 0x120, &enabled, 1);
|
|
require(write(customization + 0x134, &mode, 4) && write(customization + 0x10a, &enabled, 1) &&
|
|
write(customization + 0x10d, &enabled, 1),
|
|
"Cannot request AI appearance.");
|
|
}
|
|
bool owned(std::uintptr_t base, const Skater &skater) {
|
|
return ptr(skater.entity) == base + addr::engine::skater_entity_vtable &&
|
|
ptr(skater.entity, 0x20) == skater.context &&
|
|
ptr(skater.entity, 0x40) == skater.parent && !ptr(skater.entity, 0xf8);
|
|
}
|
|
void destroy(std::uintptr_t base, const Skater &skater) {
|
|
// The shared destruction hook reports this entity to entity_destroyed().
|
|
if (owned(base, skater))
|
|
reinterpret_cast<void (*)(std::uintptr_t, std::uintptr_t)>(base + entities::destroy_entity)(
|
|
skater.entity, skater.parent);
|
|
}
|
|
void forget(std::uintptr_t entity) {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
std::erase_if(s.skaters, [&](const Skater &skater) { return skater.entity == entity; });
|
|
s.publish();
|
|
}
|
|
|
|
void verify_native(std::uintptr_t base) {
|
|
// install_entity_hooks has verified the creation, placement, appearance,
|
|
// physics-flag (entities::physics_c7_setter) and destruction functions used here.
|
|
prefix(base, ai::physics_source_setter, ai::physics_source_setter_prefix);
|
|
prefix(base, entities::physics_cb_setter, ai::physics_cb_setter_prefix);
|
|
prefix(base, ai::create_bus, ai::create_bus_prefix);
|
|
prefix(base, ai::acquire_bus, ai::acquire_bus_prefix);
|
|
prefix(base, ai::set_property_binding, ai::set_property_binding_prefix);
|
|
// Skater entity +0x458 (ai::binding_reader) reads the +0x578 binding unchecked once
|
|
// the skater's physics exists.
|
|
prefix(base, ai::binding_read_site, ai::binding_read_site_prefix);
|
|
require(ptr(base + addr::engine::skater_entity_vtable, 0x158) == base + entities::place_entity &&
|
|
ptr(base + addr::engine::skater_entity_vtable, 0x458) == base + ai::binding_reader,
|
|
"Skater entity interface differs.");
|
|
// Brain tree and skater AI component updates gated by the run flags.
|
|
require(ptr(base + ai::brain_tree_vtable, 0x138) == base + ai::brain_tree_update &&
|
|
ptr(base + ai::skater_ai_vtable, 0x138) == base + ai::skater_ai_update,
|
|
"AI component interfaces differ.");
|
|
prefix(base, ai::skater_ai_update, ai::skater_ai_update_prefix);
|
|
}
|
|
void require_job_context(std::uintptr_t base, std::uintptr_t context) {
|
|
require(read<std::uint8_t>(base, addr::engine::entity_creation_ready) != 0,
|
|
"Native entity creation is not ready.");
|
|
const auto tls_array = static_cast<std::uintptr_t>(__readgsqword(0x58));
|
|
const auto tls_index = read<std::uint32_t>(base, addr::engine::tls_index);
|
|
require(tls_index <= 4095, "Native TLS index changed.");
|
|
const auto tls = ptr(tls_array, std::uintptr_t{tls_index} * 8);
|
|
require(read<std::uint8_t>(tls, 0xb19) != 0 && ptr(tls, 0x550) == context,
|
|
"Waiting for the native client job context.");
|
|
}
|
|
|
|
// EATBrainTree component, collection slot = its ClientIndex (12) in the player
|
|
// blueprint. Factory ai::brain_tree_factory creates the bt_default instance (+0x60) at once
|
|
// through holder +0x40, but copies EnabledByDefault (data+0x82, false) into the
|
|
// run flag +0x70; its update ai::brain_tree_update ticks the tree only while that is set.
|
|
std::uintptr_t brain_component(std::uintptr_t base, std::uintptr_t entity) {
|
|
constexpr unsigned slot = 12;
|
|
const auto collection = ptr(entity, 0x70);
|
|
require(ptr(collection) == entity && read<std::uint8_t>(collection, 8) > slot,
|
|
"Skater component collection differs.");
|
|
const auto component = ptr(collection, 0x20 + slot * 0x20);
|
|
require(component && ptr(component) == base + ai::brain_tree_vtable && ptr(component, 0x18) == collection,
|
|
"Brain tree component differs.");
|
|
const auto data = ptr(component, 8);
|
|
require(ptr(component, 0x40) == base + ai::brain_tree_holder_vtable && data &&
|
|
(ptr(component, 0x50) & ~std::uintptr_t{4}) == (ptr(data, 0x70) & ~std::uintptr_t{4}),
|
|
"Brain tree holder differs.");
|
|
return component;
|
|
}
|
|
// SkaterAIComponent, slot 14 (factory ai::skater_ai_factory, 0x50 bytes). Its update
|
|
// ai::skater_ai_update (vtable +0x138) creates the 0x2c90-byte AI skater controller at +0x40
|
|
// (ai::ai_controller_create, bound to the skater's physics body) and runs it (ai::ai_controller_run) only
|
|
// while the byte at +0x48 is set; otherwise it destroys the controller. The
|
|
// constructor clears +0x48 and nothing in the player blueprint sets it.
|
|
std::uintptr_t driver_component(std::uintptr_t base, std::uintptr_t entity) {
|
|
constexpr unsigned slot = 14;
|
|
const auto collection = ptr(entity, 0x70);
|
|
require(ptr(collection) == entity && read<std::uint8_t>(collection, 8) > slot,
|
|
"Skater component collection differs.");
|
|
const auto component = ptr(collection, 0x20 + slot * 0x20);
|
|
require(component && ptr(component) == base + ai::skater_ai_vtable && ptr(component, 0x18) == collection,
|
|
"Skater AI component differs.");
|
|
return component;
|
|
}
|
|
void set_brain(std::uintptr_t base, std::uintptr_t entity, bool enabled) {
|
|
const auto component = brain_component(base, entity);
|
|
const auto driver = driver_component(base, entity);
|
|
require(!enabled || ptr(component, 0x60) != 0, "Brain tree instance was not created.");
|
|
const std::uint8_t value = enabled ? 1 : 0;
|
|
require(write(component + 0x70, &value, 1), "Cannot change the brain tree run flag.");
|
|
require(write(driver + 0x48, &value, 1), "Cannot change the skater AI controller flag.");
|
|
}
|
|
std::string brain_text(std::uintptr_t base, std::uintptr_t entity) {
|
|
const auto component = brain_component(base, entity);
|
|
const auto driver = driver_component(base, entity);
|
|
return std::format("brain {} (tree {}), AI controller {} ({})",
|
|
read<std::uint8_t>(component, 0x70) ? "running" : "paused",
|
|
ptr(component, 0x60) ? "created" : "missing",
|
|
read<std::uint8_t>(driver, 0x48) ? "on" : "off", ptr(driver, 0x40) ? "created" : "none");
|
|
}
|
|
|
|
Skater spawn_one(std::uintptr_t base, const Local &local, const Source &source, const Matrix &at, bool physics,
|
|
unsigned id) {
|
|
const auto entity = create_actor(base, source.parent, source.blueprint);
|
|
require(entity && entity != local.entity, "Engine did not create an AI skater.");
|
|
Skater skater;
|
|
skater.id = id;
|
|
skater.entity = entity;
|
|
skater.context = local.context;
|
|
skater.parent = ptr(entity, 0x40);
|
|
skater.spawn = at;
|
|
skater.created = GetTickCount64();
|
|
{
|
|
// Track before any further native call so an engine destruction is seen.
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
s.skaters.push_back(skater);
|
|
s.publish();
|
|
}
|
|
try {
|
|
require(ptr(entity) == base + addr::engine::skater_entity_vtable,
|
|
std::format("AI entity class differs (vtable RVA {:#x}).", ptr(entity) - base));
|
|
require(ptr(entity, 0x20) == local.context && !ptr(entity, 0xf8), "AI entity ownership differs.");
|
|
// The source creator writes its data+0xe1 flag into this binding, which
|
|
// the player blueprint routes to its CAS loader entities. Entity +0x458
|
|
// reads it unchecked once physics exists, so never start without it.
|
|
require(ptr(entity, 0x578) != 0, "Skater property binding (+0x578) is missing.");
|
|
reinterpret_cast<void (*)(std::uintptr_t, std::uint8_t)>(base + ai::set_property_binding)(
|
|
entity, read<std::uint8_t>(source.data, 0xe1));
|
|
skater.component = multiplayer::read_native_skater_component(readable, base, entity);
|
|
// Runs the entity's lifecycle callbacks and initializes its owned
|
|
// components, including the client-only brain tree and navigator.
|
|
using InitializePlacement = void (*)(std::uintptr_t, const void *, std::uintptr_t, std::uint8_t);
|
|
alignas(16) const auto matrix = at;
|
|
reinterpret_cast<InitializePlacement>(base + entities::initialize_placement)(entity, matrix.data(), 0, 1);
|
|
require((read<std::uint32_t>(entity, 0x28) & 8) != 0 && ptr(entity, 0x628) == skater.component,
|
|
"AI skater initialization did not finish.");
|
|
try {
|
|
initialize_appearance(base, entity, local.entity);
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} appearance: {}", id,
|
|
e.what());
|
|
}
|
|
// Source creation order: animation physics flags after placement.
|
|
using Setter = void (*)(std::uintptr_t, std::uint8_t);
|
|
reinterpret_cast<Setter>(base + entities::physics_c7_setter)(skater.component, 1);
|
|
reinterpret_cast<Setter>(base + ai::physics_source_setter)(
|
|
skater.component, read<std::uint8_t>(source.data, 0xe0));
|
|
if (physics) {
|
|
// The same pair of setters enables the local player's physics after
|
|
// its source creates and places it (client_source_spawn.cpp).
|
|
reinterpret_cast<Setter>(base + entities::physics_c7_setter)(skater.component, 0);
|
|
reinterpret_cast<Setter>(base + entities::physics_cb_setter)(skater.component, 0);
|
|
}
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
const auto found = std::find_if(s.skaters.begin(), s.skaters.end(),
|
|
[&](const Skater &tracked) { return tracked.entity == entity; });
|
|
require(found != s.skaters.end(), "AI skater was destroyed during initialization.");
|
|
found->component = skater.component;
|
|
return skater;
|
|
} catch (...) {
|
|
try {
|
|
destroy(base, skater);
|
|
} catch (...) {
|
|
}
|
|
forget(entity);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// Owned components in collection order, as vtable RVAs. The blueprint's
|
|
// ClientIndex values (EATBrainTree 12, SkaterAI 14, AIGoals 17) identify them.
|
|
std::string component_list(std::uintptr_t base, std::uintptr_t entity) {
|
|
const auto collection = ptr(entity, 0x70);
|
|
require(ptr(collection) == entity, "Skater component collection owner differs.");
|
|
const auto count = read<std::uint8_t>(collection, 8);
|
|
require(count <= 128, "Skater component count exceeds bounds.");
|
|
std::string result;
|
|
for (unsigned i = 0; i < count; ++i) {
|
|
const auto component = ptr(collection, 0x20 + std::uintptr_t{i} * 0x20);
|
|
const auto vtable = component ? ptr(component) : 0;
|
|
result += std::format("{}{}={:#x}", i ? " " : "", i, code(base, vtable) ? vtable - base : vtable);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void spawn(std::uintptr_t base, std::uintptr_t client, unsigned count, bool physics, bool brain) {
|
|
auto &s = state();
|
|
std::string detail;
|
|
require(multiplayer::install_entity_hooks(base, detail), "Native entity hooks unavailable: " + detail);
|
|
if (!s.listening) {
|
|
multiplayer::set_entity_destroyed_listener(&entity_destroyed);
|
|
s.listening = true;
|
|
}
|
|
verify_native(base);
|
|
const auto local = local_skater(base, client);
|
|
require_job_context(base, local.context);
|
|
const auto sources = read_sources(base, local.context);
|
|
// Prefer the source that created the local skater. A natively spawned
|
|
// skater need not have one; any player-blueprint source then supplies the
|
|
// parent bus and asset domain, as for multiplayer remote actors.
|
|
auto player_source = std::find_if(sources.begin(), sources.end(), [&](const Source &source) {
|
|
return source.entity == local.entity && same_name(source.name, player_blueprint);
|
|
});
|
|
if (player_source == sources.end())
|
|
player_source = std::find_if(sources.begin(), sources.end(),
|
|
[](const Source &source) { return same_name(source.name, player_blueprint); });
|
|
if (player_source == sources.end()) {
|
|
std::string listed;
|
|
for (const auto &source : sources)
|
|
listed += std::format("{}{} ({})", listed.empty() ? "" : "; ", source.name,
|
|
source.entity == local.entity ? "local player"
|
|
: source.entity ? "created"
|
|
: "not created");
|
|
throw Unavailable(std::format("No player skater source is loaded; {} source(s): {}", sources.size(),
|
|
listed.empty() ? "none" : listed));
|
|
}
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI skaters: using {} source {:#x} ({}).",
|
|
player_source->entity == local.entity ? "the local player's" : "a player", player_source->address,
|
|
player_source->name);
|
|
// Gameplay/Characters/CharacterBlueprint_Physics_Skater_AI is stale in the
|
|
// shipped game: it wires none of the entity properties (+0x568..+0x580)
|
|
// the current skater code binds, and +0x578 is read unchecked. The player
|
|
// blueprint carries the same AI stack (bt_default brain tree, client-only
|
|
// EATBrainTree, SkaterAIComponent), so AI skaters are built from it.
|
|
const auto blueprint = player_source->blueprint;
|
|
const auto entity_data = ptr(blueprint, 0x68) & ~std::uintptr_t{4};
|
|
const auto spawn_type = entity_data ? read<std::uint32_t>(entity_data, 0xc4) : ~0u;
|
|
std::size_t existing{};
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
existing = s.skaters.size();
|
|
s.context = local.context;
|
|
}
|
|
for (unsigned i = 0; i < count; ++i) {
|
|
require(existing + i < max_skaters, std::format("At most {} AI skaters can be active.", max_skaters));
|
|
// Line them up to the player's right, facing the same way.
|
|
auto at = local.root;
|
|
const float distance = 2.0f * static_cast<float>(existing + i + 1);
|
|
for (int axis = 0; axis < 3; ++axis)
|
|
at[12 + axis] += local.root[axis] * distance;
|
|
const auto id = s.next_id++;
|
|
const auto skater = spawn_one(base, local, *player_source, at, physics, id);
|
|
logging::log(logging::Level::info, logging::Channel::skater,
|
|
"AI skater #{} spawned at {}; entity={:#x}, component={:#x}, blueprint spawn type={}, "
|
|
"physics {}.",
|
|
id, position_text(at), skater.entity, skater.component, spawn_type,
|
|
physics ? "enabled" : "left disabled");
|
|
if (brain) {
|
|
try {
|
|
set_brain(base, skater.entity, true);
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI skater #{} {}.", id,
|
|
brain_text(base, skater.entity));
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} brain: {}", id,
|
|
e.what());
|
|
}
|
|
}
|
|
try {
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI skater #{} components: {}", id,
|
|
component_list(base, skater.entity));
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} components: {}", id,
|
|
e.what());
|
|
}
|
|
}
|
|
}
|
|
|
|
void clear(std::uintptr_t base) {
|
|
std::vector<Skater> skaters;
|
|
{
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
skaters = s.skaters;
|
|
}
|
|
std::size_t destroyed{};
|
|
for (const auto &skater : skaters) {
|
|
try {
|
|
destroy(base, skater);
|
|
++destroyed;
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} removal: {}", skater.id,
|
|
e.what());
|
|
}
|
|
forget(skater.entity);
|
|
}
|
|
logging::log(logging::Level::info, logging::Channel::skater, "Removed {} AI skater(s).", destroyed);
|
|
}
|
|
|
|
void toggle_brain(std::uintptr_t base, bool enabled) {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
if (s.skaters.empty())
|
|
logging::log(logging::Level::info, logging::Channel::skater, "No AI skaters are active.");
|
|
for (auto &skater : s.skaters) {
|
|
try {
|
|
require(owned(base, skater), "Ownership changed.");
|
|
set_brain(base, skater.entity, enabled);
|
|
// Restart the movement samples from the current position.
|
|
skater.spawn = root_matrix(skater.entity);
|
|
skater.created = GetTickCount64();
|
|
skater.next_sample = 0;
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI skater #{} {}.", skater.id,
|
|
brain_text(base, skater.entity));
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} brain: {}", skater.id,
|
|
e.what());
|
|
}
|
|
}
|
|
}
|
|
|
|
std::string describe(std::uintptr_t base, const Skater &skater) {
|
|
if (!owned(base, skater))
|
|
return std::format("AI skater #{}: ownership changed.", skater.id);
|
|
const auto root = root_matrix(skater.entity);
|
|
float moved{};
|
|
for (int axis = 0; axis < 3; ++axis)
|
|
moved += (root[12 + axis] - skater.spawn[12 + axis]) * (root[12 + axis] - skater.spawn[12 + axis]);
|
|
std::string physics = "unknown";
|
|
if (skater.component && ptr(skater.component) == base + addr::engine::skater_component_vtable)
|
|
physics = std::format("body={}, board={}, flags c7/cb={}/{}", ptr(skater.component, 0x70) ? "yes" : "no",
|
|
ptr(skater.component, 0x60) ? "yes" : "no",
|
|
read<std::uint8_t>(skater.component, 0xc7), read<std::uint8_t>(skater.component, 0xcb));
|
|
std::string brain;
|
|
try {
|
|
brain = brain_text(base, skater.entity);
|
|
} catch (const std::exception &e) {
|
|
brain = e.what();
|
|
}
|
|
return std::format("AI skater #{} t={:.1f}s at {}, moved {:.2f} m, {}, {}.", skater.id,
|
|
static_cast<double>(GetTickCount64() - skater.created) / 1000.0, position_text(root),
|
|
std::sqrt(moved), physics, brain);
|
|
}
|
|
void report(std::uintptr_t base, std::uintptr_t client) {
|
|
const auto local = local_skater(base, client);
|
|
const auto sources = read_sources(base, local.context);
|
|
logging::log(logging::Level::info, logging::Channel::skater, "AI report: {} skater source(s) on this map.",
|
|
sources.size());
|
|
for (const auto &source : sources)
|
|
logging::log(logging::Level::info, logging::Channel::skater, " source {:#x}: {} ({}).", source.address,
|
|
source.name,
|
|
source.entity == local.entity ? "local player" : source.entity ? "created" : "not created");
|
|
const auto player_source = std::find_if(sources.begin(), sources.end(),
|
|
[](const Source &source) { return same_name(source.name, player_blueprint); });
|
|
if (player_source != sources.end()) {
|
|
const auto blueprint = find_blueprint(base, player_source->blueprint, ai_blueprint);
|
|
logging::log(logging::Level::info, logging::Channel::skater, " {} {}.", ai_blueprint,
|
|
blueprint ? std::format("is loaded at {:#x}", blueprint) : std::string("was not found"));
|
|
}
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
if (s.skaters.empty())
|
|
logging::log(logging::Level::info, logging::Channel::skater, " No AI skaters are active.");
|
|
for (const auto &skater : s.skaters) {
|
|
try {
|
|
logging::log(logging::Level::info, logging::Channel::skater, " {}", describe(base, skater));
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, " AI skater #{}: {}", skater.id,
|
|
e.what());
|
|
}
|
|
}
|
|
}
|
|
|
|
void sample(std::uintptr_t base) {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
const auto now = GetTickCount64();
|
|
for (auto &skater : s.skaters) {
|
|
if (skater.next_sample >= sample_ms.size() || now - skater.created < sample_ms[skater.next_sample])
|
|
continue;
|
|
++skater.next_sample;
|
|
try {
|
|
logging::log(logging::Level::info, logging::Channel::skater, "{}", describe(base, skater));
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater, "AI skater #{} sample: {}", skater.id,
|
|
e.what());
|
|
}
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
void request_spawn(unsigned count, bool physics, bool brain) {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
s.requests.push_back({Request::Kind::spawn, count, physics, brain});
|
|
}
|
|
void request_brain(bool enabled) {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
s.requests.push_back({Request::Kind::brain, 0, false, enabled});
|
|
}
|
|
void request_clear() {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
s.requests.push_back({Request::Kind::clear});
|
|
}
|
|
void request_report() {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
s.requests.push_back({Request::Kind::report});
|
|
}
|
|
std::size_t active_count() noexcept {
|
|
auto &s = state();
|
|
std::lock_guard lock(s.mutex);
|
|
return s.skaters.size();
|
|
}
|
|
void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
|
|
auto &s = state();
|
|
std::vector<Request> requests;
|
|
std::uintptr_t context{};
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
requests.swap(s.requests);
|
|
context = s.context;
|
|
}
|
|
try {
|
|
// The destruction hook normally reports level teardown. If the client
|
|
// context changed without it, drop the stale pointers untouched.
|
|
std::uintptr_t current{};
|
|
if (context && (!readable(client + 8, ¤t, sizeof(current)) || current != context)) {
|
|
std::lock_guard lock(s.mutex);
|
|
if (!s.skaters.empty())
|
|
logging::log(logging::Level::info, logging::Channel::skater,
|
|
"Forgetting {} AI skater(s) after the map changed.", s.skaters.size());
|
|
s.skaters.clear();
|
|
s.publish();
|
|
s.context = 0;
|
|
}
|
|
for (const auto &request : requests) {
|
|
if (!ready) {
|
|
logging::log(logging::Level::warning, logging::Channel::skater,
|
|
"AI skaters: load a map and spawn your skater first.");
|
|
break;
|
|
}
|
|
try {
|
|
switch (request.kind) {
|
|
case Request::Kind::spawn:
|
|
spawn(base, client, request.count, request.physics, request.brain);
|
|
break;
|
|
case Request::Kind::brain:
|
|
toggle_brain(base, request.brain);
|
|
break;
|
|
case Request::Kind::clear:
|
|
clear(base);
|
|
break;
|
|
case Request::Kind::report:
|
|
report(base, client);
|
|
break;
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::error, logging::Channel::skater, "AI skaters: {}", e.what());
|
|
}
|
|
}
|
|
if (ready)
|
|
sample(base);
|
|
} catch (const std::exception &e) {
|
|
logging::log(logging::Level::error, logging::Channel::skater, "AI skaters: {}", e.what());
|
|
} catch (...) {
|
|
}
|
|
}
|
|
} // namespace dingosdk::ai_skaters
|