Two tightly-related refinements that complete the speed-scaling and
observer-stop story:
1. Local player animation speed now reflects ForwardSpeed.
UpdatePlayerAnimation previously called SetCycle(style, motion) with
the default speedMod=1.0, so the local character's anim played at
fixed rate regardless of RunRate. Now:
- Pass result.ForwardSpeed through to SetCycle, so a 1.5× RunRate
player's run loop plays at 1.5× framerate (same timing as the
server-broadcast value remote observers see).
- Fast-path tracks both _playerCurrentAnimCommand AND
_playerCurrentAnimSpeed; a speed-only change still goes through
SetCycle, which then hits the rescale-in-place fast-path via
MultiplyCyclicFramerate.
Retail matches: the footsteps plant at the right world positions
because animation rate × physics rate stay aligned.
2. Remote stop-detection is more responsive.
Previously the 400ms stale-position heuristic was the sole stop
signal. Added a second signal: EMA observed velocity below 0.2 m/s
means the entity is stationary regardless of how recent the last
UpdatePosition was (common case: server IS sending position updates
but all at the same coordinates). Both signals gate on sequencer
CurrentVelocity also being low, so we don't flip-flop when the
motion data itself carries non-zero velocity but the entity happens
to be paused mid-stride. Stop-idle timer also tightened from 400ms
→ 300ms to match typical NPC heartbeat cadence.
Tests unchanged — both changes are small behavior tweaks around the
already-tested speed-scaling path. 654 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.
Three parts:
1. New MotionItem wire record in ServerMotionState — carries Command
(u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.
2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
now read the full InterpretedMotionState: all 7 flag fields
(CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
MotionItem tail. The packed u32 encodes flags in low 7 bits and
command count in bits 7+ (see ACE InterpretedMotionState.cs:131).
3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
class byte from a 16-bit wire value via a reflection-built lookup
of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
(ACE InterpretedMotionState.cs:139) and we need the class to route:
- 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
PlayAction (resolves from Modifiers or Links dict, overlays on
current cycle)
- 0x40xxxxxx SubState → SetCycle (cycle change)
4. OnLiveMotionUpdated in GameWindow dispatches each command:
- SubState class (0x40xxx) → SetCycle (treated same as
ForwardCommand)
- Action/Modifier/ChatEmote → PlayAction — the link animation
plays once then drops back to the current cycle naturally
(matches retail's action-queue pattern in CMotionInterp
DoInterpretedMotion, decompile FUN_00528F70).
Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).
Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: remote characters stutter-hop between UpdatePosition broadcasts
(typical 100-200ms interval), looking lagging-forward during continuous
motion. The retail client hides this gap by integrating velocity forward
each tick — apply_current_movement in chunk_00520000.c L7132-L7189,
mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs.
Strategy:
1. RemoteDeadReckonState per remote entity tracks the last authoritative
server position + rotation, an EMA-smoothed observed velocity from
position deltas, and any server-supplied HasVelocity vector.
2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity
to the server position, then update the dead-reckon state. The
observed-velocity is a 50/50 EMA against the running average so a
single jitter sample doesn't blow out the velocity.
3. TickAnimations: each tick, for every remote entity in a locomotion
cycle, integrate Entity.Position += worldVelocity * dt. World velocity
is pulled in priority order:
- Sequencer's MotionData.Velocity rotated by Entity.Rotation (the
primary source; matches MotionData's "world-space on the object"
convention per r03 §1.3)
- Server-supplied HasVelocity from UpdatePosition (already world-space)
- EMA-observed position-delta velocity (fallback for NPC motion
tables with HasVelocity=0)
4. Cap: if the predicted position drifts more than velocity ×
DeadReckonMaxPredictSeconds (1.0s) from the last server position,
clamp back toward the server. This prevents runaway when sequencer
velocity and server reality disagree (e.g. server rubber-banding).
Result: remote chars now move smoothly between position updates,
matching the retail client's visual feel. When UpdatePosition arrives
the entity snaps to the authoritative position and the dead-reckon
origin resets, so there's no accumulating drift.
Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying
that the sequencer's CurrentVelocity accurately reflects speedMod changes
across both SetCycle's rebuild path and its rescale path. Combined with
the existing MultiplyCyclicFramerate tests, this validates the
downstream-visible velocity surface the dead-reckoner reads. 633 tests
green (was 632).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the server broadcasts a mid-run UpdateMotion with a different
ForwardSpeed (e.g. the player's RunRate changes due to stamina / skill
update), acdream must NOT restart the cycle — that would reset the
footstep cursor and look like a visible twitch. Retail handles this via
Sequence.multiply_cyclic_animation_framerate (ACE
references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287),
which walks the cyclic tail of the queue and scales each node's
framerate by newSpeed / oldSpeed. MotionTable.change_cycle_speed
(MotionTable.cs L372-L379) is the caller from the same-motion path in
GetObjectSequence (L132-L139).
This commit:
1. Adds AnimNode.MultiplyFramerate(factor) — scales a single node's
framerate. Retail also swapped StartFrame↔EndFrame for negative
factors; acdream keeps StartFrame ≤ EndFrame as an invariant and
encodes direction via Framerate sign (see existing comment in
LoadAnimNode), so we only scale. Valid because callers only ever
pass positive factors from UpdateMotion ForwardSpeed.
2. Adds AnimationSequencer.MultiplyCyclicFramerate(factor) — walks
_firstCyclic through the tail and calls node.MultiplyFramerate(factor).
Also scales each node's Velocity and Omega by the same factor so
CurrentVelocity / CurrentOmega stay aligned with playback — matches
ACE's subtract_motion + combine_motion pair in change_cycle_speed.
3. Adds AnimationSequencer.CurrentSpeedMod public property — starts at
1.0, updated by SetCycle on both restart and mid-cycle rescale.
4. Adds a speed-change fast-path to SetCycle: when the (style, motion)
pair matches the current cycle and signs agree,
MultiplyCyclicFramerate(newSpeed/oldSpeed) is called instead of
rebuilding the queue — the cursor stays where it is and the animation
continues at the new rate.
5. Wires InterpretedMotionState.ForwardSpeed from UpdateMotion through
to SetCycle in OnLiveMotionUpdated. ACE omits the ForwardSpeed flag
when speed == 1.0 (InterpretedMotionState.cs:101-103), so we default
missing/zero values to 1.0.
Tests: 4 new sequencer tests covering MultiplyCyclicFramerate,
cursor preservation across speed changes, the same-motion-different-speed
fast-path, and the same-motion-same-speed no-op guard. 632 tests green
(was 628).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause found from ACE source:
- Player_Tick.cs:368 — "the client will never send a 'client released
forward' MoveToState in this scenario unfortunately"
- Server therefore can't broadcast a MotionCommand.Ready UpdateMotion
when a remote player stops moving.
- Retail observer infers stopped state from position deltas going to
zero, not from an explicit motion message.
Also found + fixed the UpdateMotion parser's 2-byte offset bug: ACE's
Align() pads based on absolute stream length (length=15 → 1 pad byte),
not relative-to-block. Previous parser assumed 3 pad bytes after the
MovementData header, which mis-aligned every subsequent field by 2.
After fix, stance/command/speed decode correctly for both server-
controlled NPCs (full stance 0x003D + cmd transitions) and remote
players (stance=0 meaning "no change" + per-axis commands).
OnLiveMotionUpdated rewrite: use SetCycle directly for sequencer
entities instead of routing through GetIdleCycle (which ignored
command when stance was 0). Preserve current style/motion when the
server omits a field ("no change" semantics). Reconstruct full
MotionCommand high byte from current motion or SubState mask.
Remote stop-detection: new _remoteLastMove dict tracks per-entity last
meaningful position + time. OnLivePositionUpdated updates only on
moves > 0.05m so the timestamp captures last actual movement.
TickAnimations checks every entity in a locomotion cycle; if their
last-move time is >400ms stale, swap sequencer to Ready. Excludes
player's own entity (driven by local input, not server observation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visible wins that prove today's wire-layer work is actually doing
something:
1. Chat panel (bottom-left): live tail of the last 10 ChatLog entries.
Color-coded by kind — Tell cyan, Channel green, System yellow, Popup
red, local/ranged white. Bound to WorldSession via the existing
GameEventWiring path (H.1) so server ChannelBroadcast / Tell /
TransientString / Popup + HearSpeech all render live. Includes a
synthetic \"connecting / connected\" system message so the panel
isn't blank before anyone talks.
2. Event panel (bottom-right): last 8 combat events from CombatState
(E.4). Damage taken (red), damage dealt (yellow), evaded-incoming
(green), missed-outgoing (grey). Each entry fades out over 5s.
DebugOverlay.BindCombat wires the listeners.
3. Silk.NET.OpenAL.Soft.Native 1.23.1 added. Before this, OpenAL
managed bindings loaded but the native runtime was absent so
IsAvailable returned false and audio was silently disabled. Now
soft_oal.dll ships to runtimes/win-x64/native/ and the engine
reports \"OpenAL engine ready (16 voices, 3D positional)\" on
launch. Footsteps + other motion-hook sounds now audible.
GameWindow: after constructing DebugOverlay, assign .Chat + .Combat
and call BindCombat to hook the event stream.
Build green, 628 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes Core state classes as public fields on GameWindow so plugins +
UI panels can bind directly, then calls GameEventWiring.WireAll inside
live-session setup to connect the parsed GameEvent stream to them.
HearSpeech (standalone GameMessage, not 0xF7B0-wrapped) is routed via
a separate lambda since it has its own dispatch branch.
After this commit, every server-sent event that the Phase F.1 / E.4 /
E.5 / H.1 parsers handle actually mutates client state live:
- Chat: ChannelBroadcast, Tell, TransientMessage, Popup, HearSpeech
- Combat: UpdateHealth, Victim/Defender/Attacker/Evasion notifications,
AttackDone
- Spellbook: MagicUpdateSpell, MagicRemoveSpell, enchantment
add/remove/dispel/purge
- Items: WieldObject, InventoryPutObjInContainer
WorldTime + Lighting added as public-field Core services so the
renderer and plugin API can read "current day fraction" / "active
lights" directly.
Build green, 602 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full audio pipeline from MotionHook → OpenAL 3D playback. Faithful to
retail's 16-voice pool, inverse-square falloff, and SoundTable
probabilistic variant selection.
Core layer (AcDream.Core/Audio):
- WaveDecoder parses the WAVEFORMATEX in Wave dat headers. PCM
(wFormatTag=1) decodes directly; MP3 (0x55) and ADPCM (0x02) return
null + log (ACM compressed decoders need Windows winmm; cross-platform
path deferred). Cites r05 §2.1-2.3 + ACE Wave.cs.
- SoundCookbook.Roll implements the probability-weighted entry pick that
gives retail footsteps their variation. Cumulative-distribution walk;
silence tail when probabilities sum to <1.
- DatSoundCache: ConcurrentDictionary-backed lazy load of Wave /
SoundTable dats, decoded PCM memoized.
App layer (AcDream.App/Audio):
- OpenAlAudioEngine (Silk.NET.OpenAL): 16-source 3D pool with
round-robin first-free, then evict-quieter-slot algorithm matching
retail chunk_00550000.c FUN_00550ad0 exactly. Separate 4-source UI
pool (source-relative). AL buffer cache keyed by Wave id.
InverseDistanceClamped distance model. Fail-open when AL driver
missing or ACDREAM_NO_AUDIO=1 — client continues without audio.
- AudioHookSink routes SoundHook / SoundTableHook / SoundTweakedHook
from the Phase E.1 animation-hook router into OpenAL. All three
hook types fire on both player AND NPCs/monsters (the sequencer
dispatches per-entity and the sink uses entity worldPos for 3D pan).
- DictionaryEntitySoundTable holds per-entity SoundTable mapping,
populated from Setup.DefaultSoundTable at hydration time. Server-
sent overrides would take precedence here when wired.
GameWindow integration:
- OpenAL init in OnLoad after dat collection, suppressible via
ACDREAM_NO_AUDIO=1.
- SetListener called each OnRender frame with camera position + view
basis vectors (fwd = -Z, up = +Y of inverse view).
- AudioEngine disposed in OnClosing before dats.
Tests: 6 WaveDecoder (PCM / MP3-null / ADPCM-null / stereo / truncated
/ peek) + 6 SoundCookbook (empty / single / 50-30-20 distribution
within 5%, silence tail, table lookup, missing table key). Verified
against r05 §2 + ACViewer export-path.
Build green, 497 tests pass (up from 485).
Ref: r05 §2 (Wave format), §5.3 (16-voice pool + eviction).
Ref: FUN_00550ad0 (chunk_00550000.c:527) eviction algorithm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds IAnimationHookSink + AnimationHookRouter for fan-out of animation
hooks to downstream subsystems (audio, particles, combat, renderer
mutators). GameWindow.TickAnimations now drains ConsumePendingHooks
every tick and broadcasts each hook via the router with the entity's
world position pre-computed.
The router is a composite sink: register N sinks once at startup, each
sees every hook. Registration is idempotent, unregister works, and a
throwing sink no longer poisons dispatch (each OnHook call is wrapped in
try/catch so one bad subsystem can't halt the whole animation tick).
A NullAnimationHookSink is provided for headless tests / offline mode.
6 router tests verify: single/multi sink fan-out, idempotent register,
unregister, throwing-sink isolation, null-sink no-op.
Total: 376 Core tests + 109 Core.Net = 485 (up from 479).
This closes Phase E.1 plumbing; E.2 (audio) and E.3 (particles) will
each register a concrete sink that translates their hook types into
real-world effects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The actual retail behavior for jump animations is a plain SubState swap —
NOT an Action overlay as I'd initially guessed. MotionCommand.Falling
(0x40000015) is a SubState cycle whose motion-table entries handle the
whole jump lifecycle:
- Links[(stance, RunForward)][Falling] = leap-into-air link
- Cycles[(stance, Falling)] = airborne cycle (loops)
- Links[(stance, Falling)][Ready/...] = landing link back to normal
Empirical verification from the diagnostic dump:
Links[0x003D0007] has 3 inner entries:
inner key: 0x41000003 (Ready)
inner key: 0x45000005 (WalkForward)
inner key: 0x40000015 (Falling) ← jackpot
SetCycle() already handles SubState + Links + Cycles resolution correctly,
so the whole fix is three lines:
if (!result.IsOnGround)
animCommand = MotionCommand.Falling;
What's in this commit:
- Added MotionCommand.Falling (0x40000015) constant + comments explaining
the retail jump-is-a-SubState flow
- GameWindow.UpdatePlayerAnimation swaps to Falling when airborne (the
cleanest possible implementation — motion table does all the work)
- Kept AnimationSequencer.PlayAction infrastructure (ported via Links
fallback + Modifiers fallback). Not needed for jump, but perfectly
valid for emotes like /wave, /bow (found in the same Links dict as
inner keys 0x13000080-0x13000083) and eventual combat attacks
- Kept MotionCommand.Jump / Jumpup / FallDown constants (unused for now
but useful reference if non-humanoid motion tables use them)
- Removed all diagnostic logging
What was learned (for future motion work):
- Retail's MotionTable.Cycles dict holds SubState loops (Ready, Walk,
Run, Falling, Crouch, etc.) by (style<<16) | (motion & 0xFFFFFF)
- MotionTable.Links dict holds TRANSITIONS between motions: the OUTER
key is the (style, fromMotion) combo; the INNER key is the TARGET
motion. The stored MotionData IS the link animation played during
the transition. This is what ACE's get_link traverses.
- MotionTable.Modifiers dict holds overlay motions (mask 0x20) — rare
for humanoids, only 8 TurnRight/SideStepRight stance variants
- Actions (mask 0x10) in retail ALSO go through Links — they're
transition animations FROM current substate, not overlays. Use
PlayAction (now correctly routed to Links dict) for them.
Jump animation now works retail-faithfully for running + jumping off.
Standing-jump behavior depends on whether the player's motion table
has a Ready→Falling link; SetCycle's fallback chain should handle it
via the style-level catch-all if the direct link is absent.
470 tests pass. Build clean.
Adds AnimationSequencer.PlayAction as the proper path for Action and
Modifier-class motions (the MotionTable.Modifiers dict, distinct from
Cycles). Action nodes are inserted before the looping cyclic tail so
they drain once and the cycle resumes naturally — leveraging the
sequencer's existing "non-looping head drains, cyclic tail wraps"
queue semantics.
What this does:
- New AnimationSequencer.PlayAction(motionCommand, speedMod=1f):
- Resolves (style<<16) | (motion&0xFFFFFF) from MotionTable.Modifiers
- Falls back to (motion&0xFFFFFF) plain key
- Silent no-op when not found (some motion tables lack these)
- Inserts AnimNodes before _firstCyclic; re-points the cursor when on
the cyclic tail so the action plays immediately
- New MotionCommand.Jump (0x2500003B) + MotionCommand.FallDown (0x10000050)
constants.
- GameWindow.UpdatePlayerAnimation fires PlayAction(Jump) on
result.JumpExtent.HasValue and PlayAction(FallDown) on JustLanded.
Key research finding: retail does NOT animate jumps.
- ACE Player.HandleActionJump explicitly clears PendingMotions and sets
IsAnimating=false during a jump (Player.cs:914-915).
- Empirical verification: the player humanoid's MotionTable only has 8
Modifier entries — all TurnRight/SideStepRight stance variants. No
Jump (0x2500003B) or FallDown (0x10000050) entries.
- Jump is a physics-only action: the character keeps whatever cycle
was active (walk/run/idle) while the physics body arcs through the
air. There is no "raise arms to jump" pose in retail.
PlayAction is still called on jump/land as a safety hatch for creature
Setups that DO carry leap animations in their Modifiers dict (drudge
jumps, monster pounces, etc.). For player humanoids it's a no-op. The
infrastructure is also ready for future emote/combat actions that
legitimately use the Modifiers dict.
470 tests pass, build clean.
Three separate fixes landed today, each addressing a specific bug the
user observed during live play:
1. NPC clothing changes by camera angle (InstancedMeshRenderer)
- Group key was (GfxObjId) only, so every humanoid NPC using the
same body mesh piled into one instance group; only the first
instance's texture was used for the entire DrawInstanced batch,
so which NPC's palette "won" changed as frustum culling and
iteration order shuffled entries.
- Now keyed by (GfxObjId, PaletteHash ^ SurfaceOverridesHash)
so only compatible instances batch; each unique appearance gets
its own draw call. Perf hit is small (humanoid NPCs each emit
one more draw call); visually every NPC is now stable.
2. GpuWorldState dedup on respawn
- Server re-sends CreateObject for the same guid on visibility
refresh / landblock crossing / appearance update. AppendLiveEntity
was blindly appending each time, so GpuWorldState accumulated
multiple copies of the same entity, each with its own
PaletteOverride / MeshRefs. That alone wasn't the clothing bug
(that was #1) but it would have caused other overlap problems
downstream.
- Added RemoveEntityByServerGuid + WorldGameState.RemoveById;
OnLiveEntitySpawnedLocked calls both before creating the new
entity so respawns replace cleanly.
3. Motion wire format — run animation sync with retail observers
- ACE's MovementData constructor only computes interpState.ForwardSpeed
on the WalkForward/WalkBackwards branch; every other ForwardCommand
falls into `else` and passes through WITHOUT speed set, giving
observers speed=0. Sending RunForward directly meant retail
clients saw us "run in place" while position drifted forward.
- Wire: always WalkForward + HoldKey.Run for running. ACE
auto-upgrades to RunForward with creature.GetRunRate() for
broadcast — correct command + correct speed at observers.
- Added per-axis FORWARD_HOLD_KEY / SIDE_STEP_HOLD_KEY /
TURN_HOLD_KEY so every active axis carries HoldKey.Run when
running (matches holtburger's build_motion_state_raw_motion_state).
- Added LocalAnimationCommand to MovementResult so our own
client still plays the RunForward cycle locally while the wire
stays WalkForward. Wire vs. local animation command are now
decoupled.
- Walk-backward wire command changed from WalkForward@-0.65 to
WalkBackward@1.0 (holtburger pattern).
- Strafe speed changed from 0.5 to 1.0 on wire AND local physics
(matches retail sidestep pace).
4. Jump height default + env-var tuning
- Default jumpSkill bumped from 100 → 200 (jump ≈ 3m at full
charge, closer to retail feel for a mid-level character).
- ACDREAM_RUN_SKILL and ACDREAM_JUMP_SKILL env vars now override
the defaults so the user can tune per-character until we parse
PlayerDescription and plumb real skill values through.
5. JustLanded signal on MovementResult
- Tracks airborne→grounded transition so future animation code
can fire the landing cycle when we land. Just a bool flag for
now — no consumer yet (the proper action-queue path will use it).
Not in this commit: jump animation itself. An earlier attempt to
SetCycle(Jump=0x2500003b) fed an Action-type motion into the SubState
cycle resolver, which produced a "torso" mis-render. Reverted. The
proper fix is porting the retail motion action-queue semantics into
AnimationSequencer — see docs/research/deepdives/r03-motion-animation.md
for the spec. That's the next session's work.
470 tests pass, build clean.
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.
Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
one shader + two draw calls (rect then text) for panel backgrounds
under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
are properly committed in this commit
Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
the default neutral angle
Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
physics Setup bounds
Indoor CellStruct PhysicsBSP collision for room walls/ceilings.
Dual sphere (body+head) from Setup dimensions.
StepUp attempts before sliding when hitting low obstacles.
FindTimeOfCollision for exact parametric BSP contact time.
Full 6-path BSP dispatcher wired into FindEnvCollisions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CylSphere.Radius from the dat is often smaller than the visual
trunk base. Setup.Radius is the overall bounding radius which better
matches the visual footprint. Use the larger of the two to prevent
clipping into wide tree bases.
Also use Setup.Height as fallback when CylSphere.Height is zero.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously CylSphere was only registered when no BSP parts existed.
Retail tests BOTH: CylSphere as the broad collision volume (trunk)
plus BSP parts for polygon-level collision. This ensures the trunk
cylinder catches collisions that individual BSP parts might miss,
especially at the base of large trees.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Most scenery objects (trees, rocks) use CylSphere collision from
their Setup, not PhysicsBSP. Register these in ShadowObjectRegistry
with a Cylinder collision type. FindObjCollisions now handles both:
- BSP: full polygon collision via BSPQuery (buildings, stabs)
- Cylinder: radial + vertical cylinder-sphere test (trees, NPCs)
Diagnostics showed 170 CylSphere entities vs 278 BSP entities in
the Holtburg landblock alone — this roughly doubles collision coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.
SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.
AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.
FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.
Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.
- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
RemoveLandblock now also clears shadow objects; TryGetLandblockContext
helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
physics BSP data is cached; selects radius from BSP bounding sphere or
Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AC heading convention: 0=West, 90=North, 180=East, 270=South.
Our internal yaw: 0=+X (East), PI/2=+Y (North).
Conversion: heading_deg = 180 - yaw_degrees, then holtburger's
from_heading formula: theta=(450-heading).toRad, w=cos(θ/2), z=sin(θ/2).
Previously sent raw Yaw as axis-angle rotation which was ~90° off.
Other clients now see correct facing direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build and send GameAction(Jump) with extent + world-space launch
velocity + sequence counters. Wire format from holtburger
JumpActionData::pack. Server can now validate and replicate jumps
to nearby clients.
Also compute RunRate locally via PlayerWeenie.InqRunRate when
running (server doesn't echo UpdateMotion ForwardSpeed to sender).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
(prevents MotionInterpreter.DoMotion from zeroing jump velocity on
every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
(preserves momentum during airborne flight)
Also fix run speed: compute RunRate locally via PlayerWeenie.InqRunRate
instead of waiting for server UpdateMotion echo (server doesn't echo
to sender). With Run skill 200, run speed is now 9.5 m/s instead of
4.0 m/s.
Strip all diagnostic logging from previous debug sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SmallVelocity threshold (0.25 m/s) in UpdatePhysicsInternal was zeroing
velocity every frame while airborne at the jump apex. With vel~0.01 m/s
and gravity adding only 0.012/frame, the zeroing won every frame and
the character got stuck at peak height forever.
Fix: only apply small-velocity zeroing when OnWalkable (grounded).
While airborne, gravity must accumulate freely through the zero-crossing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.
- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
(BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
(FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
(streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
internal node recursion, and empty cache behaviour. All 447 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold spacebar to charge (0→1 over 1s), release to jump. Height from
GetJumpHeight formula using Jump skill via PlayerWeenie. Jump physics
use MotionInterpreter.jump() → LeaveGround() → get_leave_ground_velocity().
JumpExtent is returned in MovementResult (non-null when jump fires this
frame) so GameWindow can log and eventually send the server jump packet.
Double-jump is prevented by jump_is_allowed() checking Contact+OnWalkable
flags before allowing another jump. Tests updated to use charge-then-release
pattern matching the new input model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parse ForwardSpeed from UpdateMotion (0xF74C) InterpretedMotionState.
Feed server-echoed RunRate into the player's MotionInterpreter so
get_state_velocity produces the correct speed. Previously hardcoded
at 1.0 (4.0 m/s), now matches character's Run skill.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the single-giant-buffer TerrainRenderer with TerrainChunkRenderer
that groups landblocks into 16×16 chunks, each with its own VAO/VBO/EBO.
Matches ACME's ChunkMetrics/ChunkRenderData/TerrainGPUResourceManager
pattern:
- Pre-allocated max-size VBO (~3.75MB) + EBO per chunk with DynamicDraw
- Incremental glBufferSubData uploads per landblock slot (no full rebuild)
- Fixed slot layout: slot = (localX * 16 + localY), vertBase = slot * 384
- EBO contains only indices for occupied slots → tight draw calls
- One DrawElements per chunk with chunk-level AABB frustum culling
- Empty chunks auto-dispose GPU resources
Streaming-friendly: add/remove a single landblock touches only its slot
in the chunk buffer, not the entire terrain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the per-entity glUniform uModel path with a shared instance VBO and
DrawElementsInstanced. All instance model matrices are uploaded to GPU once per
frame; the VAO's per-instance attribute pointers (locations 3–6, divisor=1) are
updated with a byte-offset re-point per group so a single VBO serves all groups
without requiring DrawElementsInstancedBaseInstance (not in Silk.NET 2.23).
Changes:
- InstancedMeshRenderer: add _instanceVbo, _instanceBuffer scratch; EnsureUploaded
sets up mat4 instance attrs (locs 3–6) from the shared VBO; Draw builds the flat
float[] of all instance matrices once then calls DrawElementsInstanced per sub-mesh.
Drops the unused uint TerrainLayer attribute (loc 3 from vertex VBO) — mesh shaders
never used it. Adds InstanceGroup helper to track per-group buffer offsets.
- mesh_instanced.frag: replace sampler2DArray+uTextureLayer with sampler2D uDiffuse,
matching the existing TextureCache / individual-texture pipeline.
- mesh_instanced.vert+frag: track as committed files (were untracked).
- Shader.cs: add SetVec3 helper needed for uLightDirection uniform.
- GameWindow.cs: switch mesh shader load from mesh.vert/.frag to
mesh_instanced.vert/.frag.
Visual output is identical: same entities, same textures, same lighting constants
(SUN_DIR=(0.5,0.4,0.6), AMBIENT=0.25, DIFFUSE=0.75 — moved from frag to vert).
Build: clean. Tests: 431/431 green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups all (entity, meshRef) pairs by GfxObjId before drawing so each
GfxObj's sub-meshes are processed as a contiguous batch. Still uses
per-entity uniform uModel — visual output is identical to the old
StaticMeshRenderer — but the _groups dict is the structural prerequisite
for swapping to DrawElementsInstanced in the follow-up commit.
Key changes:
- New InstancedMeshRenderer.cs with CollectGroups() that fills
_groups[gfxObjId] = List<InstanceEntry> each frame, reusing the
inner List<> objects to avoid per-frame allocation.
- Same two-pass (opaque+clipmap first, translucent second) draw logic
from StaticMeshRenderer, now iterating over groups rather than raw
entity/meshRef pairs.
- GameWindow.cs: field and constructor swapped from StaticMeshRenderer
to InstancedMeshRenderer — public API is identical.
- 431 tests green, 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TWO root causes for "character disappears when walking far":
1. MarkPersistent stored SERVER GUID but RemoveLandblock checked LOCAL
entity.Id — different namespaces, never matched. Fixed by adding
WorldEntity.ServerGuid field and checking it in RemoveLandblock.
2. Even with rescue working, the player entity stays in its SPAWN
landblock's entity list forever. When the player walks to a new
landblock and the spawn landblock gets frustum-culled, the entity
disappears because neverCullLandblockId is computed from the
player's current position (new landblock) but the entity is stored
in the old landblock.
Fixed by calling GpuWorldState.RelocateEntity every frame in the
player-mode update loop. This moves the entity from whatever
landblock it's currently in to the one matching its actual position.
The scan is O(entities) but only runs for one entity per frame.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: MarkPersistent(chosen.Id) stored the SERVER GUID (e.g.
0x5000000A) but RemoveLandblock checked entity.Id which is the LOCAL
sequential counter (e.g. 42). Different number spaces → never matched
→ persistent rescue never triggered → player entity lost on unload.
Fix:
- Added WorldEntity.ServerGuid field (0 for dat-hydrated scenery)
- Live entity spawn sets ServerGuid = spawn.Guid
- RemoveLandblock checks entity.ServerGuid against _persistentGuids
- MarkPersistent still stores the server GUID (correct)
This bug has been reported across multiple sessions as "character
disappears when walking far." The neverCullLandblockId fix only
prevented frustum culling but didn't prevent the entity from being
removed from the render list entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 1a of the audit remediation plan.
Extracts the 4 movement sequence counters from inbound server messages
and echoes them in outbound MoveToState + AutonomousPosition instead
of hardcoded zeros:
- instanceSequence (slot 8 in CreateObject PhysicsData timestamps)
- teleportSequence (slot 4, also from PlayerTeleport 0xF751)
- serverControlSequence (slot 5)
- forcePositionSequence (slot 6, also from UpdatePosition 0xF748)
Source: holtburger player/types.rs:237-245, mutations.rs:182-706.
The server uses these to detect stale/reordered movement packets.
Previously all zeros → server couldn't distinguish epoch boundaries.
Changes:
- CreateObject.Parsed: +4 sequence fields extracted from timestamps
- UpdatePosition.Parsed: +3 sequence fields from trailing u16s
- WorldSession: tracks 4 counters, updates from CreateObject/
UpdatePosition/PlayerTeleport for the player's own GUID
- GameWindow: passes tracked values to MoveToState.Build and
AutonomousPosition.Build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewritten AnimationSequencer from the decompiled pseudocode, then
carefully integrated into GameWindow using the same surgical approach:
only replace the frame interpolation source, keep the transform
composition pipeline (scale → rotate → translate → entity scale →
MeshRef) UNTOUCHED.
Key differences from the reverted attempt (e4dae3d):
- Sequencer returns raw PartTransform (Origin, Orientation) — NOT
pre-composed matrices. The existing transform pipeline consumes
these identically to the legacy slerp output.
- Legacy slerp path is kept as explicit fallback for entities
without a MotionTable.
- SetCycle called from both UpdatePlayerAnimation and
OnLiveMotionUpdated — adjust_motion handles left→right remapping
internally with negative framerate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:
- Sequence::update_internal (1021 bytes, the core frame advance loop)
- Sequence::advance_to_next_animation (node transitions)
- Sequence::append_animation (queue management)
- MotionTableManager::PerformMovement (1878 bytes, full state machine)
- AddAnimationsToSequence (transition link → sequence nodes)
- GetStartFramePosition / GetEndFramePosition (reverse playback support)
- AdjustNodeSpeed (negative speed = swapped start/end frames)
Key findings:
- framePosition is a 64-bit DOUBLE, not float
- Negative speedScale swaps startFrame↔endFrame at the node level
- update_internal handles both forward and reverse in one loop
- Frame triggers fire at every integer boundary crossing
- The keyframe slerp lives in the renderer, not the sequencer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for missing left-side animations.
The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.
Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.
Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
left→right with speed *= -1 (TurnLeft, SideStepLeft) or
speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
SetCycle (sequencer handles remapping internally), keeps legacy
fallback for non-sequencer entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Turn-left / strafe-left / walk-backward animations still not playing
despite the left→right fallback. Diagnostic logging added to
UpdatePlayerAnimation to capture exactly what happens when these
commands fire. Next session: check the log output, the issue may be
that UpdatePlayerAnimation is never called for these commands (the
priority logic skips turn when forward is active, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The left→right fallback resolved the cycle correctly but still passed
the original TurnLeft/SideStepLeft command to the sequencer. The
sequencer did its own internal cycle lookup with the left-side command
and found nothing → no animation played.
Fix: track which command actually resolved (after fallback) and pass
that to SetCycle so the sequencer's internal lookup matches.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AC MotionTables typically define cycles for TurnRight and SideStepRight
but not TurnLeft/SideStepLeft — the left variants reuse the right-side
animation played in reverse. When the left-side command doesn't resolve
to a cycle, fall back to the right-side equivalent so the player isn't
stuck in idle pose.
Fallback map:
TurnLeft (0x000E) → TurnRight (0x000D)
SideStepLeft (0x0010) → SideStepRight (0x000F)
WalkBackward (0x0006) → WalkForward (0x0005)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sequencer was initialized at spawn but never notified when the
player started walking/running/turning. UpdatePlayerAnimation and
OnLiveMotionUpdated both updated the legacy slerp fields but the
sequencer path in TickAnimations reads from Sequencer.Advance(dt),
which stayed at the initial idle cycle.
Fix: both methods now call ae.Sequencer.SetCycle(style, motion) when
the sequencer exists, alongside the legacy field updates (which serve
as fallback for entities without a sequencer).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Surgical integration that ONLY replaces the frame interpolation path,
preserving the exact transform composition pipeline that builds MeshRefs.
The key insight from the reverted attempt: the sequencer's Advance(dt)
returns raw (Origin, Orientation) per part — these feed into the SAME
transform composition as before:
CreateScale(defaultScale) * CreateFromQuat(orientation) * CreateTranslation(origin)
then * scaleMat for entity ObjScale
then → MeshRef with template GfxObjId + SurfaceOverrides
What changed:
- AnimatedEntity gains optional Sequencer field
- DatCollectionLoader created once at dat-open time
- Entity registration creates sequencer when MotionTable is loadable
- TickAnimations: if sequencer exists, Advance(dt) produces per-part
transforms; otherwise falls back to Phase 6.5 manual slerp
- Transform composition + MeshRef building is UNCHANGED
What was NOT changed (the previous attempt broke these):
- Per-part defaultScale application
- Entity ObjScale (scaleMat) multiplication
- PartTemplate GfxObjId + SurfaceOverrides forwarding
- The entire MeshRef construction block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the new AnimationSequencer into per-entity animation playback:
- Added `Sequencer` field to `AnimatedEntity`; populated at spawn time
by loading the entity's MotionTable and calling SetCycle on the resolved
idle (style, motion) so playback starts immediately.
- Added `_animLoader` (DatCollectionLoader) initialized alongside `_dats`
so sequencer instances share a single animation loader.
- `TickAnimations`: when an entity has a sequencer, calls `seq.Advance(dt)`
and reads back `PartTransform[]` instead of doing manual frame-index math
and Quaternion.Slerp. Falls back to the Phase 6.5 manual slerp for entities
whose MotionTable couldn't be loaded (missing dat / offline mode).
- `OnLiveMotionUpdated`: calls `sequencer.SetCycle(style, motion)` when
available so motion changes prepend transition-link frames for smooth blending.
- `UpdatePlayerAnimation`: same — calls `sequencer.SetCycle(NonCombatStyleFull, cmd)`
on motion changes; also creates a sequencer when the player entity is
lazy-registered for the first time.
The existing manual-slerp fallback is kept verbatim so behavior is unchanged
for any entity that can't be backed by a sequencer. Build clean, 426 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four fixes from the ACME StaticObjectManager cross-reference:
1. GfxObjMesh: normalize vertex normals (1d). Dat normals may not be
unit-length; without normalization, lighting is wrong per-vertex.
2. SetupMesh: add third-fallback placement frame (2a). If neither
Resting nor Default exists, use the first available frame from
PlacementFrames. Matches ACME's GetDefaultPlacementFrame.
3. SceneryGenerator: building cell exclusion (4d). Compute which
terrain vertices have buildings (from LandBlockInfo.Objects +
Buildings), skip scenery spawns in those cells. Prevents trees
from spawning inside building footprints.
4. SceneryGenerator: slope filter (4e). Compute terrain normal Z at
each displaced position and check against ObjectDesc.MinSlope /
MaxSlope bounds. Prevents trees from spawning on cliff faces.
Also confirmed 4f (scenery Z=0) is NOT a bug — GameWindow's hydrator
lifts scenery to terrain Z at line 1213. The Z=0 in SceneryGenerator
is a placeholder correctly overridden at render time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fundamental terrain fixes based on the AC2D + holtburger deep dive:
1. Terrain split formula: replaced WorldBuilder's physics-path formula
(214614067/1813693831) with AC2D's render-path formula (0x0CCAC033,
0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits
for some cells. Since the render mesh uses this formula, the physics
Z sampler must match it to avoid misalignment on slopes.
2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface
with per-triangle barycentric interpolation. Each cell is split into
two triangles (using the same AC2D formula). SampleZ determines which
triangle the query point falls in, then interpolates within that
triangle. This produces Z values that exactly match the visual terrain
mesh — no more slope clipping.
Removes the multi-point Z sampling hack from PlayerMovementController
(no longer needed with exact triangle Z).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Terrain culling: TerrainRenderer.Draw now accepts neverCullLandblockId,
matching StaticMeshRenderer. Both renderers skip frustum-culling the
player's current landblock. Previously only entities were exempt but
the terrain under the player still disappeared when looking away.
2. Yaw drift on Tab toggle: render loop stores rotation as Yaw - PI/2
(AC model facing offset), but yaw extraction on re-entering player
mode didn't compensate. Each Tab cycle rotated the player 90 degrees.
Now adds PI/2 back when extracting from the quaternion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Slope clipping: replaced single foot-forward Z sample with 4-point
sampling (forward, back, left, right at 0.7 units). Takes the max Z
across all samples so both uphill and downhill slopes keep feet above
the terrain mesh surface. Removed the +0.1 Z bias entirely.
2. Player culling: replaced per-entity scan (alwaysVisibleEntityId) with
per-landblock skip (neverCullLandblockId). The player's current
landblock is computed from _playerController.Position and passed to
the renderer. Simpler, faster, and more reliable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three fixes for user-reported movement bugs:
1. Character disappears far from spawn: streaming observer now computed
from _playerController.Position when player mode is active, instead
of _lastLivePlayerLandblockId which only updates from server echoes
(never for autonomous moves). The 5x5 streaming window now follows
the player as they walk.
2. Jump physics from ACE: JumpImpulse=5.0 and GravityAccel=9.8
matching AC's formula: velocity_z = sqrt(height * 19.6) where
height = BurdenMod * (JumpSkill / (JumpSkill + 1300) * 22.2 + 0.05)
For a new char (skill=100, burden=50%): height≈1.31, vz≈5.07.
3. Gravity reduced from 20 to 9.8 (AC's F_GRAVITY constant).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>