From 905c666481162b181fc352af0a0e1eed04c0a358 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 26 Jun 2026 22:46:35 -0400 Subject: [PATCH 1/2] Client: remote proxy locomotion (gait + in-air sync) Re-express the demo's proxy locomotion onto main's ClientHuman. The proxy is moved by the network position (movement disabled), so gait can't come from its velocity: derive ground speed from the per-packet position delta (EWMA) and drive ABP_RemoteAvatar's Speed input from the pak (smooth blend), falling back to single-node clips (ProxyLocomotion) if the ABP/blendspace is unavailable. In-air rides a new stateFlags byte (HumanSync) on HumanEntity via SerializeFields; mount/spell fields are reserved in the wire for the rest of Phase 4. Adds the anim reflection helpers (LoadAnimSequence/Asset, PlayAnimBlended, SetBlendSpaceInputOnSkin) and surfaces CharacterMesh0 from SpawnProxy. --- code/client/CMakeLists.txt | 1 + code/client/src/core/modules/human.cpp | 270 ++++++++++++++++-- code/client/src/core/modules/human.h | 51 +++- code/client/src/core/proxy_locomotion.cpp | 115 ++++++++ code/client/src/core/proxy_locomotion.h | 42 +++ code/client/src/core/snapshot_interpolator.h | 85 ++++++ code/client/src/core/student_proxy.cpp | 8 +- code/client/src/core/student_proxy.h | 6 +- .../src/sdk/reflection/ue4_reflection.cpp | 65 +++++ .../src/sdk/reflection/ue4_reflection.h | 13 + code/shared/game/human.h | 21 ++ code/shared/modules/human_sync.hpp | 30 ++ 12 files changed, 671 insertions(+), 36 deletions(-) create mode 100644 code/client/src/core/proxy_locomotion.cpp create mode 100644 code/client/src/core/proxy_locomotion.h create mode 100644 code/client/src/core/snapshot_interpolator.h create mode 100644 code/shared/modules/human_sync.hpp diff --git a/code/client/CMakeLists.txt b/code/client/CMakeLists.txt index d94b64b..ee5e6e7 100644 --- a/code/client/CMakeLists.txt +++ b/code/client/CMakeLists.txt @@ -28,6 +28,7 @@ set(HOGWARTSMP_CLIENT_FILES src/core/playground.cpp src/core/appearance_dump.cpp src/core/ccd_wire.cpp + src/core/proxy_locomotion.cpp src/core/student_proxy.cpp src/core/ui/chat.cpp diff --git a/code/client/src/core/modules/human.cpp b/code/client/src/core/modules/human.cpp index 4b90b57..abd026d 100644 --- a/code/client/src/core/modules/human.cpp +++ b/code/client/src/core/modules/human.cpp @@ -5,6 +5,7 @@ #include "core/appearance_dump.h" #include "core/application.h" #include "core/ccd_wire.h" +#include "core/proxy_locomotion.h" #include "core/student_proxy.h" #include "sdk/natives/ue4_natives.h" #include "sdk/reflection/ue4_reflection.h" @@ -18,6 +19,10 @@ #include +#include "UObject/UObjectArray.h" + +#include +#include #include namespace { @@ -134,6 +139,65 @@ namespace { } params {pos, rot, false}; CallUFunction(actor, "K2_TeleportTo", ¶ms); } + + // Drive locomotion with our packed ABP_RemoteAvatar (a Speed-steered 1D blendspace) instead of the + // single-node ProxyLocomotion clips — smooth gait blending. Flip false to force the tested fallback. + constexpr bool kUseDiyAbp = true; + const wchar_t *kRemoteAbpPath = L"/Game/Avatar/ABP_RemoteAvatar.ABP_RemoteAvatar_C"; + + // Pin a loaded asset into the GC root set so a cached UObject* can't dangle (the ABP class is cached + // in a static; an unrooted cached pointer can be freed by a collection and then dereferenced). + void RootObject(UObjectBase *obj) { + auto *arr = HogwartsMP::Core::gGlobals.objectArray; + if (!obj || !arr) { + return; + } + auto *item = arr->IndexToObject(static_cast(obj->GetUniqueID())); + if (item && item->Object == obj) { + item->SetFlags(EInternalObjectFlags::RootSet); + } + } + + // The rotation the proxy should hold. BP_RemoteAvatarCCC bakes its own mesh orientation (the mesh + // faces the actor's +X forward), so the actor faces the synced rotation directly. Single tuning seam + // if a build's proxy turns out to need a mesh-yaw correction. + glm::quat ProxyFacing(const glm::quat &synced) { + return synced; + } + + // Whether the pawn is airborne (jumping/falling) — drives the remote in-air anim. Reads + // CharacterMovementComponent::IsFalling (true off the ground in either direction). + bool DetectInAir(void *pawn) { + static auto *cmcCls = FindUClass("Class /Script/Engine.CharacterMovementComponent"); + if (!cmcCls) { + return false; + } + struct { + UClass *ComponentClass; + UObjectBase *ReturnValue; + } get {cmcCls, nullptr}; + CallUFunction(pawn, "GetComponentByClass", &get); + if (!get.ReturnValue) { + return false; + } + struct { + bool ReturnValue; + } falling {false}; + CallUFunction(get.ReturnValue, "IsFalling", &falling); + return falling.ReturnValue; + } + + // Switch a skeletal mesh into AnimBlueprint mode running animClass. UE4.27 names it SetAnimClass; some + // builds expose SetAnimInstanceClass — try both. + bool SetAnimClassOn(void *comp, UObjectBase *animClass) { + struct { + UObjectBase *NewClass; + } p {animClass}; + if (CallUFunction(comp, "SetAnimClass", &p)) { + return true; + } + return CallUFunction(comp, "SetAnimInstanceClass", &p); + } } // namespace namespace HogwartsMP::Core::Modules { @@ -154,10 +218,9 @@ namespace HogwartsMP::Core::Modules { } void ClientHuman::SpawnProxy() { - _interpolator.GetPosition()->SetCompensationFactor(1.5f); - - UObjectBase *ccc = nullptr; - auto *actor = StudentProxy::SpawnProxy(position.x, position.y, position.z, 0.f, &ccc); + UObjectBase *ccc = nullptr; + UObjectBase *mesh = nullptr; + auto *actor = StudentProxy::SpawnProxy(position.x, position.y, position.z, 0.f, &ccc, &mesh); if (!actor) { Framework::Logging::GetLogger("Human")->error("Remote avatar spawn failed"); return; @@ -165,6 +228,7 @@ namespace HogwartsMP::Core::Modules { _actor = actor; _actorIndex = ObjectIndex(actor); _ccc = ccc; + _mesh = mesh; _lastTarget = position; _lastTargetRot = rotation; _hasTarget = true; @@ -207,6 +271,10 @@ namespace HogwartsMP::Core::Modules { position = {worldLoc.X, worldLoc.Y, worldLoc.Z}; rotation = QuatFromRotator(GetActorRot(pc->Pawn)); + // Publish in-air state (rides the DeltaSerializer to the server, then everyone) so remote proxies + // play the fall clip; the vertical arc itself comes from the synced position. + SetFlag(Shared::Modules::HumanSync::InAir, DetectInAir(pc->Pawn)); + // On a CacheCCD rebuild (pointer change — assumes HL reallocates it), harvest + send the look; the // content signature suppresses redundant sends. auto *cccCls = FindUClass("Class /Script/CustomizableCharacter.CustomizableCharacterComponent"); @@ -240,40 +308,188 @@ namespace HogwartsMP::Core::Modules { } } - void ClientHuman::UpdateRemote(float tickInterval) { + void ClientHuman::UpdateRemote(float) { auto *target = AliveActor(_actor, _actorIndex); if (!target) { return; } - const auto curRaw = GetActorPos(target); - const glm::vec3 cur {curRaw.X, curRaw.Y, curRaw.Z}; - const glm::quat curRot = QuatFromRotator(GetActorRot(target)); - // A fresh replicated transform since the last leg? Set up a new interpolation (or snap on a - // teleport-sized jump). Detected by comparison — Deserialize updates position/rotation in - // place with no callback. + // A fresh replicated transform? Record a snapshot + feed the speed estimator. Detected by + // comparison — Deserialize updates position/rotation in place with no callback. A teleport-sized + // jump since the last packet snaps (clears the buffer) so we don't lerp across the gap. if (!_hasTarget || position != _lastTarget || rotation != _lastTargetRot) { - const glm::vec3 delta = position - cur; - const bool farAway = glm::dot(delta, delta) > 5000.f * 5000.f; - if (!farAway) { - _interpolator.GetPosition()->SetTargetValue(cur, position, tickInterval); - _interpolator.GetRotation()->SetTargetValue(curRot, rotation, tickInterval); - } - else { - // Streaming-in / teleport-sized jumps snap instead of crawling. - TeleportActor(target, {position.x, position.y, position.z}, RotatorFromQuat(rotation)); - _interpolator.GetPosition()->SetTargetValue(position, position, tickInterval); - _interpolator.GetRotation()->SetTargetValue(rotation, rotation, tickInterval); - } + const glm::vec3 moved = position - _lastTarget; + const bool jumped = _hasTarget && glm::dot(moved, moved) > 5000.f * 5000.f; + UpdatePacketSpeed(moved, jumped || !_hasTarget); + _interp.Push(position, ProxyFacing(rotation), jumped || !_hasTarget); _lastTarget = position; - _lastTargetRot = rotation; + _lastTargetRot = rotation; // raw synced rotation — fresh-packet detection compares the wire value _hasTarget = true; + } + + // Every frame: place the proxy at the snapshot-interpolated transform, decay the speed if stalled, + // drive the gait. (Sampling/gait run every frame, not just on packet-arrival frames.) Render ~2 + // packet intervals in the past so two snapshots always bracket the render time. + const float bufferMs = std::clamp(_intervalMs * 2.0f, 80.0f, 300.0f); + glm::vec3 rp; + glm::quat rr; + if (_interp.Sample(rp, rr, bufferMs)) { + TeleportActor(target, {rp.x, rp.y, rp.z}, RotatorFromQuat(rr)); + } + + // A stopped avatar sends no position packets (delta compression), so the last speed would stick and + // idle would keep playing the run/walk clip. Decay to standing after several missed sends (with + // headroom over the measured interval so a jittery link doesn't zero a still-moving avatar). + const float stopMs = std::max(300.f, _intervalMs * 3.f); + if (_havePacketTime && + std::chrono::duration(std::chrono::steady_clock::now() - _lastPacketTime).count() > stopMs) { + _speed = 0.0f; + _distAccum = 0.0f; + _timeAccum = 0.0f; + _havePacketTime = false; // next packet is treated as the first — clean resume, no huge-dt sample + } + // Gate anim driving until the async CCC body mesh is built (the position teleport above is safe + // pre-build; the graft / Speed writes are not). + if (ProxyReadyToDrive()) { + UpdateGait(); + } + } + + // Ground speed for gait selection from how far the replicated position moved between packets + // (horizontal only). EWMA Σdistance / Σtime: numerator and denominator share the decay, so noisy + // per-packet arrival time cancels (dividing a single distance by a single jittery dt flickers gait). + void ClientHuman::UpdatePacketSpeed(const glm::vec3 &moved, bool teleported) { + const auto now = std::chrono::steady_clock::now(); + if (teleported || !_havePacketTime) { + _speed = 0.0f; + _distAccum = 0.0f; + _timeAccum = 0.0f; + } + else { + const float dt = std::chrono::duration(now - _lastPacketTime).count(); + if (dt > 1e-3f && dt < 1.0f) { + const float horiz = std::sqrt(moved.x * moved.x + moved.y * moved.y); + constexpr float kDecay = 0.8f; // ~5-packet window + _distAccum = _distAccum * kDecay + horiz; + _timeAccum = _timeAccum * kDecay + dt; + if (_timeAccum > 1e-4f) { + _speed = _distAccum / _timeAccum; + } + const float intervalMs = dt * 1000.f; + _intervalMs = _intervalMs > 0.f ? _intervalMs + (intervalMs - _intervalMs) * 0.3f : intervalMs; + } + } + _lastPacketTime = now; + _havePacketTime = true; + } + + // DIY-pak locomotion: assign ABP_RemoteAvatar to the body mesh once, then steer its Speed var by the + // synced ground speed every frame (the blendspace blends idle→walk→jog→sprint). In-air rides the + // synced InAir flag into bInAir. The writes are no-ops until the ABP ships the vars. + void ClientHuman::UpdateGaitAbp() { + if (!_abpAssigned) { + static auto *clsMeta = FindUClass("Class /Script/CoreUObject.Class"); + static auto *abp = []() -> UObjectBase * { + auto *a = clsMeta ? LoadObjectByPath(clsMeta, kRemoteAbpPath) : nullptr; + RootObject(a); // pin against GC — a cached static that isn't rooted can dangle + return a; + }(); + if (!abp) { + _blendUnavailable = true; // pak/ABP missing — let UpdateGait fall back to single-node + Framework::Logging::GetLogger("Human")->warn("ABP_RemoteAvatar load FAILED — single-node fallback"); + return; + } + if (!SetAnimClassOn(_mesh, abp)) { + _blendUnavailable = true; // anim class couldn't be applied — use the known-good single-node path + Framework::Logging::GetLogger("Human")->warn("SetAnimClass not reflected — single-node fallback"); + return; + } + _abpAssigned = true; + Framework::Logging::GetLogger("Human")->info("ABP_RemoteAvatar assigned — locomotion via custom AnimBP"); + } + + // Ease the Speed fed to the blendspace per-FRAME toward the packet-stepped _speed (writing it raw + // steps the input at the packet rate → gait flicker). + const auto now = std::chrono::steady_clock::now(); + if (!_abpTickInit) { + _abpLastTick = now; + _abpTickInit = true; + } + const float dt = std::chrono::duration(now - _abpLastTick).count(); + _abpLastTick = now; + const float k = std::clamp(dt * 10.0f, 0.0f, 1.0f); // ~100ms follow + _abpSpeed += (_speed - _abpSpeed) * k; + + if (auto *inst = ReadObjectProperty(_mesh, "AnimScriptInstance")) { + SetFloatProperty(inst, "Speed", _abpSpeed); + SetBoolProperty(inst, "bInAir", IsInAir()); + } + } + + void ClientHuman::UpdateGait() { + if (!_mesh) { return; } + // DIY-pak AnimBP path (smooth blending). Falls through to single-node if the pak/ABP failed. + if (kUseDiyAbp && !_blendUnavailable) { + UpdateGaitAbp(); + return; + } + // Airborne: play the fall loop once and hold it until we land. Clear the ground latches so the + // gait/blend re-asserts on landing. + if (IsInAir()) { + if (!_airApplied) { + ProxyLocomotion::PlayAir(_mesh); + _airApplied = true; + _gaitApplied = false; + _moveBlendApplied = false; + } + return; + } + + const auto gait = ProxyLocomotion::GaitForSpeed(_speed, _gait); + + // Standing still (or no blendspace path): discrete clips. The 1D move blendspace bottoms out at a + // slow walk, not a true stand, so idle always uses the clip. + if (gait == ProxyLocomotion::Gait::Idle || _blendUnavailable) { + if (!_gaitApplied || gait != _gait || _moveBlendApplied) { + ProxyLocomotion::PlayGait(_mesh, gait); + _gait = gait; + _gaitApplied = true; + _airApplied = false; + _moveBlendApplied = false; + } + return; + } + + // Moving: play the blendspace once, then steer it by speed every frame. + if (!_moveBlendApplied) { + if (!ProxyLocomotion::PlayMoveBlend(_mesh)) { + _blendUnavailable = true; // asset missing — fall back to discrete clips next frame + return; + } + _moveBlendApplied = true; + _gaitApplied = false; + _airApplied = false; + } + if (!ProxyLocomotion::DriveMoveBlend(_mesh, _speed)) { + _blendUnavailable = true; // not reflected here — latch the fallback, re-assert a clip next frame + _moveBlendApplied = false; + } + } - const auto newPos = _interpolator.GetPosition()->UpdateTargetValue(cur); - const auto newRot = _interpolator.GetRotation()->UpdateTargetValue(curRot); - TeleportActor(target, {newPos.x, newPos.y, newPos.z}, RotatorFromQuat(newRot)); + // Async-spawn readiness gate: the CCC build assigns CharacterMesh0's SkeletalMesh a few frames after + // spawn; driving (graft / Speed) before then T-poses or crashes. + bool ClientHuman::ProxyReadyToDrive() { + if (_proxyReady) { + return true; + } + if (!_mesh || !ReadObjectProperty(_mesh, "SkeletalMesh")) { + return false; + } + _proxyReady = true; + Framework::Logging::GetLogger("Human")->info("CCC proxy build complete — locomotion enabled"); + return true; } void ClientHuman::DeallocReplica(MafiaNet::Connection_RM3 *) { diff --git a/code/client/src/core/modules/human.h b/code/client/src/core/modules/human.h index 3c631d9..8345e94 100644 --- a/code/client/src/core/modules/human.h +++ b/code/client/src/core/modules/human.h @@ -1,12 +1,13 @@ #pragma once #include "shared/game/human.h" - -#include +#include "core/proxy_locomotion.h" +#include "core/snapshot_interpolator.h" #include #include +#include #include class AActor; @@ -35,22 +36,60 @@ namespace HogwartsMP::Core::Modules { void SpawnProxy(); void UpdateLocal(float tickInterval); void UpdateRemote(float tickInterval); + // Smooth ground speed from the per-packet position delta (for gait selection). + void UpdatePacketSpeed(const glm::vec3 &moved, bool teleported); + // Drive the on-foot pose from the smoothed speed + the synced InAir flag. + void UpdateGait(); + // DIY-pak path: run ABP_RemoteAvatar (the packed 1D-blendspace AnimBP) on the body mesh and steer + // its Speed/bInAir each frame, instead of the single-node ProxyLocomotion clips. Smooth blending. + void UpdateGaitAbp(); + // Async-spawn readiness gate: true once CharacterMesh0's body mesh is built (driving before then + // T-poses/crashes). + bool ProxyReadyToDrive(); bool _isLocal = false; // Remote avatar: the BP_RemoteAvatarCCC proxy. actorIndex guards the pointer against GC slot reuse - // (cf. StudentProxy::ResolveAlive); ccc is its CustomizableCharacterComponent (the appearance target). + // (cf. StudentProxy::ResolveAlive); ccc is its CustomizableCharacterComponent (the appearance + // target); mesh is CharacterMesh0, the locomotion anim target. AActor *_actor = nullptr; int32_t _actorIndex = -1; UObjectBase *_ccc = nullptr; + UObjectBase *_mesh = nullptr; + bool _proxyReady = false; + + // Snapshot interpolation buffer: each fresh replicated transform is recorded with its arrival time; + // the proxy renders at (now - bufferDelay) by lerping the two snapshots bracketing it — smooth + // regardless of frame/packet rate, ~bufferDelay in the past. Replaces the framework error-chaser + // Interpolator (which snaps+freezes per packet). + SnapshotInterpolator _interp; - Framework::Utils::Interpolator _interpolator = {}; - // Last replicated transform we set up an interpolation leg toward, so a fresh packet is - // detected by comparison (there is no per-update callback). + // Last replicated transform, for fresh-packet detection by comparison (Deserialize updates + // position/rotation in place with no callback). glm::vec3 _lastTarget = glm::vec3(0.0f); glm::quat _lastTargetRot = glm::identity(); bool _hasTarget = false; + // On-foot locomotion: gait clip picked from the avatar's ground speed (derived from how far the + // replicated position moved between packets; the body itself is moved by the snapshot buffer). + ProxyLocomotion::Gait _gait = ProxyLocomotion::Gait::Idle; + bool _gaitApplied = false; // a discrete gait/idle clip is playing + bool _airApplied = false; // the in-air fall clip is playing (vs a ground gait) + bool _moveBlendApplied = false; // the move blendspace is playing (steered per frame) + bool _blendUnavailable = false; // latched if the ABP/blendspace path isn't available — single-node fallback + bool _abpAssigned = false; // ABP_RemoteAvatar assigned to the mesh once + // Speed = EWMA Σdistance / Σtime across recent packets (per-packet arrival jitter cancels between + // numerator and denominator). _abpSpeed eases _speed per-frame for the ABP input (no gait flicker). + float _speed = 0.0f; + float _abpSpeed = 0.0f; + float _distAccum = 0.0f; + float _timeAccum = 0.0f; + float _intervalMs = 0.0f; + std::chrono::steady_clock::time_point _lastPacketTime {}; + std::chrono::steady_clock::time_point _abpLastTick {}; + bool _havePacketTime = false; + bool _abpTickInit = false; + // Local-player appearance send state: the CacheCCD pointer last harvested (rebuild detection) and // the content signature last sent (change detection). void *_lastCacheCcd = nullptr; diff --git a/code/client/src/core/proxy_locomotion.cpp b/code/client/src/core/proxy_locomotion.cpp new file mode 100644 index 0000000..01cd4a8 --- /dev/null +++ b/code/client/src/core/proxy_locomotion.cpp @@ -0,0 +1,115 @@ +// On-foot locomotion clip selection for remote-avatar proxies — see proxy_locomotion.h. + +#include + +#include + +#include "proxy_locomotion.h" +#include "sdk/reflection/ue4_reflection.h" + +#include +#include + +namespace { + using namespace HogwartsMP::Core::UE4; + using HogwartsMP::Core::ProxyLocomotion::Gait; + + // Runtime object path per gait. All from the player Hu_BM_* family (the base biped skeleton — + // master-poses onto the student outfit). Idle must NOT be a gendered student clip (StuF/StuM): the + // proxy can be either gender, and a mismatched student idle fails to retarget → A-pose. The Hu_BM_* + // clips retarget onto both. + const std::unordered_map GAIT_PATHS = { + {Gait::Idle, L"/Game/Animation/Human/Hu_BM_Idle_Casual_Loop_anm.Hu_BM_Idle_Casual_Loop_anm"}, + {Gait::Walk, L"/Game/Animation/Human/Hu_BM_Walk_Loop_Fwd_anm.Hu_BM_Walk_Loop_Fwd_anm"}, + {Gait::Run, L"/Game/Animation/Human/Hu_BM_Jog_Loop_Fwd_anm.Hu_BM_Jog_Loop_Fwd_anm"}, + {Gait::Sprint, L"/Game/Animation/Human/Hu_BM_Sprint_Loop_Fwd_anm.Hu_BM_Sprint_Loop_Fwd_anm"}, + }; + const std::unordered_map GAIT_NAMES = { + {Gait::Idle, "idle"}, {Gait::Walk, "walk"}, {Gait::Run, "run"}, {Gait::Sprint, "sprint"}, + }; + + const wchar_t *AIR_PATH = L"/Game/Animation/Human/Hu_BM_Fall_Loop_v2_anm.Hu_BM_Fall_Loop_v2_anm"; + + // 1D forward-locomotion blendspace (walk->jog->run by speed) on the Hu_BM base-biped skeleton. + const wchar_t *MOVE_BLENDSPACE_PATH = L"/Game/Animation/Human/Hu_BM_MoveLoopFwd_Blendspace.Hu_BM_MoveLoopFwd_Blendspace"; + + // Representative speed per gait (UE cm/s), measured off the local player's GetVelocity at each gait. + // GaitForSpeed derives its band edges from the midpoints of these. + const std::unordered_map GAIT_SPEED = { + {Gait::Idle, 0.f}, {Gait::Walk, 150.f}, {Gait::Run, 475.f}, {Gait::Sprint, 700.f}, + }; + + // Crossfade window between locomotion assets (idle clip <-> move blendspace <-> fall clip). + constexpr float kBlendInSec = 0.2f; +} // namespace + +namespace HogwartsMP::Core::ProxyLocomotion { + float SpeedForGait(Gait gait) { + return GAIT_SPEED.at(gait); + } + + Gait GaitForSpeed(float speedCmPerSec, Gait current) { + // Band boundaries at the midpoints between adjacent GAIT_SPEED entries; the idle/walk boundary is + // a hard 40 cm/s floor so a barely-moving avatar still reads as walking and the stopped-decay + // (which drives speed to 0) can always reach idle. + if (speedCmPerSec < 40.f) { + return Gait::Idle; + } + const float bWalkRun = (GAIT_SPEED.at(Gait::Walk) + GAIT_SPEED.at(Gait::Run)) * 0.5f; + const float bRunSprint = (GAIT_SPEED.at(Gait::Run) + GAIT_SPEED.at(Gait::Sprint)) * 0.5f; + constexpr float kHys = 50.f; // sticky hysteresis — don't flip-flop on a boundary + int g = std::max(static_cast(current), static_cast(Gait::Walk)); + if (g == static_cast(Gait::Walk) && speedCmPerSec > bWalkRun + kHys) { + g = static_cast(Gait::Run); + } + if (g == static_cast(Gait::Run) && speedCmPerSec > bRunSprint + kHys) { + g = static_cast(Gait::Sprint); + } + if (g == static_cast(Gait::Sprint) && speedCmPerSec < bRunSprint - kHys) { + g = static_cast(Gait::Run); + } + if (g == static_cast(Gait::Run) && speedCmPerSec < bWalkRun - kHys) { + g = static_cast(Gait::Walk); + } + return static_cast(g); + } + + const char *Name(Gait gait) { + return GAIT_NAMES.at(gait); + } + + void PlayGait(UObjectBase *skinComp, Gait gait) { + auto *seq = LoadAnimSequence(GAIT_PATHS.at(gait)); + if (!seq) { + Framework::Logging::GetLogger("Loco")->warn("gait {} clip failed to load", GAIT_NAMES.at(gait)); + return; + } + PlayAnimBlended(skinComp, seq, true, kBlendInSec); + } + + void PlayAir(UObjectBase *skinComp) { + auto *seq = LoadAnimSequence(AIR_PATH); + if (!seq) { + Framework::Logging::GetLogger("Loco")->warn("in-air clip failed to load"); + return; + } + PlayAnimBlended(skinComp, seq, true, kBlendInSec); + } + + bool PlayMoveBlend(UObjectBase *skinComp) { + if (!skinComp) { + return false; + } + auto *bs = LoadAnimAsset(MOVE_BLENDSPACE_PATH); + if (!bs) { + Framework::Logging::GetLogger("Loco")->warn("move blendspace failed to load"); + return false; + } + PlayAnimBlended(skinComp, bs, true, kBlendInSec); + return true; + } + + bool DriveMoveBlend(UObjectBase *skinComp, float speedCmPerSec) { + return SetBlendSpaceInputOnSkin(skinComp, speedCmPerSec, 0.f); // 1D: speed on X + } +} // namespace HogwartsMP::Core::ProxyLocomotion diff --git a/code/client/src/core/proxy_locomotion.h b/code/client/src/core/proxy_locomotion.h new file mode 100644 index 0000000..5c2045f --- /dev/null +++ b/code/client/src/core/proxy_locomotion.h @@ -0,0 +1,42 @@ +#pragma once + +// On-foot locomotion clip selection for remote-avatar proxies. +// +// The proxy is moved by the network position (movement disabled), so its movement component velocity +// stays 0 and an AnimBP that reads velocity would idle. This module picks a gait from the avatar's +// ground speed (derived from successive synced positions) and either drives our packed +// ABP_RemoteAvatar's Speed input (smooth blend) or, as a fallback, plays an in-place locomotion clip +// raw on the body mesh — the network position drives the actual travel either way. +// +// Single source of truth for the gait clips + speed bands; used by the live remote-human sync +// (modules/human.cpp). Game thread only (PlayAnimation / StaticLoadObject). + +class UObjectBase; + +namespace HogwartsMP::Core::ProxyLocomotion { + enum class Gait { Idle = 0, Walk = 1, Run = 2, Sprint = 3 }; + + // Representative ground speed (UE cm/s) for a gait. + float SpeedForGait(Gait gait); + + // Map a horizontal ground speed to a gait band, sticky against `current` (hysteresis), so a speed + // hovering near a boundary doesn't flip-flop the clip (which would restart it = dropped-frame look). + Gait GaitForSpeed(float speedCmPerSec, Gait current); + + // Short name ("idle"/"walk"/"run"/"sprint") for logging. + const char *Name(Gait gait); + + // Play the looping locomotion clip for `gait` on the proxy's body mesh (single-node, crossfaded). + void PlayGait(UObjectBase *skinComp, Gait gait); + + // Play the looping in-air (fall) clip — used while a remote avatar is airborne; the vertical arc + // comes from the synced position. + void PlayAir(UObjectBase *skinComp); + + // ── Single-node move blendspace (smooth walk/jog/run) ───────────────────── + // Play the game's 1D forward-move blendspace looped on the mesh, then steer it by ground speed each + // frame. Used moving-only (the move space bottoms out at a slow walk, not a true idle, so callers + // hold a discrete idle clip at rest). + bool PlayMoveBlend(UObjectBase *skinComp); // false if the asset failed to load + bool DriveMoveBlend(UObjectBase *skinComp, float speedCmPerSec); // false if not reflected in this build +} // namespace HogwartsMP::Core::ProxyLocomotion diff --git a/code/client/src/core/snapshot_interpolator.h b/code/client/src/core/snapshot_interpolator.h new file mode 100644 index 0000000..34734c4 --- /dev/null +++ b/code/client/src/core/snapshot_interpolator.h @@ -0,0 +1,85 @@ +#pragma once + +// Snapshot (playback) interpolation for a networked transform: record timestamped (pos, rot) samples as +// they arrive, then render at (now - bufferDelay) by lerping the two samples that bracket that time. +// Smooth regardless of frame/packet rate and robust to packet-timing jitter, at the cost of showing the +// entity ~bufferDelay in the past — the Source/Quake approach. Contrast with extrapolation/dead-reckoning +// (predict forward from pos+velocity): zero added latency, but overshoots on direction changes — better +// for high-speed straight-line motion (e.g. brooms). +// +// Self-contained (glm + chrono + deque only), no engine deps, so it can be lifted into Framework::Utils +// later — just change the namespace. + +#include +#include + +#include +#include +#include + +namespace HogwartsMP::Core { + class SnapshotInterpolator { + public: + using Clock = std::chrono::steady_clock; + + // Record a freshly replicated transform (once per received packet). snap=true clears the buffer + // first (a teleport / stream-in), so the next Sample doesn't lerp across the gap. + void Push(const glm::vec3 &pos, const glm::quat &rot, bool snap) { + if (snap) { + _samples.clear(); + } + _samples.push_back({Clock::now(), pos, rot}); + while (_samples.size() > kMax) { + _samples.pop_front(); + } + } + + // Sample the render transform at (now - bufferDelayMs). Returns false if there are no samples yet. + // Holds at the oldest sample until the buffer fills, and at the newest if starved (no extrapolation + // — a stopped entity must not drift; use dead reckoning where forward prediction is wanted). + bool Sample(glm::vec3 &outPos, glm::quat &outRot, float bufferDelayMs) const { + if (_samples.empty()) { + return false; + } + const auto renderT = Clock::now() - std::chrono::duration_cast( + std::chrono::duration(bufferDelayMs)); + if (renderT <= _samples.front().t) { + outPos = _samples.front().pos; + outRot = _samples.front().rot; + return true; + } + if (renderT >= _samples.back().t) { + outPos = _samples.back().pos; + outRot = _samples.back().rot; + return true; + } + for (size_t i = 0; i + 1 < _samples.size(); ++i) { + const auto &a = _samples[i]; + const auto &b = _samples[i + 1]; + if (renderT >= a.t && renderT <= b.t) { + const float span = std::chrono::duration(b.t - a.t).count(); + const float alpha = span > 1e-3f ? std::chrono::duration(renderT - a.t).count() / span : 1.0f; + outPos = glm::mix(a.pos, b.pos, alpha); + outRot = glm::slerp(a.rot, b.rot, alpha); + return true; + } + } + outPos = _samples.back().pos; // unreachable (renderT is between front and back) + outRot = _samples.back().rot; + return true; + } + + void Reset() { + _samples.clear(); + } + + private: + struct Entry { + Clock::time_point t; + glm::vec3 pos; + glm::quat rot; + }; + std::deque _samples; + static constexpr size_t kMax = 16; + }; +} // namespace HogwartsMP::Core diff --git a/code/client/src/core/student_proxy.cpp b/code/client/src/core/student_proxy.cpp index 185f912..dd6654d 100644 --- a/code/client/src/core/student_proxy.cpp +++ b/code/client/src/core/student_proxy.cpp @@ -324,12 +324,18 @@ namespace HogwartsMP::Core::StudentProxy { return nullptr; } - AActor *SpawnProxy(float x, float y, float z, float yawDeg, UObjectBase **outCcc) { + AActor *SpawnProxy(float x, float y, float z, float yawDeg, UObjectBase **outCcc, UObjectBase **outMesh) { if (outCcc) { *outCcc = nullptr; } + if (outMesh) { + *outMesh = nullptr; + } UObjectBase *body = nullptr; auto *actor = SpawnCccProxy({x, y, z}, yawDeg, &body); + if (outMesh) { + *outMesh = body; + } if (actor && outCcc) { struct { UClass *ComponentClass; diff --git a/code/client/src/core/student_proxy.h b/code/client/src/core/student_proxy.h index c2f3efc..7793eef 100644 --- a/code/client/src/core/student_proxy.h +++ b/code/client/src/core/student_proxy.h @@ -27,7 +27,9 @@ namespace HogwartsMP::Core::StudentProxy { UObjectBase *FirstActiveSkin(); // Spawn a remote-avatar proxy (BP_RemoteAvatarCCC from the pak). outCcc receives the proxy's - // CustomizableCharacterComponent so the caller can apply the player's CCD (see CcdWire). Game thread only. - AActor *SpawnProxy(float x, float y, float z, float yawDeg, UObjectBase **outCcc); + // CustomizableCharacterComponent so the caller can apply the player's CCD (see CcdWire); outMesh + // (optional) receives CharacterMesh0, the body skeletal mesh used as the locomotion anim target. + // Game thread only. + AActor *SpawnProxy(float x, float y, float z, float yawDeg, UObjectBase **outCcc, UObjectBase **outMesh = nullptr); void DestroyProxy(AActor *actor); } // namespace HogwartsMP::Core::StudentProxy diff --git a/code/client/src/sdk/reflection/ue4_reflection.cpp b/code/client/src/sdk/reflection/ue4_reflection.cpp index 2c6ce59..04147c2 100644 --- a/code/client/src/sdk/reflection/ue4_reflection.cpp +++ b/code/client/src/sdk/reflection/ue4_reflection.cpp @@ -362,4 +362,69 @@ namespace HogwartsMP::Core::UE4 { } return false; } + + UObjectBase *LoadAnimSequence(const wchar_t *path) { + static std::unordered_map cache; + if (auto it = cache.find(path); it != cache.end()) { + return it->second; + } + static auto *seqCls = FindUClass("Class /Script/Engine.AnimSequence"); + auto *seq = seqCls ? LoadObjectByPath(seqCls, path) : nullptr; + cache[path] = seq; // negatives cached too — a missing asset won't reload-spam each frame + return seq; + } + + UObjectBase *LoadAnimAsset(const wchar_t *path) { + // Validate against the AnimationAsset base so a BlendSpace / BlendSpace1D / AnimSequence all pass. + static std::unordered_map cache; + if (auto it = cache.find(path); it != cache.end()) { + return it->second; + } + static auto *assetCls = FindUClass("Class /Script/Engine.AnimationAsset"); + auto *obj = assetCls ? LoadObjectByPath(assetCls, path) : nullptr; + cache[path] = obj; + return obj; + } + + bool PlayAnimBlended(UObjectBase *skin, UObjectBase *asset, bool loop, float blendInSec) { + if (!skin || !asset) { + return false; + } + // SetAnimationAsset on the existing single-node instance snapshots the current pose and blends + // into the new asset over InBlendInTime — the crossfade plain PlayAnimation lacks. Needs an + // instance to already exist (the proxy's baked AnimBP / a prior PlayAnimation creates one), so + // the first-ever play / a build without SetAnimationAsset falls through to the instant path. + if (auto *inst = ReadObjectProperty(skin, "AnimScriptInstance")) { + struct { + UObjectBase *NewAsset; + bool bIsLooping; + float InBlendInTime; + } p {asset, loop, blendInSec}; + if (CallUFunction(inst, "SetAnimationAsset", &p)) { + return true; + } + } + struct { + UObjectBase *NewAnimToPlay; + bool bLooping; + } play {asset, loop}; + CallUFunction(skin, "PlayAnimation", &play); // no instance yet, or not reflected — instant swap + return false; + } + + bool SetBlendSpaceInputOnSkin(UObjectBase *skin, float x, float y) { + if (!skin) { + return false; + } + // The single-node instance (created by PlayAnimation / a blendspace PlayAnimBlended) owns the + // blendspace coordinate. + auto *inst = ReadObjectProperty(skin, "AnimScriptInstance"); + if (!inst) { + return false; + } + struct { + float X, Y, Z; + } in {x, y, 0.f}; // FVector InBlendInput (UE4.27 floats) + return CallUFunction(inst, "SetBlendSpaceInput", &in); + } } // namespace HogwartsMP::Core::UE4 diff --git a/code/client/src/sdk/reflection/ue4_reflection.h b/code/client/src/sdk/reflection/ue4_reflection.h index 564cd6d..0969c79 100644 --- a/code/client/src/sdk/reflection/ue4_reflection.h +++ b/code/client/src/sdk/reflection/ue4_reflection.h @@ -101,4 +101,17 @@ namespace HogwartsMP::Core::UE4 { // True if cls or any superclass has the given short name. bool IsSubclassOf(UClass *cls, const char *baseName); + + // ── Animation playback (proxy locomotion / mount poses) ────────────────── + // Load an AnimSequence by path, cached (negatives cached too — a missing asset won't reload-spam). + UObjectBase *LoadAnimSequence(const wchar_t *path); + // Load any AnimationAsset (AnimSequence / BlendSpace / BlendSpace1D) by path, cached. + UObjectBase *LoadAnimAsset(const wchar_t *path); + // Play an anim asset single-node on a skeletal mesh, crossfading from the current pose over + // blendInSec (vs PlayAnimation's instant swap). Returns true if the blended path was taken; falls + // back to an instant PlayAnimation if no instance exists yet / SetAnimationAsset isn't reflected. + bool PlayAnimBlended(UObjectBase *skin, UObjectBase *asset, bool loop, float blendInSec); + // Set a single-node blendspace's input coordinate (X = speed for the 1D move space). Returns false + // if the mesh has no single-node instance or SetBlendSpaceInput isn't reflected in this build. + bool SetBlendSpaceInputOnSkin(UObjectBase *skin, float x, float y); } // namespace HogwartsMP::Core::UE4 diff --git a/code/shared/game/human.h b/code/shared/game/human.h index 809d5e3..ffeb5a7 100644 --- a/code/shared/game/human.h +++ b/code/shared/game/human.h @@ -1,6 +1,7 @@ #pragma once #include "shared/modules/appearance.hpp" +#include "shared/modules/human_sync.hpp" #include @@ -23,14 +24,34 @@ namespace HogwartsMP::Shared { uint64_t spawnProfile = 0; // Display name, shown in chat and exposed to scripting. std::string nickname; + // Per-tick boolean state (in-air/mounted/…), packed into one byte; written by the owning client, + // relayed to everyone else. Its own delta Field so it can toggle without re-sending the string. + uint8_t stateFlags = 0; + // Per-tick string-ish state (broom/spell ids, aim pitch); written by the owning client, relayed. + Modules::HumanSync::UpdateData data {}; // Worn appearance, set server-side; rides the construction snapshot. MUST stay the trailing field // (SerializeCcd's containment relies on it). Modules::CcdProfile ccd; + bool IsInAir() const { + return (stateFlags & Modules::HumanSync::InAir) != 0; + } + bool IsMounted() const { + return (stateFlags & Modules::HumanSync::Mounted) != 0; + } + void SetFlag(Modules::HumanSync::StateFlag flag, bool on) { + stateFlags = on ? (stateFlags | flag) : (stateFlags & ~flag); + } + void OnSerializeConstruction(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(spawnProfile); fields.Field(nickname); Modules::SerializeCcd(fields, ccd); } + + void SerializeFields(Framework::Networking::Replication::FieldSerializer &fields) override { + fields.Field(stateFlags); + fields.Field(data); + } }; } // namespace HogwartsMP::Shared diff --git a/code/shared/modules/human_sync.hpp b/code/shared/modules/human_sync.hpp new file mode 100644 index 0000000..9d08ae5 --- /dev/null +++ b/code/shared/modules/human_sync.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace HogwartsMP::Shared::Modules { + struct HumanSync { + // Packed boolean per-tick state. Its own delta Field (one byte): these toggle often (InAir on + // every jump), so keeping them out of UpdateData means a jump doesn't re-send the string. Add new + // boolean states (wand drawn, hooded, crouch, swim) as further bits here. + enum StateFlag : uint8_t { + Mounted = 1u << 0, // riding a broom (broom sync — Phase 4 commit 11) + InAir = 1u << 1, // jumping/falling — proxy plays the fall clip; vertical arc from synced pos + Dodge = 1u << 2, // mid dodge-roll; proxy plays the roll montage on the rising edge (spell commit) + Cast = 1u << 3, // casting; proxy plays the cast montage on the rising edge (spell commit) + Lumos = 1u << 4, // wand light on; sustained state (spell commit) + }; + + // Per-tick string-ish payload replicated through HumanEntity. Its own delta Field. POD only + // (delta-tracked as a whole by VariableDeltaSerializer), so it must stay trivially copyable. + struct UpdateData { + // Which broom (1-based id into the MountClasses allowlist; 0 = default/unknown). Broom commit. + uint8_t mountId = 0; + // Which spell (1-based id into the SpellRecords allowlist; 0 = none/unknown). Spell commit. + uint8_t spellId = 0; + // Aim pitch (deg, -90..90) for an in-flight cast — the proxy rebuilds the cast direction from + // synced facing-yaw + this. Spell commit. + int8_t aimPitch = 0; + }; + }; +} // namespace HogwartsMP::Shared::Modules From 9b2cb2bcf293c43a74ade20edc3a09a4a9fab095 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 26 Jun 2026 23:41:34 -0400 Subject: [PATCH 2/2] Server: cycle gaits + in-air in /walknpcs (locomotion test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Human.setInAir(bool) scripting binding (sets the InAir state flag, relayed to clients) and upgrade /walknpcs to orbit spawned NPCs through idle→walk→run→ sprint→jump with facing, so remote-proxy locomotion can be eyeballed with one client (/spawnnpc, then /walknpcs to start/stop, /clearnpcs to remove). --- code/server/src/core/builtins/human.cpp | 7 ++++ code/server/src/core/builtins/human.h | 4 ++ resources/gamemode/server/main.js | 50 +++++++++++++++++++------ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/code/server/src/core/builtins/human.cpp b/code/server/src/core/builtins/human.cpp index 2040090..1c24614 100644 --- a/code/server/src/core/builtins/human.cpp +++ b/code/server/src/core/builtins/human.cpp @@ -194,6 +194,12 @@ namespace HogwartsMP::Scripting { peer->BroadcastRPC(upd); } + void Human::SetInAir(bool inAir) { + if (auto *e = ResolveHuman(GetId())) { + e->SetFlag(Shared::Modules::HumanSync::InAir, inAir); + } + } + v8pp::class_ &Human::GetClass(v8::Isolate *isolate) { auto it = _classes.find(isolate); if (it != _classes.end()) { @@ -210,6 +216,7 @@ namespace HogwartsMP::Scripting { .ctor() .function("toString", &Human::ToString) .function("sendChat", &Human::SendChat) + .function("setInAir", &Human::SetInAir) .function("emit", &Human::Emit) .function("setData", &Human::SetData) .function("hasData", &Human::HasData) diff --git a/code/server/src/core/builtins/human.h b/code/server/src/core/builtins/human.h index 30c06da..6dba10b 100644 --- a/code/server/src/core/builtins/human.h +++ b/code/server/src/core/builtins/human.h @@ -24,6 +24,10 @@ namespace HogwartsMP::Scripting { void SendChat(std::string message); + // Set/clear the in-air state flag on the entity (relayed to clients so the proxy plays the fall + // clip). Used by the /walknpcs locomotion test harness; the vertical arc comes from position. + void SetInAir(bool inAir); + // Emit a named event to this player's client scripts (Core.Events). payloadJson is sent as-is // and JSON.parsed on the client into the handler's single argument; pass JSON text. void Emit(std::string eventName, std::string payloadJson); diff --git a/resources/gamemode/server/main.js b/resources/gamemode/server/main.js index 34c8149..913b5c3 100644 --- a/resources/gamemode/server/main.js +++ b/resources/gamemode/server/main.js @@ -200,6 +200,9 @@ Events.on("chatCommand", (player, message, command, args) => { if (npcWalkTimer) { clearInterval(npcWalkTimer); npcWalkTimer = null; + // Clear in-air so an NPC stopped mid-jump doesn't stay stuck in the fall anim (the + // per-tick re-assert that normally clears it is gone once the timer stops). + for (const npc of npcs) npc.setInAir(false); player.sendChat("[DEV] NPCs stopped"); break; } @@ -207,26 +210,51 @@ Events.on("chatCommand", (player, message, command, args) => { player.sendChat("[DEV] No NPCs to walk — use /spawnnpc first"); break; } - // Orbit each NPC around an absolute center (the player's current spot) - // with a wide radius so the motion is unmistakable. Setting absolute - // positions avoids the tiny oscillation a += accumulator produced. + // Orbit each NPC around the player's spot, cycling the gait speed bands (+ a jump) so the + // remote-avatar locomotion sync can be eyeballed with a single client: orbit linear speed + // drives the client's gait clip (walk/run/sprint), the rotation set to the movement tangent + // exercises facing, and setInAir() toggles the in-air fall anim + a small Z hop. const c = player.position; const center = { x: c.x, y: c.y, z: c.z }; - const RADIUS = 300; // ~3 m + const RADIUS = 400; // ~4 m + const DT = 0.05; // 50 ms tick, in seconds + // { linear speed cm/s, seconds, inAir } per phase (objects, not a tuple array, so checkJs + // infers speed/dur as numbers). + const PHASES = [ + { speed: 0, dur: 2, inAir: false }, + { speed: 150, dur: 4, inAir: false }, + { speed: 475, dur: 4, inAir: false }, + { speed: 700, dur: 4, inAir: false }, + { speed: 475, dur: 1.2, inAir: true }, + ]; let angle = 0; + let phase = 0; + let phaseT = 0; npcWalkTimer = setInterval(() => { - angle += 0.05; + const { speed, dur, inAir } = PHASES[phase]; + phaseT += DT; + if (phaseT >= dur) { + phaseT = 0; + phase = (phase + 1) % PHASES.length; + } + angle += (speed / RADIUS) * DT; // linear -> angular + const hop = inAir ? Math.sin(Math.PI * (phaseT / dur)) * 120 : 0; // little jump arc for (let i = 0; i < npcs.length; i++) { - const phase = angle + (i * 2 * Math.PI) / npcs.length; - // Vector3.set() — assigning pos.x/.y/.z directly only writes a - // JS shadow (x/y/z are SetNativeDataProperty), leaving the - // underlying C++ vector unchanged, so the move would be a no-op. + const a = angle + (i * 2 * Math.PI) / npcs.length; + // Vector3.set() — assigning pos.x/.y/.z directly only writes a JS shadow + // (SetNativeDataProperty), leaving the C++ vector unchanged → the move is a no-op. const pos = npcs[i].position; - pos.set(center.x + Math.cos(phase) * RADIUS, center.y + Math.sin(phase) * RADIUS, center.z); + pos.set(center.x + Math.cos(a) * RADIUS, center.y + Math.sin(a) * RADIUS, center.z + hop); npcs[i].position = pos; + // Face the movement tangent (Euler yaw about Z); the client reads it back as facing. + const yawDeg = (Math.atan2(Math.cos(a), -Math.sin(a)) * 180) / Math.PI; + const rot = npcs[i].rotation; + rot.set(0, 0, yawDeg); + npcs[i].rotation = rot; + npcs[i].setInAir(inAir); // re-asserted each tick → clears itself when the jump ends } }, 50); - player.sendChat(`[DEV] Walking ${npcs.length} NPC(s) in a circle (run /walknpcs again to stop)`); + player.sendChat(`[DEV] Walking ${npcs.length} NPC(s): idle→walk→run→sprint→jump (run /walknpcs again to stop)`); break; }