From 00c0bafb8088277996a8e442d80a3e6a7c9381cb Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sat, 13 Jun 2026 14:13:31 -0400 Subject: [PATCH] Client: Wire remote humans to dressed student proxies StudentProxy gains a registry-free per-avatar API: - SpawnProxy(x,y,z,yaw, Appearance, &skin) / DestroyProxy(actor) - Appearance { bool female; int house; } selects the per-gender outfit and per-house tint/crest overlay (kit_params_houses.h) human.cpp wiring (this is the Hogwarts stand-in for the native EntityFactory MafiaMP drives in its human.cpp): - Create spawns a proxy at the entity's streamed Transform; the actor + GC-guard index + skin component live in a new Avatar component - Update/UpdateRemoteHuman interpolate position and rotation and apply them via K2_TeleportTo; teleport-sized jumps snap instead of crawling - Remove destroys the proxy - Two fixes carried over from the broom work: read the local pawn's WORLD location via K2_GetActorLocation (RootComponent->RelativeLocation is parent-relative and stays static), and fetch Tracking only after all add()s so the player ref isn't written through a dangling archetype slot Appearance is plumbed but defaults to Gryffindor male; deriving it from the wire waits on the HumanSpawn message carrying it (a MAJOR bump). Broom mount/dismount stays on broom_proxy and re-applies on top. --- code/client/src/core/modules/human.cpp | 280 +++++++++++++++++-------- code/client/src/core/modules/human.h | 15 ++ code/client/src/core/student_proxy.cpp | 48 ++++- code/client/src/core/student_proxy.h | 27 +++ 4 files changed, 279 insertions(+), 91 deletions(-) diff --git a/code/client/src/core/modules/human.cpp b/code/client/src/core/modules/human.cpp index 3e0dfb1..dd2e825 100644 --- a/code/client/src/core/modules/human.cpp +++ b/code/client/src/core/modules/human.cpp @@ -4,8 +4,14 @@ #include +#include + #include +#include "core/student_proxy.h" +#include "core/ue4_natives.h" +#include "core/ue4_reflection.h" + #include "shared/messages/human/human_despawn.h" #include "shared/messages/human/human_self_update.h" #include "shared/messages/human/human_spawn.h" @@ -13,6 +19,103 @@ #include "shared/modules/human_sync.hpp" #include "shared/modules/mod.hpp" +#include + +namespace { + using namespace HogwartsMP::Core::UE4; + + // Weak-ref resolution against GC slot reuse (cf. StudentProxy). + AActor *AliveActor(AActor *actor, int32_t index) { + auto *arr = HogwartsMP::Core::gGlobals.objectArray; + if (!arr || !actor || index < 0) { + return nullptr; + } + auto *item = arr->IndexToObject(index); + if (!item || item->Object != reinterpret_cast(actor)) { + return nullptr; + } + return actor; + } + + int32_t ObjectIndex(AActor *actor) { + return actor ? static_cast(reinterpret_cast(actor)->GetUniqueID()) : -1; + } + + struct Vec3f { + float X, Y, Z; + }; + struct Rot3f { + float Pitch, Yaw, Roll; + }; + + Vec3f GetActorPos(void *actor) { + Vec3f loc{}; + CallUFunction(actor, "K2_GetActorLocation", &loc); + return loc; + } + + Rot3f GetActorRot(void *actor) { + Rot3f rot{}; + CallUFunction(actor, "K2_GetActorRotation", &rot); + return rot; + } + + // UE rotator (degrees) <-> quaternion, ported from FRotator::Quaternion() + // and FQuat::Rotator() so axis/sign conventions match the engine exactly. + glm::quat QuatFromRotator(const Rot3f &r) { + constexpr float kDegToRad = glm::pi() / 180.f; + const float sp = std::sin(r.Pitch * kDegToRad * 0.5f), cp = std::cos(r.Pitch * kDegToRad * 0.5f); + const float sy = std::sin(r.Yaw * kDegToRad * 0.5f), cy = std::cos(r.Yaw * kDegToRad * 0.5f); + const float sr = std::sin(r.Roll * kDegToRad * 0.5f), cr = std::cos(r.Roll * kDegToRad * 0.5f); + glm::quat q; + q.x = cr * sp * sy - sr * cp * cy; + q.y = -cr * sp * cy - sr * cp * sy; + q.z = cr * cp * sy - sr * sp * cy; + q.w = cr * cp * cy + sr * sp * sy; + return q; + } + + float NormalizeAxisDeg(float deg) { + deg = std::fmod(deg + 180.f, 360.f); + if (deg < 0.f) { + deg += 360.f; + } + return deg - 180.f; + } + + Rot3f RotatorFromQuat(const glm::quat &q) { + constexpr float kRadToDeg = 180.f / glm::pi(); + constexpr float kThreshold = 0.4999995f; // gimbal-lock guard (UE's SINGULARITY_THRESHOLD) + const float singularity = q.z * q.x - q.w * q.y; + const float yawY = 2.f * (q.w * q.z + q.x * q.y); + const float yawX = 1.f - 2.f * (q.y * q.y + q.z * q.z); + Rot3f r{}; + r.Yaw = std::atan2(yawY, yawX) * kRadToDeg; + if (singularity < -kThreshold) { + r.Pitch = -90.f; + r.Roll = NormalizeAxisDeg(-r.Yaw - 2.f * std::atan2(q.x, q.w) * kRadToDeg); + } + else if (singularity > kThreshold) { + r.Pitch = 90.f; + r.Roll = NormalizeAxisDeg(r.Yaw - 2.f * std::atan2(q.x, q.w) * kRadToDeg); + } + else { + r.Pitch = std::asin(2.f * singularity) * kRadToDeg; + r.Roll = std::atan2(-2.f * (q.w * q.x + q.y * q.z), 1.f - 2.f * (q.x * q.x + q.y * q.y)) * kRadToDeg; + } + return r; + } + + void TeleportActor(void *actor, const Vec3f &pos, const Rot3f &rot) { + struct { + Vec3f DestLocation; + Rot3f DestRotation; + bool ReturnValue; + } params{pos, rot, false}; + CallUFunction(actor, "K2_TeleportTo", ¶ms); + } +} // namespace + namespace HogwartsMP::Core::Modules { flecs::query Human::findAllHumans; @@ -24,36 +127,50 @@ namespace HogwartsMP::Core::Modules { world.component(); world.component(); world.component(); + world.component(); findAllHumans = world.query_builder().build(); world.system("UpdateLocalPlayer") - .each([](flecs::entity e, Tracking &tracking, Shared::Modules::HumanSync::UpdateData &metadata, LocalPlayer &lp, Framework::World::Modules::Base::Transform &tr) { - if (tracking.player) { - const auto rootComponent = tracking.player->PlayerController->Pawn->RootComponent; - tr.pos = {rootComponent->RelativeLocation.X, rootComponent->RelativeLocation.Y, rootComponent->RelativeLocation.Z}; - // tr.rot = {rootComponent->RelativeRotation.W, rootComponent->RelativeRotation.X, rootComponent->RelativeRotation.Y, rootComponent->RelativeRotation.Z}; + .each([](flecs::entity e, Tracking &tracking, Shared::Modules::HumanSync::UpdateData &, LocalPlayer &lp, Framework::World::Modules::Base::Transform &tr) { + if (!tracking.player) { + return; } + // PlayerController can be null transiently (e.g. during the + // fast-travel the game runs right after connecting). + const auto pc = tracking.player->PlayerController; + if (!pc) { + return; + } + const auto pawn = pc->Pawn; + if (!pawn) { + return; + } + // Read the pawn's WORLD location via the game's own getter — + // RootComponent->RelativeLocation is parent-relative, not the + // world position, so it stays static while the player moves. + const auto worldLoc = GetActorPos(pawn); + tr.pos = {worldLoc.X, worldLoc.Y, worldLoc.Z}; + tr.rot = QuatFromRotator(GetActorRot(pawn)); }); - world.system("UpdateRemoteHuman").each([](flecs::entity e, Tracking &tracking, Interpolated &interpolated) { - if (e.try_get() == nullptr) { - const auto rootComponent = tracking.player->PlayerController->Pawn->RootComponent; - auto updateData = e.try_get_mut(); - auto humanData = e.try_get_mut(); - const auto humanPos = rootComponent->RelativeLocation; - // const auto humanRot = rootComponent->RelativeRotation; - const auto newPos = interpolated.interpolator.GetPosition()->UpdateTargetValue({humanPos.X, humanPos.Y, humanPos.Z}); - // const auto newRot = interpolated.interpolator.GetRotation()->UpdateTargetValue({humanRot.W, humanRot.X, humanRot.Y, humanRot.Z}); - rootComponent->RelativeLocation = {newPos.x, newPos.y, newPos.z}; - // rootComponent->RelativeRotation = {newRot.w, newRot.x, newRot.y, newRot.z}; + world.system("UpdateRemoteHuman").each([](flecs::entity e, Interpolated &interpolated, Avatar &av) { + if (e.try_get() != nullptr) { + return; } + auto *target = AliveActor(av.actor, av.actorIndex); + if (!target) { + return; + } + const auto cur = GetActorPos(target); + const auto newPos = interpolated.interpolator.GetPosition()->UpdateTargetValue({cur.X, cur.Y, cur.Z}); + const auto newRot = interpolated.interpolator.GetRotation()->UpdateTargetValue(QuatFromRotator(GetActorRot(target))); + TeleportActor(target, {newPos.x, newPos.y, newPos.z}, RotatorFromQuat(newRot)); }); } void Human::Create(flecs::entity e, uint64_t spawnProfile) { - // auto info = Core::gApplication->GetEntityFactory()->RequestHuman(spawnProfile); - auto &trackingData = e.ensure(); + e.ensure(); auto &interp = e.ensure(); interp.interpolator.GetPosition()->SetCompensationFactor(1.5f); @@ -62,12 +179,31 @@ namespace HogwartsMP::Core::Modules { e.add(); e.set({Shared::Modules::Mod::MOD_PLAYER}); e.add(); - // todo spawn + + // Remote avatar: a student proxy at the entity's last known position. + const auto tr = e.try_get(); + const float x = tr ? static_cast(tr->pos.x) : 0.f; + const float y = tr ? static_cast(tr->pos.y) : 0.f; + const float z = tr ? static_cast(tr->pos.z) : 0.f; + + // TODO(appearance sync): derive gender/house from the wire once the + // HumanSpawn message carries appearance; defaults to Gryffindor male. + const StudentProxy::Appearance appearance{}; + UObjectBase *skin = nullptr; + auto *actor = StudentProxy::SpawnProxy(x, y, z, 0.f, appearance, &skin); + if (!actor) { + Framework::Logging::GetLogger("Human")->error("Remote avatar spawn failed"); + return; + } + auto &av = e.ensure(); + av.actor = actor; + av.actorIndex = ObjectIndex(actor); + av.skin = skin; } void Human::SetupLocalPlayer(Application *, flecs::entity e) { //e.world().defer_begin(); - auto &trackingData = e.ensure(); + e.add(); e.add(); e.add(); @@ -91,7 +227,10 @@ namespace HogwartsMP::Core::Modules { return; } - trackingData.player = localPlayer; + // Fetch the component only after ALL adds above: every add() moves the + // entity to a new archetype table, dangling earlier ensure() refs (the + // original code wrote tracking.player through one — it never landed). + e.ensure().player = localPlayer; auto es = e.try_get_mut(); es->modEvents.updateProc = [](Framework::Networking::NetworkPeer *peer, uint64_t guid, flecs::entity e) { @@ -107,39 +246,45 @@ namespace HogwartsMP::Core::Modules { } void Human::Update(flecs::entity e) { - const auto trackingData = e.try_get(); - if (!trackingData) { + auto av = e.try_get_mut(); + if (!av) { return; } - - auto updateData = e.try_get_mut(); - auto humanData = e.try_get_mut(); - auto rootComponent = trackingData->player->PlayerController->Pawn->RootComponent; - - // Update basic data const auto tr = e.try_get(); - if (e.try_get()) { - auto interp = e.try_get_mut(); - const auto humanPos = rootComponent->RelativeLocation; - // const auto humanRot = trackingData->human->GetRot(); - interp->interpolator.GetPosition()->SetTargetValue({humanPos.X, humanPos.Y, humanPos.Z}, tr->pos, HogwartsMP::Core::gApplication->GetTickInterval()); - // interp->interpolator.GetRotation()->SetTargetValue({humanRot.w, humanRot.x, humanRot.y, humanRot.z}, tr->rot, HogwartsMP::Core::gApplication->GetTickInterval()); + + auto *target = AliveActor(av->actor, av->actorIndex); + if (!target || !tr) { + return; + } + const auto cur = GetActorPos(target); + const auto curRot = QuatFromRotator(GetActorRot(target)); + const glm::vec3 delta = glm::vec3(tr->pos) - glm::vec3{cur.X, cur.Y, cur.Z}; + const bool farAway = glm::dot(delta, delta) > 5000.f * 5000.f; + auto interp = e.try_get_mut(); + if (interp && !farAway) { + interp->interpolator.GetPosition()->SetTargetValue({cur.X, cur.Y, cur.Z}, tr->pos, HogwartsMP::Core::gApplication->GetTickInterval()); + interp->interpolator.GetRotation()->SetTargetValue(curRot, tr->rot, HogwartsMP::Core::gApplication->GetTickInterval()); } else { - rootComponent->RelativeLocation = {tr->pos.x, tr->pos.y, tr->pos.z}; - // rootComponent->RelativeRotation = {tr->rot.w, tr->rot.x, tr->rot.y, tr->rot.z}; + // Streaming-in / teleport-sized jumps snap instead of crawling. + TeleportActor(target, {static_cast(tr->pos.x), static_cast(tr->pos.y), static_cast(tr->pos.z)}, RotatorFromQuat(tr->rot)); + if (interp) { + interp->interpolator.GetPosition()->SetTargetValue(tr->pos, tr->pos, HogwartsMP::Core::gApplication->GetTickInterval()); + interp->interpolator.GetRotation()->SetTargetValue(tr->rot, tr->rot, HogwartsMP::Core::gApplication->GetTickInterval()); + } } - - // todo additional sync data } void Human::Remove(flecs::entity e) { - auto trackingData = e.try_get_mut(); - if (!trackingData || e.try_get() != nullptr) { + if (e.try_get() != nullptr) { return; } - - // todo despawn + auto av = e.try_get_mut(); + if (!av) { + return; + } + StudentProxy::DestroyProxy(AliveActor(av->actor, av->actorIndex)); + *av = {}; } void Human::SetupMessages(Application *app) { @@ -150,26 +295,7 @@ namespace HogwartsMP::Core::Modules { return; } - // Setup tracking info Create(e, msg->GetSpawnProfile()); - - // Setup other components - auto updateData = e.try_get_mut(); - auto humanData = e.try_get_mut(); - // todo spawn info - - // set up client updates (NPC streaming) - // TODO disabled for now, we don't really need to stream NPCs atm -#if 0 - auto es = e.try_get_mut(); - es->modEvents.clientUpdateProc = [&](Framework::Networking::NetworkPeer *peer, uint64_t guid, flecs::entity e) { - Shared::Messages::Human::HumanClientUpdate humanUpdate; - humanUpdate.FromParameters(e.id()); - // set up sync data - peer->Send(humanUpdate, guid); - return true; - }; -#endif }); net->RegisterMessage(Shared::Messages::ModMessages::MOD_HUMAN_DESPAWN, [app](MafiaNet::RakNetGUID guid, Shared::Messages::Human::HumanDespawn *msg) { const auto e = app->GetWorldEngine()->GetEntityByServerID(msg->GetServerID()); @@ -203,34 +329,20 @@ namespace HogwartsMP::Core::Modules { auto frame = e.try_get_mut(); frame->modelHash = msg->GetSpawnProfile(); - - // update actor data }); } + void Human::UpdateTransform(flecs::entity e) { - const auto trackingData = e.try_get(); - if (!trackingData) { + auto av = e.try_get_mut(); + if (!av) { return; } - - // Update basic data const auto tr = e.try_get(); - if (e.try_get()) { - auto interp = e.try_get_mut(); - // // todo reset lerp - // const auto humanPos = trackingData->human->GetPos(); - // const auto humanRot = trackingData->human->GetRot(); - // interp->interpolator.GetPosition()->SetTargetValue({humanPos.x, humanPos.y, humanPos.z}, tr->pos, HogwartsMP::Core::gApplication->GetTickInterval()); - // interp->interpolator.GetRotation()->SetTargetValue({humanRot.w, humanRot.x, humanRot.y, humanRot.z}, tr->rot, HogwartsMP::Core::gApplication->GetTickInterval()); - } - else { - // SDK::ue::sys::math::C_Vector newPos = {tr->pos.x, tr->pos.y, tr->pos.z}; - // SDK::ue::sys::math::C_Quat newRot = {tr->rot.x, tr->rot.y, tr->rot.z, tr->rot.w}; - // SDK::ue::sys::math::C_Matrix transform = {}; - // transform.Identity(); - // transform.SetRot(newRot); - // transform.SetPos(newPos); - // trackingData->human->SetTransform(transform); + auto *target = AliveActor(av->actor, av->actorIndex); + if (!target || !tr) { + return; } + // Hard set (streaming-in / teleport) — skip interpolation. + TeleportActor(target, {static_cast(tr->pos.x), static_cast(tr->pos.y), static_cast(tr->pos.z)}, RotatorFromQuat(tr->rot)); } } // namespace HogwartsMP::Core::Modules diff --git a/code/client/src/core/modules/human.h b/code/client/src/core/modules/human.h index 721996d..88e8edf 100644 --- a/code/client/src/core/modules/human.h +++ b/code/client/src/core/modules/human.h @@ -9,12 +9,27 @@ #include +#include + +class AActor; +class UObjectBase; + namespace HogwartsMP::Core::Modules { struct Human { struct Tracking { SDK::UPlayer *player = nullptr; }; + // Remote-player avatar: a student proxy spawned on entity creation. + // actorIndex guards the pointer against GC slot reuse (cf. + // StudentProxy::ResolveAlive). skin is the head/hands component the + // outfit is master-posed to (kept for anim / future mount handling). + struct Avatar { + AActor *actor = nullptr; + int32_t actorIndex = -1; + UObjectBase *skin = nullptr; + }; + struct Interpolated { Framework::Utils::Interpolator interpolator = {}; }; diff --git a/code/client/src/core/student_proxy.cpp b/code/client/src/core/student_proxy.cpp index 6cdb980..c566722 100644 --- a/code/client/src/core/student_proxy.cpp +++ b/code/client/src/core/student_proxy.cpp @@ -148,8 +148,9 @@ namespace { struct StudentRef { AActor *actor; - int32_t objectIndex; // GUObjectArray slot (UObjectBase::GetUniqueID) - int32_t serialNumber; // FUObjectItem serial at spawn (0 = none allocated yet) + int32_t objectIndex; // GUObjectArray slot (UObjectBase::GetUniqueID) + int32_t serialNumber; // FUObjectItem serial at spawn (0 = none allocated yet) + UObjectBase *skinComp; // lifetime tied to actor }; // Game thread only; the render thread sees the size via g_activeCount. std::vector g_students; @@ -349,7 +350,7 @@ namespace { // ── The spawn itself ───────────────────────────────────────────────────── - AActor *SpawnStudent(const FVector &pos, float yawDeg, bool female = false, int house = 0) { + AActor *SpawnStudent(const FVector &pos, float yawDeg, bool female = false, int house = 0, UObjectBase **outSkinComp = nullptr) { auto *cls = reinterpret_cast(find_uobject("Class /Script/Phoenix.Biped_Character")); if (!cls) { Log()->error("Biped_Character class not found"); @@ -589,6 +590,10 @@ namespace { Log()->warn("Idle AnimSequence not found — student will hold ref pose"); } + if (outSkinComp) { + *outSkinComp = skinComp; + } + Log()->info("Student spawned at ({:.0f}, {:.0f}, {:.0f})", pos.X, pos.Y, pos.Z); return finalize(actor); } @@ -619,9 +624,10 @@ namespace { const FVector pos{loc.X + std::cos(rad) * kDistance, loc.Y + std::sin(rad) * kDistance, loc.Z}; // Alternate gender and cycle houses (spawn 8 to see all four houses // in both genders); +180 so the students face back toward the player. - const bool female = (i % 2 == 1); - const int house = (i / 2) % 4; - if (auto *actor = SpawnStudent(pos, rot.Yaw + offsetDeg + 180.f, female, house)) { + const bool female = (i % 2 == 1); + const int house = (i / 2) % 4; + UObjectBase *skinComp = nullptr; + if (auto *actor = SpawnStudent(pos, rot.Yaw + offsetDeg + 180.f, female, house, &skinComp)) { auto *obj = reinterpret_cast(actor); const auto index = static_cast(obj->GetUniqueID()); int32_t serial = 0; @@ -630,7 +636,7 @@ namespace { serial = item->GetSerialNumber(); } } - g_students.push_back({actor, index, serial}); + g_students.push_back({actor, index, serial, skinComp}); ++spawned; } } @@ -695,4 +701,32 @@ namespace HogwartsMP::Core::StudentProxy { size_t ActiveCount() { return g_activeCount.load(std::memory_order_relaxed); } + + AActor *FirstActive() { + for (const auto &ref : g_students) { + if (auto *actor = ResolveAlive(ref)) { + return actor; + } + } + return nullptr; + } + + UObjectBase *FirstActiveSkin() { + for (const auto &ref : g_students) { + if (ResolveAlive(ref)) { + return ref.skinComp; + } + } + return nullptr; + } + + AActor *SpawnProxy(float x, float y, float z, float yawDeg, Appearance appearance, UObjectBase **outSkinComp) { + return SpawnStudent({x, y, z}, yawDeg, appearance.female, std::clamp(appearance.house, 0, 3), outSkinComp); + } + + void DestroyProxy(AActor *actor) { + if (actor && GWorld && *GWorld && UWorld__DestroyActor) { + UWorld__DestroyActor(*GWorld, actor, false, true); + } + } } // namespace HogwartsMP::Core::StudentProxy diff --git a/code/client/src/core/student_proxy.h b/code/client/src/core/student_proxy.h index bfbaeae..33dfb5d 100644 --- a/code/client/src/core/student_proxy.h +++ b/code/client/src/core/student_proxy.h @@ -2,7 +2,19 @@ #include +class AActor; +class UObjectBase; + namespace HogwartsMP::Core::StudentProxy { + enum class House : int { Gryffindor = 0, Slytherin = 1, Ravenclaw = 2, Hufflepuff = 3 }; + + // Visual identity for a proxy. Maps to SpawnStudent's per-gender outfit + + // per-house tint/crest overlay (see kit_params_houses.h). + struct Appearance { + bool female = false; + int house = 0; // House value; clamped to [0,3] when applied + }; + // Thread-safe request setters; the actual spawn/despawn happens on the // next engine tick via ProcessPending (game thread required: SpawnActor, // StaticLoadObject and ProcessEvent are not safe off-thread). @@ -13,4 +25,19 @@ namespace HogwartsMP::Core::StudentProxy { void ProcessPending(); size_t ActiveCount(); + + // First still-alive spawned student (nullptr if none) — game thread only. + // Used by the broom experiment as the rider. + AActor *FirstActive(); + + // Skin (head+hands) SkeletalMeshComponent of FirstActive() — the outfit is + // master-posed to it, so playing an AnimSequence here drives the whole + // student. Only valid while FirstActive() is alive. + UObjectBase *FirstActiveSkin(); + + // Registry-free spawn/destroy for remote-player avatars (game thread + // only). appearance selects gender + house; outSkinComp receives the skin + // component for anim playback. + AActor *SpawnProxy(float x, float y, float z, float yawDeg, Appearance appearance, UObjectBase **outSkinComp); + void DestroyProxy(AActor *actor); } // namespace HogwartsMP::Core::StudentProxy