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>
The previous tests pinned the buggy 8-byte header assumption. Now they
match ACE's actual wire format: u16 movSeq + u16 srvSeq + u8 isAuto +
1 pad (via BinaryWriter.Align based on absolute stream length 15→16).
All 166 Core.Net tests now green again.
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>
Updates memory/project_session_2026_04_18.md with the final session
counts after the extra B.4 / H.2 / AppraiseInfoParser / character-
actions / appraise-wiring commits that landed after the mid-session
docs update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GameEventWiring now registers a handler for
GameEventType.IdentifyObjectResponse (0x00C9) that:
1. Runs AppraiseInfoParser.TryParse to extract the full property bundle.
2. If the item is in the repository, merges the bundle into its
Properties via ItemRepository.UpdateProperties (fires
ItemPropertiesUpdated).
3. Merges any SpellBook entries into Spellbook.OnSpellLearned (caster
weapons list their cast-on-use spells; PlayerDescription reuses the
same container for the player's learned set).
Effect: when the player clicks "Appraise" on an item, the tooltip
panel can read full property detail from ItemInstance.Properties
immediately after the server replies.
Build + 628 tests still green. No new test file needed — existing
AppraiseInfoParser tests cover the parse path; GameEventWiring round-
trip tests cover the dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the single biggest P0 gap from r08: the AppraiseInfo blob
carried by both IdentifyObjectResponse (0x00C9) and the initial
PlayerDescription (0x0013) is now parsed end-to-end for the six core
property tables.
Wire layer:
- AppraiseInfoParser.TryParse returns a Parsed record:
(Guid, Flags, Success, PropertyBundle, SpellBook[]).
- IdentifyResponseFlags enum mirrors ACE's bitfield exactly.
- Header reader: u16 count + u16 numBuckets (ACE
PackableHashTable.WriteHeader format).
- Per-table readers: IntStatsTable, Int64StatsTable, BoolStatsTable
(u32 → bool), FloatStatsTable (f64 values), StringStatsTable
(string16L values with 4-byte pad), DidStatsTable.
- SpellBook reader: u32 count followed by count u32 spell ids, with
sanity cap at 4096 entries.
What's NOT yet parsed (deferred, noted in XML doc):
- ArmorProfile / CreatureProfile / WeaponProfile / HookProfile blobs
require porting their respective Structure classes.
- Enchantment bitfields (u16 highlight + u16 color triplets).
- ArmorLevels block.
The parser is defensive: malformed / truncated tables raise
FormatException which is caught internally; the caller gets
whatever properties parsed successfully before the error.
Tests (7 new):
- Header-only (no tables).
- IntStatsTable round-trip with mixed sign values.
- BoolStatsTable (u32 ↔ bool conversion).
- StringStatsTable with padded-length strings.
- SpellBook parsing.
- Combined flags across multiple tables.
- Truncated payload → null.
Build green, 628 tests pass (up from 621).
This unlocks the Attributes / Skills / Paperdoll UI panels once their
renderers land — every property key the server sends now gets stored
on the target ItemInstance (or — for PlayerDescription — the player's
own property bag once wired).
Ref: ACE AppraiseInfo.Write (AppraiseInfo.cs:735), PackableHashTable.
Ref: r08 §4 payload for 0x00C9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Outbound GameActions for XP-spending + combat-mode-change. These
complete the wire surface for the character-sheet UI: the player
clicks "spend XP on Strength," the panel calls BuildRaiseAttribute,
the session sends it, the server responds with updated PlayerDescription
or PrivateUpdateAttribute GameEvents.
Wire layer:
- BuildRaiseAttribute (0x0045): attrId u32, xpSpent u64.
- BuildRaiseVital (0x0044): vitalId u32, xpSpent u64.
- BuildRaiseSkill (0x0046): skillId u32, xpSpent u64.
- BuildTrainSkill (0x0047): skillId u32, credits u32 (note: credits
is u32 here, NOT u64 like the xpSpent variants).
- BuildChangeCombatMode (0x0053): mode enum as u32
(Undef=0, NonCombat=1, Melee=2, Missile=3, Magic=4, Peaceful=5).
Tests (5 new): byte-exact encoding of each, including the Train/
Raise size difference due to u32 vs u64 payloads.
Build green, 621 tests pass (up from 616).
Ref: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click-to-interact wire layer. Adds the three most common "do a thing
to an object" GameActions that the UI triggers on left-click / use-item
contexts.
Wire layer:
- InteractRequests.BuildUse (0x0036): single target guid — click a
door, loot a corpse, talk to an NPC, activate a lifestone, step on
a portal.
- InteractRequests.BuildUseWithTarget (0x0035): source + target —
key on locked door, scroll on self, salvage tool on item.
- InteractRequests.BuildTeleToLifestone (0x0063): no-arg recall. Fails
server-side if not tied; reply comes back as GameEvent WeenieError.
Server reply for Use + UseWithTarget is GameEventType.UseDone (0x01C7)
carrying a WeenieError code (0 = success). Already parsed; wiring
into a "UseDone" event on CombatState-style holder can be a follow-up.
Tests (3 new): byte-exact encoding of all three builders.
Build green, 616 tests pass (up from 613).
Ref: r08 §3 rows 0x0035/0x0036/0x0063.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side allegiance data model + outbound swear/break actions.
The inbound AllegianceUpdate blob (0x0020) is complex and is deferred;
the tree API here is designed so the handler can push nodes in when
the blob parser lands.
Wire layer:
- AllegianceRequests.BuildSwear (0x001D): single uint32 patronGuid.
- AllegianceRequests.BuildBreak (0x001E): single uint32 targetGuid
(works for both breaking from patron and breaking away a vassal;
the server picks behavior based on the relationship).
Core layer (AcDream.Core/Allegiance):
- AllegianceNode: Guid, Name, PatronGuid, Rank (clamped 0..10),
VassalGuids list.
- AllegianceTree: Dictionary-backed, events on TreeChanged.
- SetMonarch: registers the root (no patron).
- UpsertNode: adds/refreshes + auto-inserts into parent's vassal list.
- RemoveNode: removes from parent list too; descendants are left with
dangling patron pointers for the UI to hide (next AllegianceUpdate
refreshes).
- GetAncestors: walks up to monarch, cycle-detected for defense.
- GetDescendants: BFS-order flattening.
- AllegianceMath.ComputePassup: retail XP formula
(50+22.5×loyalty)/291 × (1+RT/730×IG/720) × earned,
clamped at 0.
Tests (11 new):
- Tree: SetMonarch fires TreeChanged, UpsertNode auto-populates parent
vassal list, rank clamp at 10, RemoveNode cleans parent list,
GetAncestors chain, cycle-safe walk, GetDescendants BFS order.
- Math: Passup known-value check (1000 XP, 10 loyalty, 100 RT/IG
days → ~963 XP), negative clamp.
- Wire: Swear + Break byte-exact encoding.
Build green, 613 tests pass (up from 602).
Next: wire inbound AllegianceUpdate (0x0020) + AllegianceInfoResponse
(0x027C) handlers once the blob parser lands. Chat "Allegiance"
Turbine channel joining (r11 §2.1 step 9) layers on top of
Phase H.1 chat infrastructure.
Ref: r11 §1 (tree structure + rank cap), §2 (swear/break wire),
§3.2 (XP passup formula).
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>
Central registration helper that wires every parsed GameEvent from the
Phase F.1 dispatcher into the appropriate Core state class:
- ChannelBroadcast / Tell / CommunicationTransientString / PopupString
→ ChatLog (H.1)
- UpdateHealth / Victim / Defender / Attacker / EvasionAttacker /
EvasionDefender / AttackDone → CombatState (E.4)
- MagicUpdateSpell / MagicRemoveSpell / MagicUpdateEnchantment /
MagicRemoveEnchantment / MagicDispelEnchantment /
MagicPurgeEnchantments → Spellbook (E.5)
- WieldObject / InventoryPutObjInContainer → ItemRepository (F.2)
This is the piece that makes the dispatcher go from "thing that routes
opcodes" to "thing that populates state the UI can redraw from". Before
this, every handler had to be wired at each call site; now one call
at startup (or per-reconnect) does the whole map.
Project graph: added AcDream.Core.Net → AcDream.Core ProjectReference
so the wiring can see both the dispatcher (Net) and the state classes
(Core). Net's own tests already pull in Core indirectly, so test scope
is unchanged.
Tests (6 new, in Core.Net.Tests): verify round-trip via the actual
dispatcher. Build envelope → dispatch → assert the correct Core state
change. Covers ChannelBroadcast, UpdateHealth, MagicUpdateSpell,
WieldObject, PopupString, MagicPurgeEnchantments.
Build green, 602 tests pass (up from 596).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail-faithful 8-light cap selection (r13 §12) — the fixed-function
D3D pipeline's "hardware lights" constraint carried over to modern GL
via UBO-per-draw.
Core layer (AcDream.Core/Lighting):
- LightSource: Kind (Directional/Point/Spot), WorldPosition,
WorldForward, ColorLinear, Intensity, Range (hard cutoff),
ConeAngle (spot), OwnerId (entity attachment), IsLit latch.
- CellAmbientState: (AmbientColor, SunColor, SunDirection) sourced from
R12 sky state for outdoor cells or EnvCell dat for indoor cells.
- LightManager: Register/Unregister/UnregisterByOwner/Clear + Tick
per frame. Selection matches r13 §12.2 exactly:
1) Skip unlit + directional.
2) Compute DistSq for every registered point/spot.
3) Drop lights outside Range² * 1.1 (10% slack prevents pop).
4) Sort by DistSq ascending; take up to 7 (slot 0 reserved for Sun).
5) Slot 0 = Sun (Directional); slots 1..7 = nearest in-range.
Tests (9 new):
- Register/Unregister/Idempotent register.
- Tick picks top 8 by distance when 12 registered.
- Range filter drops far lights (5.0 range, 20m away).
- Range slack includes lights at exactly the boundary.
- Sun reserved at slot 0 across ticks.
- Unlit lights excluded; toggling IsLit brings them back.
- UnregisterByOwner removes all owner's lights.
- DistSq updated each tick for viewer movement.
Build green, 596 tests pass (up from 587).
Next: wire LightManager into the shader UBO pass (G.2 second commit)
and feed Sun from WorldTimeService.CurrentSunDirection per frame.
Ref: r13 §10.2 (D3D attenuation = none inside Range + hard cutoff),
§12 (full port plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side deterministic sky + weather + day/night system per R12.
Retail's model is 95% client-side: the server just delivers its
current PortalYearTicks (double, seconds since boot-seed) at login and
in TimeSync packets; the client computes everything else locally from
the constants in r12 §1.2 + ACE DerethDateTime.cs.
Core layer (AcDream.Core/World):
- DerethDateTime: retail-exact calendar (16 hours/day, 30 days/month,
12 months/year, 7620 ticks/day, 2,743,200 ticks/year). HourName enum
covers all 16 named half-hour slots (Darktide → GloamingAndHalf);
MonthName covers the 12 Derethian months (Snowreap → Frostfell).
DayFraction, CurrentHour, IsDaytime, ToCalendar.
- SkyKeyframe + SkyStateProvider: 4-keyframe default day/dawn/noon/dusk
with linear color + angular-wrap heading interpolation + slerp-like
shortest-arc lerp so heading wraps 350° → 10° don't tween backwards
through 180°. Default keyframe colors tuned to retail screenshots
(sunrise warm, noon white, sunset red, midnight deep blue).
- WorldTimeService: owns the live clock. SyncFromServer(ticks) sets
baseline; NowTicks advances by real-time elapsed. Exposes DayFraction,
CurrentSky, CurrentSunDirection, IsDaytime for the render thread.
This is the foundation Phase G.2 (dynamic lighting) consumes: lighting
uniforms are fed from CurrentSky's SunColor / AmbientColor / sun
direction, varying smoothly across the day.
Tests (16 new):
- DerethDateTime: midnight, half-day, wrap, Dawnsong, Midsong,
day/night flag at dawn vs Darktide-Half, year rollover, month
advance.
- SkyState: 4-default keyframes, noon-exact matches frame data,
midpoint lerps between neighbours, wrap across midnight doesn't
produce NaN, sun direction returns unit vector, WorldTimeService
sync + DayFraction at noon.
Build green, 587 tests pass (up from 570).
Ref: r12 §1 (Portal Year math), §2 (sky objects), §4 (color lerp).
Ref: ACE DerethDateTime.cs + NetworkSession TimeSync handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the client-side combat loop: send attacks, receive server's
damage broadcasts, maintain per-entity health state for HP bars +
damage floaters. All atop Phase F.1's GameEvent dispatcher.
Wire layer:
- AttackTargetRequest (0x0008 C→S, inside 0xF7B1): targetGuid +
powerLevel + accuracyLevel + attackHeight. 28-byte body.
- GameEvents parsers for all combat notifications from r08 §4:
- VictimNotification (0x01AC) — you got hit, full details
- KillerNotification (0x01AD) — you killed X
- AttackerNotification (0x01B1) — you hit X for Y (damage%)
- DefenderNotification (0x01B2) — X hit you
- EvasionAttackerNotification (0x01B3) — X evaded
- EvasionDefenderNotification (0x01B4) — you evaded X
- AttackDone (0x01A7) — attack sequence completed
Core layer:
- CombatState: per-entity health-percent cache + typed events
(HealthChanged, DamageTaken, DamageDealtAccepted, EvadedIncoming,
MissedOutgoing, AttackDone). Each event carries enough detail for
the UI to render damage floaters, HP bars, and a combat log panel.
Server is authoritative; client only mirrors state.
The server computes damage (armor, resist, crit, hit-chance); the
client only displays results. Predictive UI like "estimated damage
at 0.75 power" still works via the existing CombatMath helper class
that was in the scaffold (r02 §5 formulas).
Tests (13 new):
- AttackTargetRequest byte-exact wire encoding
- VictimNotification / AttackerNotification / EvasionAttacker /
AttackDone round-trip parse.
- CombatState: UpdateHealth caches + fires, Victim fires DamageTaken,
Attacker fires DamageDealt, Evasion routes to right event, AttackDone
carries sequence+error, Clear resets cache.
Build green, 544 tests pass (up from 532).
Ref: r02 §7 (wire formats), r08 §4 (event payloads), ACE
GameEvent*Notification.cs families.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the inbound GameEvent routing layer — the single biggest
network-protocol gap per r08 (94 sub-opcodes, zero handled before).
WorldSession now detects 0xF7B0, parses the 16-byte header (guid +
gameEventSequence + eventType), and forwards to a pluggable
GameEventDispatcher.
Added:
- GameEventEnvelope record + TryParse with layout from
ACE GameEventMessage.cs.
- GameEventType enum: all 94 S→C sub-opcodes from
ACE.Server.Network.GameEvent.GameEventType, named per ACE conventions.
- GameEventDispatcher: handler registry + unhandled-counts bag for
diagnostics ("which server events are firing that we don't parse?").
Handlers invoked synchronously on the decode thread; thrown exceptions
are swallowed + logged to stderr so one bad handler can't take down
the packet loop.
- GameEvents parsers: ChannelBroadcast, Tell, TransientMessage,
PopupString, WeenieError (+ WithString), UpdateHealth, PingResponse,
MagicUpdateSpell. Each returns a typed record or null on malformed
payload. String16L helper matches the existing CharacterList pattern
(u16 length + ASCII bytes + 4-byte pad).
- WorldSession.GameEvents property exposing the dispatcher so
GameWindow / UI / chat can register handlers at startup.
Wired into WorldSession.ProcessDatagram: new `else if (op ==
GameEventEnvelope.Opcode)` branch with TryParse + Dispatch.
Tests (13 new):
- Envelope: valid round-trip, wrong outer opcode, too-short body.
- Dispatcher: handler invoked, unhandled count, exception isolation,
unregister + rollover to unhandled.
- Event parsers: ChannelBroadcast, Tell, UpdateHealth, WeenieError,
Transient, MagicUpdateSpell.
Total: 521 tests pass (up from 508).
With this dispatcher in place, Phase F.2 (items + appraise), F.3 (combat
+ damage), F.4 (spell cast state machine), chat UI, allegiance, quest
tracker — all of which depend on GameEvent handling — are unblocked.
Ref: r08 §4 (GameEvent sub-opcode table), §2 (envelope wire shape).
Ref: ACE GameEventMessage.cs / GameEventType.cs.
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>
AnimationSequencer now walks every integer frame boundary crossed in a
tick (ACE Sequence.update_internal pattern), dispatching AnimationHook
objects whose Direction matches the playback direction (Forward or
Backward) or is Both. Mirrors ACE's Sequence.execute_hooks exactly.
New public API:
- ConsumePendingHooks() drains all hooks fired since last call, including
AnimationDone sentinel on link-node drain (emote/attack completion).
- ConsumeRootMotionDelta() drains accumulated PosFrames root motion;
AFrame.Combine (forward) / AFrame.Subtract (backward) applied per
crossed frame to match retail.
- CurrentVelocity / CurrentOmega expose the active MotionData's velocity
and omega (scaled by speedMod at enqueue), letting downstream physics
integrate the animation-driven motion.
All 27 AnimationHookType variants (SoundHook, AttackHook,
CreateParticleHook, ReplaceObjectHook, DefaultScriptHook, SetOmegaHook,
TransparentHook, ScaleHook, SetLightHook, etc.) now flow through the
hook queue. Consumers in E.2/E.3 (audio + particles) will route them to
the right subsystems.
9 new tests cover: forward-hook crossing fires exactly once, Both-direction
fires in either direction, Forward-only suppressed on reverse playback,
Backward fires on reverse, PosFrames accumulation + drain, Velocity
exposure + speedMod scaling, AnimationDone fires on link drain.
Build green; 470 tests → 479 (361 Core + 9 new E.1 hook tests + 109 Net).
Ref: docs/research/deepdives/r03-motion-animation.md §5 (hooks), §7.1-7.2
(PosFrames), §7.3 (negative framerate).
Ref: ACE Sequence.cs:262 (execute_hooks), Sequence.cs:351-443
(update_internal per-frame crossing walk).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the three evening client-debugging commits + the five new
architectural findings they produced:
- NPC clothing by-camera-angle flicker → instance-batching dedup fix
(`(GfxObjId, PaletteHash ^ SurfaceOverridesHash)` as group key)
- Run animation broadcast fix → wire WalkForward+HoldKey.Run +
separate LocalAnimationCommand for local RunForward cycle
- Jump animation via MotionCommand.Falling SubState swap (not Action,
not Modifier — just a plain SubState cycle)
Also captures the lessons learned from the jump saga: trust empirical
motion-table dumps over assumptions, the retail animation taxonomy
(Style/SubState/Modifier/Action masks), and the wire/local-animation
separation pattern.
Updates the pickup table with the small remaining polish items
(backward-walk jitter, stop-running twitch, remote-char Z offset).
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.
Two memory files landed:
1. Updated memory/project_session_2026_04_17.md — covers all three
commits today:
- ff325ab debug overlay + mouse controls
- 7230c15 retail UI research + C# scaffold
- 3f913f1 13-slice deep-dive marathon + scaffolds + roadmap
Includes the "what to build tomorrow" lookup table, the architectural
headline findings, and session lessons (Opus-4.7 parallel swarms are
worth the cost; keystone.dll landmine; GameEvent dispatcher is the
biggest network gap).
2. New memory/project_retail_research_index.md — permanent index for
the 20 research docs (6 UI slices + 13 subsystem slices). Quick-
lookup table "use this slice when you're doing X". Also captures the
critical cross-cutting findings (architecture, wire, dat ranges) and
already-extracted retail-faithful formulas for instant reference.
This file is the standing invariant: before writing any retail-AC-
specific code, open it first to find the matching slice.
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
The BSP collision detection runs in object-local space, but the
collision response (normals, push offsets) was being applied directly
to world-space SpherePath without rotating back to world space. For
rotated objects (trees, rocks, buildings), this caused the push
direction to be wrong — pushing the player sideways or into the
object instead of away from it.
Added localToWorld quaternion parameter to FindCollisions and all
helper methods (StepSphereDown, CollideWithPt, NegPolyHitDispatch).
All normals and offsets are now transformed via
Vector3.Transform(v, localToWorld) before being applied to SpherePath,
matching ACE's path.LocalSpacePos.LocalToGlobalVec() pattern.
Indoor cell collision uses Quaternion.Identity (cell-local = world).
Object collision passes obj.Rotation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the patched collision system (~60-70% retail) with a faithful
port of ACE's BSPTree/BSPNode/BSPLeaf/Polygon collision pipeline.
BSPQuery.cs completely rewritten (1808 lines):
- Polygon-level: polygon_hits_sphere_precise (retail two-loop test),
pos_hits_sphere, hits_sphere, walkable_hits_sphere, check_walkable,
adjust_sphere_to_plane, find_crossed_edge, adjust_to_placement_poly
- BSP traversal: sphere_intersects_poly, find_walkable, hits_walkable,
sphere_intersects_solid, sphere_intersects_solid_poly
- BSP tree-level: find_collisions (6-path dispatcher), step_sphere_up,
step_sphere_down, slide_sphere, collide_with_pt, adjust_to_plane,
placement_insert
PhysicsDataCache.cs: Added ResolvedPolygon type with pre-computed
vertex positions and face planes (matching ACE's Polygon constructor
which calls make_plane() at load time). Populated at cache time to
avoid per-collision-test vertex lookups.
TransitionTypes.cs: FindObjCollisions rewritten to use the retail
per-object FindCollisions 6-path dispatcher instead of the old
"find earliest t, then apply custom response" approach. BSP objects
now go through the same collision paths as indoor cell BSP.
The previous approach was explicitly rejected by the user after ~10
iterations of patches. This port follows the CLAUDE.md mandatory
workflow: decompile first → cross-reference ACE → port faithfully.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Session 2026-04-14 summary: rendering rebuild complete, movement
speed+jump+facing working, collision partially working but needs
full retail port.
Memory updated with explicit user feedback: no more patching
collision, must port from decompiled code faithfully per CLAUDE.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document current state (patchwork ~60-70%) and plan for clean port
from ACE's complete C# implementation. Lists all 12 files to port,
what to keep vs replace, and the correct approach.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Rewind to t-0.02 instead of exact contact time, plus 2cm normal
push-back. The previous 0.5cm was too small — at high speed the
sub-step could overshoot past the surface.
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>
When FindTransitionalPosition fails (stuck in corner, too many steps),
use the partially-resolved position instead of falling back to the
simple Resolve which has no object collision. This prevents walking
through objects when the transition can't find a clean path.
The player now stops at corners instead of clipping through.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The broad-phase rejection was using 3D distance for cylinder objects,
which includes the Z offset between player feet and cylinder base.
Trees have their origin at the base (Z=ground) while the player
sphere is at chest height (Z=ground+~2.5m). The 3D distance exceeded
the combined radius, causing the collision test to be skipped entirely.
Fix: use horizontal (XY) distance for cylinder broad-phase since
the vertical extent is checked separately in the cylinder test.
Also increase broad-phase margin from 1m to 2m.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GetNearbyObjects now searches the player's landblock plus all 8
neighbors. Previously only searched one landblock, missing objects
near landblock boundaries — which includes most trees/rocks since
scenery is placed across the full streaming window.
Also added diagnostic logging (will strip after verification).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When collision is detected at t=0 (already overlapping at step start),
push the sphere out along the collision normal by half-radius instead
of trying to slide with zero displacement (which gets stuck).
Returns Adjusted instead of Slid so the transition loop retries
from the pushed-out position.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure FindObjCollisions to compute collision ALONG the movement
path instead of at the final position:
BSP: movement-aware SphereIntersectsPoly with front-face culling
(dot(movement, normal) < 0). Only detects faces the sphere is
approaching, matching retail Polygon.pos_hits_sphere.
Cylinder: quadratic ray-cylinder intersection computes parametric
contact time t. If t < 1.0, sphere is rewound to the contact point.
Both: find the EARLIEST collision (minimum t), rewind sphere to
contact point + small epsilon along normal, then SlideSphere.
This prevents the "walking into walls" penetration (BUG-005).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>