Commit graph

76 commits

Author SHA1 Message Date
Erik
24974cfbb9 refactor(anim): sequence-wide velocity/omega matching retail Sequence
Before: CurrentVelocity was a pass-through of the current AnimNode's
Velocity. So during a stance transition, while the link animation
played (with no velocity of its own), CurrentVelocity returned (0,0,0)
and remote dead-reckoning briefly stopped advancing the entity. Visible
as a hitch at every idle → walk or walk → run transition.

Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega
are Sequence-wide fields updated by MotionTable.add_motion's
Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new
MotionData is appended, the sequence velocity is REPLACED by that data's
velocity × speedMod. In SetCycle's rebuild path the order is:
  1. clear_physics      → zero
  2. add_motion(link)   → velocity = link's (typically 0)
  3. add_motion(cycle)  → velocity = cycle's (the real walk/run velocity)

After step 3, Sequence.Velocity is the CYCLE's velocity even though
CurrAnim is the link node. So dead-reckoning reads the cycle's velocity
from frame zero of the transition — no stutter.

This commit:

- Converts AnimationSequencer.CurrentVelocity / CurrentOmega from
  per-node computed properties to sequence-wide private-set properties.
- Adds ClearPhysics() helper (mirrors Sequence.clear_physics).
- EnqueueMotionData now updates the sequence velocity/omega (matching
  add_motion's SetVelocity semantics). Only replaces when the
  MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity
  modifiers don't zero the running cycle, matching retail.
- SetCycle's rebuild path calls ClearPhysics before the new add_motion
  chain (matches MotionTable.cs L100-L101, L152-L153).
- MultiplyCyclicFramerate scales the sequence-wide velocity/omega
  instead of per-node fields — algebraically equivalent to retail's
  subtract_motion(old) + combine_motion(new) pair in change_cycle_speed.

New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that
after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's
velocity even during the link frames. Catches the old bug directly.

All 659 tests pass (was 658).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:41:21 +02:00
Erik
6e589d3b89 test(anim): PlayAction conformance — Action, Modifier, Emote
Four new tests covering the PlayAction routing paths that the new
UpdateMotion Commands[] handler relies on:

- PlayAction_Action_ResolvesFromLinksDict — a ThrustMed attack in
  SwordCombat stance resolves via Links[(SwordCombat, Ready)][ThrustMed]
  and its anim frames become visible after PlayAction is called.
- PlayAction_Modifier_ResolvesFromModifiersDict — Jump (0x2500003B,
  Modifier class) resolves via Modifiers[(Style, Jump)] and its anim
  plays on top of the current cycle.
- PlayAction_Emote_RoutesThroughActionBranch — Wave (0x13000087, class
  byte 0x13 = Action | ChatEmote | Mappable) goes through the Action
  branch because the Action bit is set, resolving from Links just like
  attacks. Validates the class-bit math.
- PlayAction_NoEntryInTable_IsNoOp — silent no-op when the table has
  no entry for the motion, with the queue length unchanged.

Together these lock in that the same PlayAction path correctly routes
the three major one-shot classes the Commands[] handler fans out to
NPCs and remote players. 658 tests green (was 654).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:38:01 +02:00
Erik
3f41872d88 feat(anim): route Commands[] list — full NPC/monster motion support
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>
2026-04-19 10:34:18 +02:00
Erik
b7a9322b40 feat(anim): dead-reckoning remote entity positions
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>
2026-04-19 10:29:56 +02:00
Erik
afafefd71f feat(anim): MultiplyCyclicFramerate — retail mid-cycle speed change
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>
2026-04-19 10:26:55 +02:00
Erik
62cf755e7d feat(allegiance): Phase H.2 AllegianceRequests + AllegianceTree model
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>
2026-04-18 17:17:45 +02:00
Erik
a28a69af71 feat(lighting): Phase G.2 LightSource + LightManager (data + selection)
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>
2026-04-18 17:09:51 +02:00
Erik
6850d716a2 feat(world): Phase G.1 DerethDateTime + SkyStateProvider + WorldTimeService
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>
2026-04-18 17:07:26 +02:00
Erik
404cab55ba feat(chat): Phase H.1 Talk/Tell/ChatChannel + HearSpeech + ChatLog
Completes the chat-wire layer end-to-end: outbound Talk (/say), Tell
(/tell), ChatChannel, + inbound HearSpeech (0x02BB) / HearRangedSpeech
(0x02BC) routed into a unified ChatLog that also consumes the already-
parsed GameEvent ChannelBroadcast / Tell / TransientMessage / Popup.

Wire layer (AcDream.Core.Net/Messages):
- ChatRequests.BuildTalk (0x0015, inside 0xF7B1): gameActionSequence
  + string16L message. PackString16L helper with 4-byte pad.
- ChatRequests.BuildTell (0x005D): targetName + message, each
  string16L with its own padding.
- ChatRequests.BuildChatChannel (0x0147): channelId + message.
- HearSpeech.TryParse handles BOTH 0x02BB local AND 0x02BC ranged —
  single parser with IsRanged flag in the returned record. Standalone
  GameMessage (NOT wrapped in 0xF7B0).

WorldSession integration:
- ProcessDatagram branch for HearSpeech.LocalOpcode /
  HearSpeech.RangedOpcode; fires new SpeechHeard event.
- Places the new branch before the 0xF7B0 GameEvent branch so ordering
  stays stable.

Core layer (AcDream.Core/Chat):
- ChatEntry record: (Kind, Sender, Text, SenderGuid, ChannelId, Received).
- ChatKind enum: LocalSpeech, RangedSpeech, Channel, Tell, System, Popup.
- ChatLog: ring-buffer (default 500) of entries; adapters for every
  inbound source (OnLocalSpeech, OnChannelBroadcast, OnTellReceived,
  OnSystemMessage, OnPopup) plus OnSelfSent for echoing outbound.
  Fires EntryAppended so UI panel can scroll / highlight.

Tests (15 new):
- ChatRequests: Talk / Tell / ChatChannel byte-exact encoding (including
  string16L padding edge cases).
- HearSpeech: local + ranged round-trip, wrong-opcode returns null.
- ChatLog: local / ranged / channel / tell / system / self echo,
  ring-buffer drops oldest, Clear empties.

Build green, 570 tests pass (up from 555).

With the chat wire layer in place, Phase H.1's "chat window panel"
(UI slice 05) is purely a UI task: instantiate ChatLog, bind to
EntryAppended, feed rows into the retail-UI widget toolkit. No more
protocol gaps.

Ref: r08 §3 (opcodes 0x0015, 0x005D, 0x0147), §2 (0x02BB, 0x02BC).
Ref: ACE GameMessageHearSpeech.cs + GameActionChannelBroadcast.cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:03:45 +02:00
Erik
c95aedcd4a feat(spells): Phase E.5 CastSpellRequest + Spellbook/enchantment state
Completes the client-side spell loop on top of Phase F.1. Player can
send cast requests; spellbook + active-enchantment state mirrors what
the server broadcasts.

Wire layer:
- CastSpellRequest (C→S, inside 0xF7B1 GameAction):
  - BuildUntargeted (0x0048): self-buffs, recalls, heal-self — 16 bytes.
  - BuildTargeted (0x004A): projectile attacks, target buffs/debuffs — 20 bytes.
- GameEvents parsers added:
  - 0x02C1 MagicUpdateSpell: spell-id → spellbook.
  - 0x01A8 MagicRemoveSpell.
  - 0x02C2 MagicUpdateEnchantment: spellId + layerId + duration + casterGuid
    (summary head; full stat-mod body deferred).
  - 0x02C3 MagicRemoveEnchantment: (layerId, spellId).
  - 0x02C7 MagicDispelEnchantment: same shape.

Core layer:
- Spellbook: learned-spell set + active-enchantment-by-layer dict
  with events (SpellLearned, SpellForgotten, EnchantmentAdded,
  EnchantmentRemoved). Duplicate learn is idempotent. Same-layer
  add refreshes duration. Purge fires per-record remove for UI
  cleanup.
- ActiveEnchantmentRecord: (SpellId, LayerId, Duration, CasterGuid).

Tests (10 new):
- CastSpellRequest untargeted (16 bytes) + targeted (20 bytes) wire encoding.
- GameEvents: MagicUpdateSpell, MagicUpdateEnchantment,
  MagicRemoveEnchantment round-trip.
- Spellbook: learn idempotent, forget, add/refresh enchantment,
  remove fires event, purge-all clears + fires per-record.

Build green, 555 tests pass (up from 544).

Ref: r01 §2 (wire casts), §3 (cast state machine), §5 (stacking rules).
Ref: r08 §3 opcodes 0x0048/0x004A, §4 opcodes 0x01A8/0x02C1-0x02C8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:00:32 +02:00
Erik
2e3f9d7a04 feat(combat): Phase E.4 AttackTargetRequest + combat notification pipeline
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>
2026-04-18 16:58:14 +02:00
Erik
2561f5599f feat(items): Phase F.2 ItemRepository + AppraiseRequest round-trip
Implements the item-state mirror + appraise round-trip infrastructure
on top of Phase F.1's GameEvent dispatcher.

Core layer (AcDream.Core/Items):
- ItemRepository: ConcurrentDictionary-backed live item state keyed by
  server ObjectId. Events: ItemAdded, ItemMoved, ItemRemoved,
  ItemPropertiesUpdated. MoveItem handles container / slot / equip
  location updates atomically and fires ItemMoved with old+new container
  ids. UpdateProperties merges a PropertyBundle patch (for appraise
  results) without clobbering existing untouched keys.

Wire layer (AcDream.Core.Net/Messages):
- AppraiseRequest (0x00C8 C→S, inside 0xF7B1 GameAction envelope):
  Build(sequence, targetGuid) → 16-byte body ready for SendGameAction.
- GameEvents.ParseIdentifyResponseHeader for 0x00C9 S→C — extracts
  (guid, appraiseFlags, success). Full PropertyBundle deserialization
  (the 10-flag bitfield-indexed tables) is a future pass; header alone
  is enough to route into the repository + surface "appraise complete"
  to UI.
- GameEvents.ParseWieldObject (0x0023) — server-driven equip.
- GameEvents.ParsePutObjInContainer (0x0022) — server-driven inventory
  move (item, container, placement).

Tests (11 new):
- ItemRepository: add/update fires correct event, move updates fields,
  missing-id returns false, remove, properties merge, clear.
- Wire: AppraiseRequest byte-exact encoding, IdentifyResponse header
  round-trip, WieldObject round-trip, PutObjInContainer round-trip.

Build green, 532 tests pass (up from 521).

Phase F.2 unblocks the Paperdoll + Inventory UI panels and the
"appraise on right-click" UX. Next pieces: PropertyBundle full
deserializer (AppraiseInfo 10-flag bitfield), outbound move/drop/
pickup actions.

Ref: r06 §1 (ItemType), §2 (EquipMask), §5 (appraise wire), §7 (pack
depth rules).
Ref: ACE GameEventIdentifyObjectResponse.cs for AppraiseInfo format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:55:36 +02:00
Erik
d3165f99d7 feat(vfx): Phase E.3 particle system + hook wiring + registry
Full runtime particle pipeline consuming Phase E.1's animation hooks.
13 motion integrators, per-emitter particle pools with overwrite-oldest
eviction, colour / scale / alpha interpolation over life, and a
ParticleHookSink routing CreateParticle / DestroyParticle / StopParticle /
CreateBlockingParticle hooks from the animation-hook router.

Core layer:
- ParticleSystem: handle-based emitter pool, per-tick emission
  accumulator (retail Birthrate = time-between-spawns → our emit rate
  via 1/B), 13 integrators covering the full ParticleType enum:
  Still, LocalVelocity, GlobalVelocity, 7 Parabolic variants (all
  apply Gravity * dt to velocity), Swarm (orbital drift),
  Explode (outward from anchor), Implode (inward to anchor, dies at
  convergence).
- EmitterDescRegistry: id-keyed EmitterDesc cache with fallback-to-
  default for unknown ids. Replaces the dat-loaded path until
  Chorizite.DatReaderWriter exposes ParticleEmitterInfo (v2.1.7 does
  not; upgraded from 2.1.4 anyway for future types).
- ParticleHookSink: wires the full hook family:
  - CreateParticleHook → SpawnEmitterById at entity pose + hook offset
  - CreateBlockingParticleHook → marker only (blocking semantics live
    in the sequencer not here)
  - DestroyParticleHook → StopEmitter(handle, fadeOut=false)
  - StopParticleHook   → StopEmitter(handle, fadeOut=true)
  - (Default/CallPES deferred until PhysicsScript dat is loadable)

GameWindow integration:
- ParticleSystem created eagerly (no driver dep), sink registered with
  hook router, Tick advanced per OnRender frame after animation tick so
  hooks fired this frame get integrated.

Tests (11 new): spawn-handle, emit-over-time steady state, lifetime
death curve, LocalVelocity movement, Parabolic gravity arc, Explode
outward trajectory, StopEmitter instant kill vs fadeOut, MaxParticles
cap enforcement, registry default fallback, registry custom
registration.

Upgraded Chorizite.DatReaderWriter 2.1.4 → 2.1.7 across Core + Cli.

Build green, 508 tests pass (up from 497).

Ref: r04 §2 (CParticleManager), §3 (13 integrators), §6 (PhysicsScript).
Renderer (instanced billboarded quads in translucent pass) ships next
commit; this one covers the data / logic / wiring layer in full.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:48:17 +02:00
Erik
351723928f feat(audio): Phase E.2 OpenAL engine + SoundTable cookbook + hook wiring
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>
2026-04-18 16:38:26 +02:00
Erik
b04d393329 feat(anim): Phase E.1 hook router + GameWindow wiring
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>
2026-04-18 16:30:23 +02:00
Erik
4db0b2f16c feat(anim): Phase E.1 motion hooks + PosFrames + velocity/omega surfacing
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>
2026-04-18 16:28:15 +02:00
Erik
e12d255d2e feat(physics): port full CTransition collision response from pseudocode
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>
2026-04-14 11:17:45 +02:00
Erik
e2f0c8580e feat(physics): cell-based ShadowObject collision
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>
2026-04-14 11:05:09 +02:00
Erik
e08a06ac5b feat(physics): Transition.FindTransitionalPosition core algorithm
Port FindTransitionalPosition, TransitionalInsert, FindEnvCollisions,
AdjustOffset, DoStepDown, ValidateTransition from transition_pseudocode.md.
Outdoor terrain collision with step-down ground contact. Indoor BSP and
object collision deferred to subsequent tasks.

Also adds PhysicsEngine.SampleTerrainZ() which dispatches the terrain Z
query to the right registered landblock by world-space XY position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:52:45 +02:00
Erik
874d267117 feat(physics): PhysicsDataCache + BSP sphere query
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>
2026-04-13 23:28:39 +02:00
Erik
0bec5d5296 feat(movement): spacebar charged jump with skill-based height
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>
2026-04-13 23:20:52 +02:00
Erik
5cb14da714 feat(physics): PlayerWeenie with retail Run/Jump formulas
Implements IWeenieObject with GetRunRate and GetJumpHeight from
decompiled client, cross-referenced against ACE MovementSystem.
Default skills (Run=200, Jump=100) used until skill parsing ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:15:25 +02:00
Erik
c7fa1d36fb feat(movement): wire server RunRate into player MotionInterpreter
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>
2026-04-13 23:11:49 +02:00
Erik
0335e317d2 fix(anim): remove frame swap — cursor now traverses all frames in reverse
ROOT CAUSE of "twitching" / stuck-on-frame-0 for reverse animations
(TurnRight with negative dat framerate, StrafeRight, etc.):

The frame swap (StartFrame↔EndFrame for negative speed) made EndFrame=0,
and GetStartFramePosition returned (0+1)-eps = 0.999. The cursor
oscillated between 0.0 and 0.999 — floor() of anything in [0,1) is
always 0, so only frame 0 ever rendered.

Fix: DON'T swap. Keep StartFrame=0, EndFrame=N-1 regardless of speed
sign. GetStartFramePosition for negative speed returns (N-1+1)-eps ≈ N,
so the cursor starts near the high end and counts down through ALL
frames. The Advance loop's reverse boundary check uses StartFrame (the
low value) correctly without the swap.

Also strips diagnostic logging from AnimationSequencer and GameWindow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:15:27 +02:00
Erik
78aef6d575 refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:

1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
   client binary. Previously float, which accumulated rounding error over long
   sessions.

2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
   negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
   from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.

3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
   forward and reverse; boundary detection uses EndFrame as the lower bound for
   reverse playback (matching the post-swap field semantics); remainder time
   propagates correctly across node boundaries for large dt values.

4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
   formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
   ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.

5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
   linked list is exhausted, matching the retail loop-forever semantics.

6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
   correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
   WalkBackward→WalkForward (negate×0.65 BackwardsFactor).

7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
   existing implementation is correct.

AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift

All 431 tests green (109 net + 322 core).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 12:59:32 +02:00
Erik
f48f2745c4 feat(anim): AnimationSequencer with transition links + retail slerp
Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.

## What this adds

**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):

- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
  AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
  wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.

- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
  `FUN_005360d0` (chunk_00530000.c:4799-4846):
    1. dot-product sign-flip to take the shorter arc
    2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
    3. sin-based slerp for all other cases
    4. validate weights lie in [0,1] before using sin result (retail
       client validation step that guards degenerate inputs)

- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
  mirrors ACE's `MotionTable.get_link` positive-speed path.
  DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
  `MotionCommandData` whose `.MotionData[toMotion]` is the transition
  `MotionData`. Link frames are prepended before the cyclic tail, so
  idle->walk plays the short transition clip then loops the walk cycle.

- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
  sequencer is testable offline without opening dat files.

- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
  returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).

**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.

317 tests green, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:22:42 +02:00
Erik
14569558fb refactor(physics): wire PhysicsBody + MotionInterpreter into PlayerMovementController
Replace the ad-hoc movement simulation with the ported retail physics:

- PlayerMovementController now owns a PhysicsBody (gravity, friction, Euler
  integration with sub-stepping) and a MotionInterpreter (motion state machine,
  speed constants from retail dat).

- Orientation quaternion is synced from Yaw each frame (Yaw=0 → +X, matching
  the cos/sin convention the camera and outbound messages expect).

- Horizontal velocity is composed from MotionInterpreter.get_state_velocity()
  speeds (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25 from
  decompiled globals) then pushed via PhysicsBody.set_local_velocity so the
  orientation quaternion rotates them into world space correctly.

- Vertical velocity (gravity / jump / fall) is snapshot before DoMotion calls
  so apply_current_movement's set_local_velocity(0,0,0) can't clobber it.

- Jump delegates to MotionInterpreter.jump() + LeaveGround() which calls
  get_leave_ground_velocity() → DefaultJumpVz=10.0 (retail value).

- PhysicsEngine.Resolve is still called each frame with zero delta to sample
  terrain/cell Z under the body and set Contact+OnWalkable accordingly.

- Drive UpdatePhysicsInternal(dt) directly instead of update_object(wallClock)
  to avoid the MinQuantum (~33ms) guard that would silently drop 60fps frames.

Test update: jump loop extended from 30→50 frames to cover the longer flight
time from retail DefaultJumpVz=10 (≈2.04s) vs old JumpImpulse=5 (≈1.02s).

303 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:08:02 +02:00
Erik
e3f8f95dfc feat(core): port decompiled AC client MotionInterpreter — 10 methods, 45 tests
C# port of CMotionInterp from chunk_00520000.c — the AC client's
motion state machine that controls walk/run/jump/turn.

Methods ported:
- PerformMovement: top-level 5-case dispatcher
- DoMotion: process raw motion command from packet
- StopCompletely: reset to Ready (0x41000003)
- get_state_velocity: compute velocity for current interpreted state
- apply_current_movement: push velocity to PhysicsBody
- jump: initiate jump (validate + set extent + leave ground)
- get_jump_v_z: vertical jump velocity (delegates to WeenieObj)
- get_leave_ground_velocity: full 3D launch vector
- jump_is_allowed: requires Gravity + Contact + OnWalkable
- contact_allows_move: slope angle + state checks

Supporting types: MotionCommand constants, MovementType enum,
WeenieError enum, RawMotionState/InterpretedMotionState structs,
IWeenieObject interface.

412 total tests (303 core + 109 net). 45 new MotionInterpreter tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:00:39 +02:00
Erik
6a5d8c1580 feat(core): port decompiled AC client physics — CollisionPrimitives + PhysicsBody
Two major C# ports from the decompiled retail AC client (acclient.exe):

1. CollisionPrimitives (9 functions, 26 tests):
   - SphereIntersectsRay, RayPlaneIntersect, CalcNormal
   - SphereIntersectsPoly, FindTimeOfCollision
   - HitsWalkable, FindWalkableCollision
   - SlideSphere, LandOnSphere
   Ported from chunk_00530000.c functions FUN_005384e0 through FUN_0053a230.
   Cross-referenced against ACE's Physics/ C# port for algorithm verification.

2. PhysicsBody (7 methods, 31 tests):
   - update_object (top-level per-frame, sub-stepped at MaxQuantum=0.1)
   - UpdatePhysicsInternal (Euler: pos += v*dt + 0.5*a*dt²)
   - calc_acceleration (gravity=-9.8 when HasGravity)
   - set_velocity (clamp to MaxVelocity=50)
   - set_local_velocity (body→world via quaternion)
   - set_on_walkable, calc_friction (ground normal + pow decay)
   Ported from chunk_00510000.c/chunk_00500000.c.
   Struct layout confirmed against ACE PhysicsObj field offsets.

367 total tests green (258 core + 109 net). 57 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:54:51 +02:00
Erik
21fd550909 feat(physics): port 9 collision primitives from acclient.exe (chunk_00530000.c)
Adds CollisionPrimitives.cs with C# ports of FUN_005384e0 / FUN_00539500 /
FUN_00539ba0 / FUN_00539110 / FUN_00539060 / FUN_0053a230 / FUN_0053a040 /
FUN_00538eb0 / FUN_00538f50 — covering ray-sphere, sphere-poly contact,
find-time-of-collision, face-normal computation, ray-plane intersection,
walkable checks, edge-normal slide, and sphere landing.

Key findings from cross-referencing with ACE's Polygon.cs:
- The edge-perpendicular formula is cross(N, edge) (normal × edge), matching
  the retail param_1[9/10/8] order in the decompiled loops.
- find_time_of_collision uses t = (dot(origin,N)+D) / dot(dir,N); the sign
  is negative when approaching from above — contact = origin − dir*t.
- land_on_sphere only succeeds when the sphere centre is within one radius of
  the plane (dist < r), which is the "settling onto ground" scenario.

26 new tests green; full suite 367/367 green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 23:53:47 +02:00
Erik
05749f52e0 test: port ACME ClientReference + conformance tests
Ports the decompiled AC client ground-truth oracle and exhaustive
conformance test suite from WorldBuilder-ACME-Edition into acdream's
test project.

ClientReference.cs: faithful C# port of CLandBlockStruct.cpp with
IsSWtoNECut, GetPalCode, GetVertexHeight, GetVertexPosition.

ClientConformanceTests.cs verifies acdream's implementations match:
- SplitDirection: 9 spot-checks + 25,600-cell full sweep (0 mismatches)
- PalCode: 5 spot-checks + 256 exhaustive roads + 1M exhaustive types
- Height sampling: flat terrain exact match, vertex corners match,
  interpolated points in-range
- TerrainSurface.SampleZ agrees with TerrainBlending split direction
- Constants match (CellSize=24, CellsPerBlock=8, BlockLength=192)

27 new tests. 310 total (201 core + 109 net), all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:45:20 +02:00
Erik
a3b389603d fix(app): multi-point Z sampling + never-cull player landblock
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>
2026-04-12 21:29:54 +02:00
Erik
192e066182 fix(app+core): Phase B.3 — player cull-exempt, jump height, slope Z
Three user-reported movement fixes:

1. Player disappears when facing away: StaticMeshRenderer now accepts
   an alwaysVisibleEntityId. When a culled landblock contains the
   player entity, it is still drawn. Prevents the frustum culler from
   hiding the player character when they walk far from their spawn
   landblock.

2. Jump too high: JumpImpulse reduced from 10.0 to 3.5 (placeholder;
   retail scales by Jump skill value from the server).

3. Slope Z alignment: replaced the frame-delta slope bias with a
   foot-forward sampling approach — sample terrain Z at 1 unit ahead
   in the walk direction and use max(center, foot) as the ground Z.
   Handles multi-grade slopes where the terrain rises faster than a
   single-point sample tracks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:24:50 +02:00
Erik
41013ce3e3 fix(core+app): Phase B.3 — Setup.StepUpHeight + scenery road exclusion
Four targeted fixes for user-reported movement/visual bugs:

1. Player entity disappearing: GpuWorldState now supports persistent
   entities (MarkPersistent/DrainRescued). The player character survives
   landblock unloads and gets re-injected into the streaming window at
   the current center landblock.

2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController
   keeps the character model above terrain z-fighting edge cases.

3. Camera after portal teleport: ChaseCamera.Update now called
   immediately after teleport snap so the camera recenters on the new
   position instead of lingering at the pre-teleport location.

4. Scenery on roads: SceneryGenerator now checks road status at the
   final displaced position (not just the origin vertex), catching
   objects that drift from non-road vertices onto road cells.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:56:45 +02:00
Erik
768a9a0619 fix(app+core): Phase B.3 — Setup.StepUpHeight + scenery road exclusion
StepUpHeight: when Tab enters player mode, read Setup.StepUpHeight from the
player entity's dat and apply it to the controller (fallback 2f for non-Setup
entities or when the dat value is zero). Previously hardcoded to 5.0 which
made step-up too permissive.

Road exclusion: SceneryGenerator now skips terrain vertices where bits 0-1 of
the raw terrain word are non-zero. These bits encode the road type (GetRoad()
in ACViewer's Landblock.cs). Trees, rocks and bushes will no longer be placed
on road surfaces.

Added SceneryGenerator.IsRoadVertex(ushort) public helper + 9 unit tests
(theory + fact) verifying the road-bit convention matches TerrainInfo.Road.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:27:36 +02:00
Erik
8252523b8b feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine
Replace the disabled if(false) outdoor→indoor branch with real portal-plane
crossing logic. LandblockPhysics now carries IReadOnlyList<PortalPlane> Portals
(populated at load time; GameWindow passes Array.Empty for now until Task 3).

Resolve logic:
- Outdoor player: tests all portals where TargetCellId==0xFFFF (outside-facing);
  crossing enters the portal's OwnerCellId.
- Indoor player: tests portals where OwnerCellId==currentCell; crossing to
  TargetCellId==0xFFFF exits to terrain, otherwise transitions room-to-room.
- Landblock boundary crossing: unchanged — candidatePos landblock lookup already
  picks the adjacent block's terrain naturally.

Tests: renamed disabled test → Resolve_OutdoorThroughPortal_TransitionsToIndoor;
added Resolve_IndoorThroughExitPortal_TransitionsToOutdoor and
Resolve_LandblockBoundary_PicksAdjacentTerrain. 274 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:22:55 +02:00
Erik
cb46d892d5 feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)
Adds the foundational portal-plane record for cell transition detection.
PortalPlane.FromVertices computes a normalised plane from 3 coplanar
polygon vertices via cross product + dot product; IsCrossing tests whether
a movement vector straddles the plane (strictly negative dot-product
product — exact-on-plane position returns false as specified).

4 new unit tests: normal construction, opposite-side crossing, same-side
no-crossing, start-on-plane no-crossing.  All 269 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:17:48 +02:00
Erik
228eecbb31 chore(app): Phase B.2 — strip diagnostic logging + fix indoor-transition test
Removes all [PLAYER], [PLAYER-INIT], [PLAYER-ANIM] diagnostic dump
lines now that walking, camera, and animation are verified working.

Updates PhysicsEngineTests.Resolve_EnterIndoorCell to match the new
behavior (outdoor→indoor transition disabled in the B.2 MVP): the
test now asserts the player stays outdoor at terrain Z instead of
transitioning to the indoor cell.

265 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:27:49 +02:00
Erik
d9cd2b0b1d feat(app): Phase B.2 — PlayerMovementController (input → physics → motion state)
Per-frame controller that reads MovementInput (WASD/ZX/Shift/mouse),
drives PhysicsEngine.Resolve for collision, and tracks motion state
changes for outbound server messages + animation switching. Walk
(~4 u/s) and run (~7 u/s) speeds match AC retail. Heartbeat timer
triggers AutonomousPosition every ~200ms while moving.

5 new tests covering idle, forward, run, turn, and state-change
detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:27:07 +02:00
Erik
84d7d06008 feat(app): Phase B.2 — ChaseCamera (third-person follow camera)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:26:20 +02:00
Erik
88d446d11d feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)
Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.

Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).

One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.

6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:54:28 +02:00
Erik
19aa8ce5d0 feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID)
Extracts the bilinear heightmap interpolation from GameWindow's
inlined SampleTerrainZ into a reusable class. Also adds outdoor
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).

First component of the physics collision engine.

6 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:51:54 +02:00
Erik
520589911b feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
Projects an XY point onto a cell's floor polygons via brute-force
triangle iteration + barycentric Z interpolation. Fan-triangulates
quads and larger polygons. Returns null when outside all floor
surfaces. Accepts pre-transformed world-space vertex positions so
the caller handles EnvCell coordinate transforms.

Second component of the physics collision engine.

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:51:22 +02:00
Erik
07fde88534 feat(app): Phase A.2 — FrustumCuller + FrustumPlanes (Gribb-Hartmann)
Per-landblock frustum culling for the streaming renderer. Extracts
6 normalized view-frustum planes from a View×Projection matrix using
the standard Gribb-Hartmann method. IsAabbVisible tests the AABB's
most-positive vertex against each plane — conservative (no false
negatives) and zero-allocation.

Key implementation note: System.Numerics.Matrix4x4 uses ROW-VECTOR
convention (clip = worldPos * VP), so Gribb-Hartmann must operate on
the COLUMNS of the matrix (not rows). The spec's row-based pseudocode
assumed column-major (OpenGL) convention; the fix is col4 ± col{1..3}.

7 new tests covering ortho, perspective (front/behind/left/far/
near-straddling), and acdream's actual Z-up camera convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:49:17 +02:00
Erik
f792931d21 fix(app): Phase A.1 — pending-spawn list in GpuWorldState (proper fix)
Fifth and final Phase A.1 hotfix. Replaces the previous "drop on
miss" semantics in GpuWorldState.AppendLiveEntity with a per-landblock
pending bucket that survives the race where a CreateObject arrives
before its landblock has been streamed in.

Root cause:
The post-login spawn flood (40+ NPCs/items) drains in a single
WorldSession.Tick() call. The synchronous streamer enqueues all 25
visible-window landblocks in one shot but StreamingController.Tick
was capped at MaxCompletionsPerFrame=4, so only 4 landblocks landed
in GpuWorldState on the first frame. The center landblock 0xA9B4FFFF
may or may not have been in those first 4 (HashSet iteration order
is undefined). Spawns whose target landblock wasn't yet loaded were
silently dropped by AppendLiveEntity. Re-ordering the OnUpdate
(streaming first, live second) didn't fix it because the cap still
limited to 4 per frame; spawns for landblocks #5+ kept dropping
until the queue drained, by which point the spawn flood was over.

The reordering was correct but insufficient. The cap was a relic of
the original async streamer design (limit GPU upload spikes per
frame). With the synchronous streamer there's no backlog to spread,
so the cap was pure latency for no benefit. Setting it to int.MaxValue
restores "drain everything you just enqueued" semantics.

The pending-spawn list is the *correct* architecture fix that makes
the system robust against any future ordering bug, not just the cap:
- AppendLiveEntity for an unloaded landblock parks the entity in a
  per-landblock pending bucket instead of dropping it.
- AddLandblock drains pending entries for its landblock and merges
  them into the loaded record before storing.
- RemoveLandblock drops pending entries for the same landblock —
  if the player moved away, the spawns are no longer relevant; the
  server resends them via CreateObject when the player returns.

Diagnostic counter PendingLiveEntityCount exposes the bucket size
so future regressions are visible without spelunking.

7 new GpuWorldStateTests pin the contract:
- AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately
- AppendLiveEntity_LandblockNotLoaded_ParksInPending
- AddLandblock_DrainsPendingEntriesForThatLandblock
- AddLandblock_DoesNotDrainPendingForADifferentLandblock
- RemoveLandblock_DropsPendingForThatLandblock
- RemoveLandblock_LoadedThenRemoved_DropsItsEntities
- IsLoaded_ReturnsTrueForLoaded_FalseForPendingOnly

Also removes the diagnostic Console.WriteLine I added in the previous
debugging round and the old LiveAppendsResolved/Dropped counters that
were never read by anyone.

219 tests green (212 + 7 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:19:40 +02:00
Erik
f83a8c1674 fix(app): Phase A.1 — encode landblock IDs with 0xFFFF terminator, not 0xFFFE
ROOT CAUSE of the "giant ball with spikes" terrain corruption that
the previous two hotfix attempts (lock + synchronous loading) failed
to address. Threading was a red herring all along.

AC dat conventions:
  0xAAAA0xFFFF — LandBlock dat (terrain heightmap)
  0xAAAA0xFFFE — LandBlockInfo dat (static-object metadata)

WorldView.NeighborLandblockIds correctly uses 0xFFFF. My
StreamingRegion.EncodeLandblockId from Phase A.1 Task 1 used 0xFFFE
by mistake. Every streaming load was therefore calling
LandblockLoader.Load with the LandBlockInfo id, which makes
DatCollection ask DatBinReader to read a LandBlock from the
LandBlockInfo file. The reader's internal buffer position lands in
the middle of the wrong file's bytes, ReadBytesInternal asks for an
out-of-range slice, throws ArgumentOutOfRangeException, and the
landblocks that DON'T throw return half-populated LandBlock objects
whose Height[] arrays contain garbage. Garbage Z values render as
the spike pattern.

The kicker: my Task-1 review fix added a test
(Constructor_SmallRadius_IDsMatchEncodingRule) that asserted
Assert.Contains(0x1234FFFEu, region.Visible). The test was passing
because it pinned the wrong value. I literally codified the bug.

Fix: change EncodeLandblockId's terminator from 0xFFFEu to 0xFFFFu
and update the test to assert 0x1234FFFFu. The XML doc on Visible
now explicitly explains the 0xFFFF/0xFFFE distinction so this can't
recur.

The previous two hotfixes (_datLock in c991fb2, synchronous streamer
in 531c9f9) stay in place — _datLock is defensive belt-and-suspenders
that documents which entry points read dats, and synchronous loading
is correct-by-default until we decide whether to reintroduce
background loading (Phase A.3 may make it unnecessary anyway).

212 tests green. With this fix the streaming should actually work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:59:21 +02:00
Erik
531c9f9349 fix(app): Phase A.1 — make LandblockStreamer synchronous (DatCollection isn't thread-safe)
Second hotfix attempt for the "ball of spikes" terrain corruption.
The previous _datLock fix was insufficient because dat reads happen
from many render-thread code paths I didn't enumerate (animation
tick, OnLiveMotionUpdated, OnLivePositionUpdated, the live spawn
hydration, ApplyLoadedTerrain) and locking each is invasive and
fragile.

DatReaderWriter's DatCollection is fundamentally not thread-safe:
DatBinReader's internal buffer position is shared per-database, so
two concurrent .Get<T> calls corrupt each other's read state. The
ArgumentOutOfRangeException at DatBinReader.ReadBytesInternal in
the failure log is the smoking gun — one read started reading a
LandBlock, another moved the reader's position, the first one
asked for the wrong number of bytes.

Until Phase A.3 introduces a thread-safe dat wrapper (or until we
preload all dats into pure in-memory dictionaries), the streamer
runs synchronously: EnqueueLoad invokes the load delegate inline
on the calling thread and writes the result to the outbox in a
single call. The render-thread DrainCompletions loop picks it up
on the same frame.

API surface unchanged — Channel-based outbox, EnqueueLoad/Unload,
DrainCompletions, Start (now no-op), Dispose all preserved. Move
back to async loading is a single-class change once dat thread
safety lands.

Cost: visible frame hitch when crossing landblock boundaries
(rendering the new landblock is now on the render thread). For
default 5×5 the hitch is one landblock per cardinal step, ~50ms
worst case. Acceptable for the MVP — correctness over hitches.

Updated the off-thread test to assert the new synchronous contract
(loader runs on the calling thread). The other 4 tests still pass
unchanged because their spin-drain pattern works with synchronous
delivery too.

The previous _datLock from commit c991fb2 stays in place as
defensive belt-and-suspenders — it's free in synchronous mode and
keeps the contract documented at every dat-reading entry point.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:56:19 +02:00
Erik
9067c4f60b feat(app): Phase A.1 — StreamingController glue
Called once per frame from OnUpdate. Owns a StreamingRegion and uses
delegates into LandblockStreamer + a terrain-apply callback so unit
tests can inject fakes. Handles first-tick bootstrap (whole window
loads), boundary recenter (diff against previous center), and
drain completions (up to N per frame to cap GPU upload spikes).

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:26:55 +02:00
Erik
c5e207a51f fix(app): Phase A.1 — LandblockStreamer lifecycle + threading hardening
Code review follow-up to commit 0904372. Five Important fixes plus
three Minor polish items found by the reviewer before StreamingController
depends on this class under churn.

I1: Dispose is now thread-safe via Interlocked.Exchange on an int
    guard. Two concurrent Dispose calls no longer double-dispose the
    CancellationTokenSource.

I2: EnqueueLoad/EnqueueUnload now throw ObjectDisposedException when
    called after Dispose instead of silently dropping the job. Jobs
    vanishing into a completed channel was a debugging hazard.

I3: Start throws ObjectDisposedException when called after Dispose
    instead of silently doing nothing (the old guard only checked
    whether the thread was non-null, not whether the streamer was
    still usable).

I4: New test Load_ExecutesLoaderOnBackgroundThread captures the
    loader delegate's ManagedThreadId and asserts it differs from
    the test thread's id, proving the whole reason this class
    exists (off-thread execution) is actually happening.

I5: New LandblockStreamResult.WorkerCrashed record type for the
    outer catch in WorkerLoop. Previously the crash path wrote
    Failed(0, ex.ToString()) which collided with landblock (0, 0)
    in the north ocean, making "worker crashed" indistinguishable
    from "landblock 0 failed to load".

Minor polish:
- M1: Test spin constants (SpinTimeoutMs, SpinStepMs,
  SpinMaxIterations) extracted so the 200 x 10ms pattern has one
  source of truth.
- M2: DefaultDrainBatchSize public const on LandblockStreamer so
  the batch cap has a name and a comment explaining why 4.
- M3: Safety-argument comment on the sync-over-async
  WaitToReadAsync call explaining why it cannot deadlock (dedicated
  thread, no SyncContext).
- M6: XML remarks on the class and on DrainCompletions documenting
  threading contract (Enqueue = any thread, Drain = single consumer
  thread).

112 Core + 96 Core.Net tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:20:41 +02:00
Erik
0904372af6 feat(app): Phase A.1 — LandblockStreamer (background worker + channels)
Background thread pulls load/unload jobs from an inbox channel, invokes
a caller-supplied Func<uint, LoadedLandblock?> (production wraps
LandblockLoader.Load, tests inject a fake), and posts results to an
outbox channel the render thread drains. Graceful shutdown via
CancellationToken; failed loads reported rather than retried.

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:14:18 +02:00