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>
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>
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>
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>
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>
Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.
SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.
AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.
FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.
Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.
- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
RemoveLandblock now also clears shadow objects; TryGetLandblockContext
helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
physics BSP data is cached; selects radius from BSP bounding sphere or
Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.
- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
(BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
(FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
(streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
internal node recursion, and empty cache behaviour. All 447 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold spacebar to charge (0→1 over 1s), release to jump. Height from
GetJumpHeight formula using Jump skill via PlayerWeenie. Jump physics
use MotionInterpreter.jump() → LeaveGround() → get_leave_ground_velocity().
JumpExtent is returned in MovementResult (non-null when jump fires this
frame) so GameWindow can log and eventually send the server jump packet.
Double-jump is prevented by jump_is_allowed() checking Contact+OnWalkable
flags before allowing another jump. Tests updated to use charge-then-release
pattern matching the new input model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
1. Slope clipping: replaced single foot-forward Z sample with 4-point
sampling (forward, back, left, right at 0.7 units). Takes the max Z
across all samples so both uphill and downhill slopes keep feet above
the terrain mesh surface. Removed the +0.1 Z bias entirely.
2. Player culling: replaced per-entity scan (alwaysVisibleEntityId) with
per-landblock skip (neverCullLandblockId). The player's current
landblock is computed from _playerController.Position and passed to
the renderer. Simpler, faster, and more reliable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three 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>
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>
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>
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>
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>
Outbound GameAction message builders for player movement:
- MoveToState (0xF61C): sent on motion state changes (start/stop
walking, turn, speed change). Carries RawMotionState (flag-driven
variable fields) + WorldPosition + sequence numbers.
- AutonomousPosition (0xF753): periodic position heartbeat sent
every ~200ms while moving. No RawMotionState — just WorldPosition
+ sequences + contact byte.
Both follow the GameAction envelope pattern (0xF7B1 + sequence +
action type) established by GameActionLoginComplete. Wire format
ported from references/holtburger movement protocol — field order
and alignment match exactly (contact byte + pad_to_4).
Also:
- Adds WriteFloat to PacketWriter (needed by both builders)
- Adds SendGameAction + NextGameActionSequence to WorldSession
(public wrappers for PlayerMovementController in Task 2)
11 new tests, 265 total, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>