Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6)
from the retail decomp, turning wall-slides into proper step-up climbs
and airborne-to-roof landings.
Path 5 (grounded mover hits polygon):
- StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true
- DoStepDown now includes the retail Placement validation step
(ACE Transition.cs:731-741) — sphere must not be inside solid geometry
after finding a contact plane; this correctly blocks the tall-wall case
- FindObjCollisions now allocates a local ShadowEntry list per call to
prevent "collection modified" exceptions when DoStepUp recurses back
through TransitionalInsert → FindObjCollisions
- BSPQuery.FindCollisions passes engine through to StepSphereUp
Path 6 (airborne mover hits polygon):
- SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets
WalkInterp=1 — then returns Adjusted so TransitionalInsert retries
- SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for
the tall-wall fallback
- TransitionalInsert Collide branch: re-tests as Placement when
ContactPlaneValid; on failure restores backup and returns Collided
Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs):
- Tests use foot-position convention (CurPos = foot, sphere center =
CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords
- MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact
state between sub-steps), -50f for airborne/roof tests
- to.X adjusted so sub-steps land sphere inside (not exactly touching)
the wall, avoiding the EPSILON-shrink false-negative edge case
- All 12 BSPStepUp tests now GREEN; full suite 823/823
Retail refs:
CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746
CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710
SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279
CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two files under tests/:
BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical
collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m),
and steep slope (60deg). Pre-builds ResolvedPolygon dicts with correct
polygon_hits_sphere_precise winding (CCW relative to outward normal).
BSPStepUpTests.cs — 11 conformance tests:
A1-A6: baselines that pass before and after implementation (no-hit, geometry
fixture sanity checks).
B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides).
C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides).
Retail refs in test docstrings:
BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 /
ACE BSPTree.cs:192-196.
CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 /
ACE Transition.cs:746-777.
SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 /
ACE SpherePath.cs:279-286.
CTransition::transitional_insert Collide branch
acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics
injection without real DAT content.
Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing.
Pre-flight: object-translation plane D is in object-local space. Bug is dormant
for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.
Behaviour now matches retail:
- Two non-PK players walk through each other.
- Two PK players collide.
- Two PKLite players collide.
- Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
- Impenetrable target ("Free" PK status) — always collides.
- Player vs creature/NPC — always collides (this is what closes the
user-facing complaint that walking into a Holtburg vendor was
walking through them).
- Mover with IGNORE_CREATURES — walks through creature targets.
- Viewer (camera ray) — walks through creatures.
- Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.
CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
- new file src/AcDream.Core/Physics/CollisionExemption.cs.
- 13-test matrix covering every documented case
(CollisionExemptionTests.cs).
- Static + pure → cheap to call from the hot path.
Wiring:
- TransitionTypes.FindObjCollisions: after broadphase distance
reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
`continue`. Static landblock entries (State=0, Flags=None) fall
through cheaply — no behavior change for static collision.
- PhysicsEngine.ResolveWithTransition: new optional moverFlags
parameter (default None for back-compat). PlayerMovementController
passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
None (matches non-player movers, no PvP exemption applies).
- PK/PKLite/Impenetrable bits for the LOCAL player are not yet
sourced from PlayerDescription's PlayerKillerStatus property —
that's a follow-up. Default "non-PK player" matches ACE's
character-creation default and the user's +Acdream test
character.
Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.
dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).
Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NPCs / monsters / other players now register into ShadowObjectRegistry
as collision targets. The local player walks into them and stops at
the body cylinder, instead of passing through.
GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built
and stored in `_entitiesByServerGuid`, call the new
RegisterLiveEntityCollision helper for any non-self entity. The helper
honors retail's geometry-priority order (acclient_2013_pseudo_c.txt:
276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and
applies the retail phantom-Setup skip (no CylSpheres / no Spheres /
zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS
fallthrough at :276917).
GameWindow.OnLivePositionUpdated: after the entity's render pos/rot
are set to server truth, push the same coordinates into the registry
via ShadowObjectRegistry.UpdatePosition (the cheap
preserve-shape-and-flags path Commit A added). Mirrors retail's
SetPosition → change_cell → AddShadowObject chain (
acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
The local player's own server guid is filtered out at both
registration and update — its PhysicsBody is the simulator (the
source of truth for our collisions), not a collision target.
The decoded EntityCollisionFlags + raw PhysicsState bits are stored
on each ShadowEntry but NOT YET CONSULTED by the collision resolver
— Commit C is where the PvP exemption block lands. Practical effect
of THIS commit: every visible body, including non-PK other players,
blocks the local player. Two non-PK players currently can't pass
through each other; that's the rule Commit C reverts to retail.
No new unit tests in this commit (Commit A's ShadowObjectRegistry +
EntityCollisionFlags suite covers the new field plumbing).
Verification is live: at Holtburg the +Acdream test character should
stop on contact with NPCs / vendors. Phantom decorations (small
plants, grass) continue to pass through (L-fix3 phantom skip
extends naturally to the live path via the same Setup-shape gate).
dotnet build green, dotnet test 1454 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.
Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
...) which the parser previously dropped at line ~337 with a bare
`pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
PWD._bitfield trailer per acclient.h:6431-6463), where
acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.
Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
positions verified against retail's SetPlayerKillerStatus (
acclient_2013_pseudo_c.txt:441868-441890) which maps
PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
`Flags` (decoded EntityCollisionFlags). Backward-compatible — all
five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
emits per visible entity. Reuses the entry's existing shape +
state + flags. Mirrors retail's CPhysicsObj::SetPosition
(acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
Used by Commit C's PvP exemption gate.
Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
entry to new cell, preserves State/Flags, unregistered no-op,
Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
bitfield (with PK / PKLite bit cases) parse and surface.
1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.
Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked how AC differentiates collidable vs phantom scenery.
The retail signal is at the Setup level: a Setup with NO
CylSpheres, NO Spheres, AND zero overall Radius is decorative
-- the player walks through it. retail header
docs/research/named-retail/acclient.h enum PhysicsState
includes ETHEREAL_PS = 0x4 / IGNORE_COLLISIONS_PS = 0x10 for
runtime-toggled flags, but the static signal for scenery is
the empty Setup collision arrays.
Pre-fix the mesh-bounds-fallback at
GameWindow.cs:4297-4429 ran on every outdoor scenery entity
regardless of Setup intent, then clamped the resulting
cylinder radius to >= 0.3 m. So small plants/grass got a
0.3 m collision cylinder and blocked the player even though
the Setup explicitly said no collision.
Fix: before the mesh-bounds fallback, check the cached Setup.
If it's a Setup-typed object (0x020xxxxx) AND CylSpheres /
Spheres / Radius are all empty/zero, mark it phantom and
skip the collision registration entirely. Non-phantom
scenery (trees with real CylSpheres or canopy-only BSPs)
still gets the mesh-bounds fallback so the player walks
under canopies but bumps into trunks. Raw GfxObjs
(0x010xxxxx, no Setup metadata) keep the old fallback
behaviour because they don't expose phantom intent.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: trees that exist in retail are missing in ACdream.
SceneryGenerator had an extra heuristic filter at lines 169-180
that rejected scenery whose cell-origin vertex was a road vertex,
on top of the proper retail post-displacement road check
(FUN_00530d30 port via IsOnRoad). The comment admitted it
wasn't in the retail decomp -- it was added to widen road
margins visually. Side effect: any cell whose SW corner
happened to touch a road vertex had ALL of its scenery
dropped, even when the displaced position was well clear of
the road ribbon.
Removing the extra guard. The retail FUN_00530d30 ribbon test
already handles road exclusion correctly; the heuristic was
strictly subtractive and silently dropped trees the retail
client renders.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: other characters disappear when the camera rotates,
even though they're standing within visible distance.
Root cause: InstancedMeshRenderer's landblock-level frustum cull
(InstancedMeshRenderer.cs:352-355) skipped the entire landblock's
entity list when the landblock AABB was outside the frustum.
Static scenery culling that way is fine, but ANIMATED entities
(remote players, NPCs, monsters) got culled with the landblock --
they vanished as soon as the camera turned away from their block.
Fix: pass an animatedEntityIds set to Draw. Inside CollectGroups
the landblock-cull decision is now per-landblock boolean (not a
continue), and the per-entity loop bypasses the cull when the
entity id is in animatedEntityIds. Static entities still respect
the landblock cull. GameWindow rebuilds the set per frame from
_animatedEntities (typically <100 entities, cheap).
Fast path preserved: when animatedEntityIds is null/empty AND
the landblock is culled, skip the entity list entirely -- same
O(visible-landblocks) cost as before.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.
Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
MovementParameters block from MoveTo packets (movementType 6/7),
adds RemoteMoveToDriver per-tick body-orientation steering with
±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
research subagents converging on retail
CMotionInterp::move_to_interpreted_state's unconditional
forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
+ InterpretedState bulk-copy out of substate-only branch so
Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
velocity at the final-approach tick so body lands EXACTLY at
DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
the regression where MoveTo creatures stayed at default
ForwardCommand=Ready in InterpretedState and only translated via
UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
fallback chain (requested → WalkForward → Ready → no-op) at BOTH
the OnLiveMotionUpdated path AND the spawn handler. Prevents
ClearCyclicTail from wiping the body's cyclic tail when ACE
CreateObject carries CurrentMotionState.ForwardCommand pointing
to an Action-class motion (e.g. AttackHigh1 from a mid-swing
creature) which has no cyclic-table entry — was the "torso on
the ground" symptom for monsters seen in combat by a fresh
observer.
Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).
Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).
Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reports same "torso on the ground" symptom after 34d7f4d. Likely
cause: my fallback only covered the OnLiveMotionUpdated path, not the
spawn handler at the CreateObject boundary. If the spawn-time
SetCycle requests a (style, motion) pair the MotionTable lacks,
ClearCyclicTail wipes the cyclic tail at line 396 of
AnimationSequencer.cs and every body part snaps to its setup-default
offset until the first OnLiveMotionUpdated UM applies the path's
fallback there.
Apply the same fallback chain (requested → WalkForward → Ready →
no-op-don't-clear) at the spawn handler. Also add a one-line
diagnostic dump (under ACDREAM_DUMP_MOTION=1) on both code paths so
the next launch confirms whether the fallback is actually firing and
what (mtable, style, motion) tuples are missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.
Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.
This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.
Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.
Tests: 1420 → 1422.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit ff6d3d0: at login, monsters appear
as "just a torso on the ground" until they start moving.
Cause: f794832 lifted the InterpretedState bulk-copy to apply to BOTH
overlay and substate packets, but gated it on
`!IsServerControlledMoveTo`. The original substate-only
DoInterpretedMotion call I removed had previously updated
InterpretedState for MoveTo packets too (fullMotion=RunForward seed
from PlanMoveToStart routed through ApplyMotionToInterpretedState's
RunForward case). My replacement only fired for non-MoveTo packets,
silently regressing MoveTo creatures to a default
ForwardCommand=Ready InterpretedState.
Consequence: chasing creatures had ForwardCommand=Ready in their
InterpretedState even though the cycle on the sequencer was
RunForward. apply_current_movement (gate: RunForward||WalkForward)
returned zero velocity — body never advanced via the steering branch's
velocity integration. The body ONLY translated when an UpdatePosition
hard-snap arrived (every ~200ms server tick), producing the
"torso on the ground at spawn" pose before the first UP snap landed
and "running on the spot" between snaps.
Fix: drop the IsServerControlledMoveTo gate. Bulk-copy
InterpretedState.ForwardCommand=fullMotion and ForwardSpeed=speedMod
UNCONDITIONALLY for any packet that reaches OnLiveMotionUpdated.
Matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) which doesn't filter by
movement type — for MoveTo, RunForward/speed*runRate; for substate,
the wire's command/speed; for overlay, Attack/animSpeed (and
get_state_velocity gates correctly to zero, the desired stop).
Tests still 1420 green — the existing parser/driver tests cover the
data; this is a code-path completeness fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed residual after f794832: creature stops to attack but
still runs slightly through the player before stopping.
Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a
60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist
falls just below the 0.6 m DistanceToObject arrival threshold, the
arrival predicate fires and zeroes velocity — but the body has
already advanced one full tick INTO the threshold zone. That last
tick is the "running through" the user sees, especially when
combined with a player visual radius of ~0.5 m.
Fix: cap horizontal velocity in the steering branch so the body lands
EXACTLY at the arrival threshold instead of overshooting it. Pure
function in RemoteMoveToDriver (ClampApproachVelocity) so it's
testable; called from GameWindow.cs after apply_current_movement
sets RunForward velocity from the active cycle.
The clamp is a strict scale-down of the X/Y components; Z is left
to gravity / terrain handling. No-op for the flee branch — fleeing
has no overshoot risk by definition.
Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP
tolerance), would-overshoot scale-down, already-at-threshold zeroing,
flee no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.
When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.
Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.
Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
- ServerMoveToActive flag update — stale RunForward MoveTo state
persisted, the per-tick driver kept steering toward the prior
Origin and calling apply_current_movement.
- InterpretedState.ForwardCommand bulk-copy — even if the flag had
been cleared, the body's InterpretedState.ForwardCommand stayed
at RunForward from the prior MoveTo cycle, so
apply_current_movement kept producing forward velocity.
- MoveToPath capture — staleness-timeout band-aid masked this.
Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.
Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).
Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.
Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regressions on commit 186a584:
1. "Monster keeps running in different directions when it should be
attacking" — chase oscillates around the player at melee range
instead of stopping. Root cause: arrival check used MinDistance
only (retail's algorithm), but ACE puts the melee threshold in
DistanceToObject (default 0.6) and leaves MinDistance at 0. So
our check was never satisfied; body kept re-targeting around the
player as each MoveTo refresh moved the destination.
Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
Honors retail when retail sets MinDistance > 0; falls through to
ACE's DistanceToObject when MinDistance is 0. Confirmed by
independent research (named retail decomp, ACE wire writers,
holtburger client) that DistanceToObject is the documented chase
threshold in ACE; retail's MinDistance is only meaningful when
server config overrides the default 0.
2. "Monster disappears, then runs in place" — entity left our
streaming view, server stopped emitting MoveTo, last destination
stayed cached. When entity re-entered view, body still steered
toward the stale point, eventually arrived (V=0), animation kept
playing → "running on the spot."
Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
~1 Hz during active chase; if no fresh packet for 1.5 s, the
entity has either left view, transitioned off MoveTo without us
seeing the cancel UM, or had its move cancelled server-side.
Clear destination + zero velocity so the next interpreted-motion
UM (or fresh MoveTo) drives the body cleanly.
Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.
Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).
Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase C.1 ships the retail-faithful PES particle pipeline plus a set of
sky-pass refinements that landed alongside it (Translucent+ClipMap blend,
raw-Additive fog-skip, sampler-object wrap selection). Three feature
commits:
ec1bbb4 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
3d21c13 refactor(sky): replace per-frame wrap-mode mutation with persistent samplers
6d159d9 docs(roadmap): mark Phase C.1 shipped
What landed
===========
Particle data layer (retail ports cited in commits):
- ParticleEmitterInfo unpack with all 13 ParticleType motion
integrators (Particle::Init 0x0051c930, Particle::Update 0x0051c290).
- PhysicsScriptRunner with hook scheduling + CallPES self-loop
semantics (FUN_0051bed0..bfb0 family).
- ParticleHookSink translates CreateParticle / DestroyParticle /
StopParticle / CallPES hooks into emitter spawn/stop calls.
- EmitterDescRegistry resolves dat ParticleEmitter records to
runtime descriptors. DAT emitters do NOT default additive — blend
state is derived from the particle GfxObj surface flags.
- AttachLocal (is_parent_local=1) follows the live parent each frame
via ParticleSystem.UpdateEmitterAnchor / ParticleHookSink
.UpdateEntityAnchor — matches retail
ParticleEmitter::UpdateParticles 0x0051d2d4.
- ParticleSystem.EmitterDied lets the sink prune dead per-entity
handle tracking so naturally-expired emitters don't leak.
Particle GL renderer:
- Instanced billboard quads with material-derived blend per particle.
- Global back-to-front sort (across textures + blend modes).
- Bounding-box → axis/size dispatch picking the largest two
dimensions for non-billboard particles.
- Point-sprite degrade detection via DegradeMode == 2.
- C-vector orientation for ParabolicLVGAGR / LVLALR / GVGAGR.
Sky-pass refinements (most landed earlier on feature/sky-fixes; the
C.1 worktree adds the last few):
- Translucent + ClipMap forces alpha-blend for cloud sheet
0x08000023 (matches D3DPolyRender::SetSurface 0x0059c4d0 branch
at decomp line 425246).
- Raw-Additive fog-skip via uApplyFog uniform (matches 0x0059c882).
- Per-keyframe SkyObjectReplace Translucency / Luminosity /
MaxBright divided by 100 (raw dat is percent, shader expects
fraction).
- Bit 0x01 pre/post-scene split (matches GameSky
::CreateDeletePhysicsObjects 0x005073c0 routing).
- Setup-backed (0x020xxxxx) sky objects via SetupMesh.Flatten —
earlier code dropped these silently.
- Persistent GL sampler objects (Wrap + ClampToEdge) replace
per-frame TexParameter mutation. Ported from WorldBuilder
(Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132).
- Post-scene Z-offset (-120m) gated on (Properties & 4) != 0 &&
(Properties & 8) == 0 per GameSky::UpdatePosition 0x00506dd0
instead of firing on every post-scene SkyObject.
Sky-PES playback intentionally disabled
=======================================
A 2026-04-28 named-retail recheck disproved the original C.1
sky-PES premise. SkyDesc::GetSky (0x00501ec0) copies
SkyObject.default_pes_object into CelestialPosition.pes_id, but
GameSky::CreateDeletePhysicsObjects, MakeObject, and UseTime never
read the field. The experimental sky-PES path remains gated behind
ACDREAM_ENABLE_SKY_PES=1 for dat archaeology only — do not
reintroduce per-SkyObject PES playback in the normal render path
without new decompile evidence.
Tests
=====
dotnet build green, dotnet test green: 695 + 393 + 243 = 1331 passed
(up from 1325). New tests:
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
Visual verification
===================
Sky / cloud / weather: confirmed by the user during phase development
(pink clouds restored, post-scene rain cylinder Z gated, no aurora
blobs on the skybox). Sampler refactor visually verified as a no-op.
Deferred to Phase C.1.5
=======================
Wiring entity-attached emitters to retail effect IDs:
- Portal swirls (currently rotating-black-disk placeholder).
- Chimney smoke / fireplace flames.
- Spell-cast effect emitter spawns from animation hooks.
The ParticleHookSink wiring is ready; only the entity-side
identification of which retail effect ID belongs to each weenie
class is deferred. File a follow-up issue if needed.
Adds the Phase C.1 row to the "Phases already shipped" table and
flags the C.1 bullet in the "Phases ahead — Phase C — Polish / visuals"
section as ✓ SHIPPED. Retains C.1 entity-emitter wiring (portal swirls,
chimney smoke, fireplace flames) as a Phase C.1.5 follow-up — the data
layer is ready, only the wiring of entity-attached emitters to retail
effect IDs is deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports WorldBuilder's GL sampler-object pattern
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132,
SkyboxRenderManager.cs:312). Two persistent samplers (Repeat +
ClampToEdge) are created once at GL init; the sky pass binds the
appropriate one to texture unit 0 per submesh instead of mutating
per-texture GL_TEXTURE_WRAP_S/T state.
Why this is better than the prior M1 track-and-restore hack:
1. Sampler state is decoupled from texture state. Two renderers can
share the same texture handle but sample it with different wrap
modes simultaneously and safely — sampler state at the bind point
overrides the texture's own wrap parameters.
2. No bookkeeping. Drops the HashSet<uint> clamped-textures tracking
and the end-of-pass restore loop. The only restore needed is
BindSampler(0, 0) to release unit 0 back to per-texture state.
3. Constant cost. Sampler objects are created once per GL context,
not per draw. Filter modes match TextureCache's upload defaults
(Linear/Linear, no mipmaps) so the binding is purely a wrap-mode
selection.
Field count: SkyRenderer.cs -28 lines, +14 lines. GameWindow.cs gets
the SamplerCache field + ctor + Dispose. SkyRenderer disposed before
SamplerCache so the sky teardown path doesn't reference a freed
sampler handle.
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.
The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.
Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.
Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.
Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
+ MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
(0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
— port aid; flagged divergences (WalkRunThreshold default, set_heading
snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
workflow (decompile → cross-reference → pseudocode → port).
Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).
Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keep the retail MoveTo speed/runRate parsing from 9812965 for animation playback, but do not use the partial MoveTo state as a body-position solver. Until the full retail MoveToManager target path is ported, retain UpdatePosition-derived velocity for server-controlled creature position and prevent that velocity from clobbering the packet-derived animation cycle speed.
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop.
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.
Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.
Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
Self-contained spec for the next session: PES (Particle Effect
Schedule) renderer that produces retail's "aurora light play",
portal swirls, chimney smoke, fireplace flames in one
implementation. Rolls up ISSUES.md #28 (root-caused this session
to PES on CelestialPosition.pes_id) and likely #29 (residual
cloud density gap).
Picks up after sky/weather session (merged at f7c9e88). Phase
E.3 already shipped the data layer (ParticleSystem,
EmitterDescLoader, ParticleHookSink, PhysicsScriptRunner,
VfxModel in src/AcDream.Core/Vfx/). C.1 is the visual half:
SkyDescLoader PesObjectId capture, SkyRenderer emitter spawn,
billboarded-quad GL renderer following WorldBuilder's
ParticleBatcher pattern.
Spec includes Step 0 grep targets, references in priority order
(decomp first, ACME/WorldBuilder second), the Dereth Rainy
DayGroup PES enumeration from tools/StarsProbe (notably
0x3300042C active 0.27-0.91 = "render this and confirm" target),
implementation outline (C.1.0 through C.1.6), pitfalls from
prior sessions, and the worktree setup commands.
To kick off the next session, point it at this file.
Three small hygiene items flagged by external code-review reports
during the sky/weather investigation:
1. CullFace state leak in SkyRenderer.RenderPass.
Disabled CullFace at the start of the sky pass without restoring it
on exit. Benign today — the global convention in this codebase is
CullFace=off and subsequent renderers (InstancedMeshRenderer,
StaticMeshRenderer) explicitly enable on entry / disable on exit —
but a future caller assuming culling stays on across the sky pass
would have silently broken. Wrap with an IsEnabled save / Enable
restore using TextRenderer.cs's pattern.
2. Stale comment in SubMeshGpu.SurfTranslucency doc.
Said "the shader multiplies output alpha by (1 - x)". After commit
97fc1b5 the shader uses translucency DIRECTLY as opacity per retail
D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260).
Updated to reflect the current formula.
3. Stale comment in sky.frag header.
Said "fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency)".
Updated to "× uSurfTranslucency" with citation.
Not addressed: Report 2's "uLuminosity declared but never referenced"
claim. Verified false — the uniform was already removed; the only
remaining uLuminosity references are in comments documenting the
historical removal (sky.frag header line 13-14 explicitly says
"removed 2026-04-26"). Report 2 was reading stale content.
1314 tests pass.
Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:
97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed
Net effect:
* Sun + ambient colors now use retail's |sunVec| magnitude formula
from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
blue-white sky tint at most keyframes.
* Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
cloud + correct rain alpha.
* Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
haze visible without flat-fogging the dome at storm keyframes.
* Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
425295 — sun stays bright at horizon dusk/dawn.
* Pre/post-scene partition is bit 0x01 (post-scene placement) instead
of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
at decomp 269036. Fixes double-rendered foreground rain.
* Translucent flag forces alpha-blend over Additive when ClipMap is
set, matching retail's blend resolution at decomp 425246-425260.
Cloud surface 0x08000023 now classified correctly.
* Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
instead of being silently dropped by EnsureMeshUploaded.
Tests: 1227 pass.
User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.
Outstanding:
* Issue #28 — PES particle rendering ("aurora light play"). Now
root-caused with implementation outline; defer to its own Phase.
* Issue #29 — residual cloud-density gap; likely rolls into #28.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
# src/AcDream.App/Rendering/GameWindow.cs
Updated #28 (aurora effect) from "unknown root cause" to "PES
particles attached via CelestialPosition.pes_id". Includes the
verbatim retail header struct, the StarsProbe-confirmed list of
PES-bearing entries in Dereth Rainy DG3 (notably PES 0x3300042C
active 0.27-0.91, which is the user's Warmtide screenshot), the
implementation outline, and decomp pointers to
CPhysicsObj::InitPartArrayObject + CPartArray::CreateSetup.
Filed #29 for the residual cloud-density gap that remained after
this session's Translucent-override fix (commit 375065b) and Setup
wiring (commit 646ccca). Two follow-up hypotheses captured —
likely rolls into #28 once PES rendering lands.
Independent code review by an external agent (2026-04-27) flagged
that SkyRenderer.EnsureMeshUploaded only ever called
_dats.Get<GfxObj>(...) — every 0x020xxx Setup ID returned null and
got cached as an empty submesh list, silently dropping every
Setup-backed sky object across the Dereth Region. In Rainy DG3
alone that's 6 dropped SkyObjects (0x02000714, 0x02000BA6 ×2,
0x02000588 ×4, 0x02000589 ×3 across various time-of-day windows).
Verbatim from retail's CelestialPosition struct at acclient.h:35451:
struct CelestialPosition {
IDClass<...> gfx_id;
IDClass<...> pes_id; // particle scheduler
float heading; float rotation;
Vector3 tex_velocity;
float transparent; float luminosity; float max_bright;
unsigned int properties;
};
Per the named retail decomp, CPhysicsObj::InitPartArrayObject (decomp
~280484) dispatches gfx_id by type prefix: type 6 → direct GfxObj,
type 7 → Setup via CPartArray::CreateSetup (decomp ~287490) which
walks Setup.Parts. Mirror that here: detect 0x020xxxxx in
EnsureMeshUploaded, route to a new EnsureSetupUploaded helper that
flattens via SetupMesh.Flatten (existing Phase-2 utility) and bakes
each part's transform into the vertex positions before upload.
Sky setups don't animate in any way that affects the static-mesh
visual we render here.
Probe extension: also added the Diffuse column to RainMeshProbe's
sky-surface audit so the (Type, Translucency, Luminosity, Diffuse)
quadruple is visible on every flag-bit row.
Visual impact at verification launch: not observable. The Setup
objects in Rainy DGs appear to be tiny placeholder meshes existing
mainly to anchor PES emitters. The dynamic "aurora-like" sheen the
user observes in retail comes from the PES particle layer, which
remains unimplemented (issue #28). Keeping this fix because the
geometry path is now decomp-correct and provides foundation for
the eventual PES wiring.
Issue #29 filed for the residual cloud-density gap. 1227 tests pass.
acdream's TranslucencyKindExtensions.FromSurfaceType picked Additive
first (priority order). Retail's D3DPolyRender::SetSurface at
0x0059c4d0 (decomp 425083+) has a different resolution: when the
Translucent flag (0x10) is set AND either Base1ClipMap (0x04) is set
OR the surface would otherwise be opaque (no Additive/Alpha/InvAlpha),
the blend is *forced* to (SrcAlpha, InvSrcAlpha) — i.e. standard
alpha-blend, not additive. Verbatim from decomp lines 425246-425260:
if ((curr_surface_type & 0x10) != 0) {
if (skipChk != 0 || ebx == 0 || arg3 == 1) {
edi_2 = BLEND_SRCALPHA; // src
ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend
}
curr_alpha = _ftol2(translucency * 255);
}
Where `arg3 == 1` is set after the Base1ClipMap branch and `ebx == 0`
is the opaque-base case in Branch 2.
Concrete impact: Dereth's inner cloud sheet GfxObj 0x01004C35 uses
surface 0x08000023 with Type=0x10114 (B1ClipMap|Translucent|Alpha|
Additive). Retail renders it alpha-blend; acdream was rendering it
additive. Additive on a dark cloud texture only brightens the
background — sun shines through unchanged — which doesn't match
retail's denser cloud appearance.
Rain surface 0x080000C5 (Type=0x10112 = B1Image|Translucent|Alpha|
Additive, NO ClipMap) hits Branch 1 → Additive, ClipMap branch is
skipped, the Translucent override doesn't fire (arg3 stays 0) → stays
Additive. Visual rain rendering is unchanged.
User reported no visible difference at the verification launch; the
remaining cloud-density gap likely lives in the PES particle layer
(issue #28). Keeping this fix because the classification is now
decomp-correct regardless of immediate visual impact — issue #29
documents the residual gap.
1227 tests pass.
The pre/post-scene sky pass split was using SkyObjectData.IsWeather
(bit 0x04) — the wrong bit. Per the named retail decomp:
GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp 269036:
MakeObject(this, gfx_id, &tex_velocity,
(properties & 1), // arg4: post-scene flag
(properties & 4)); // arg5: weather gate
GameSky::MakeObject at 0x00506ee0 / decomp 268656:
if (arg4 != 0)
AddObjectToSingleCell(result, after_sky_cell); // post-scene
else
AddObjectToSingleCell(result, before_sky_cell); // pre-scene
So bit 0x01 routes between before_sky_cell (rendered pre-scene by
GameSky::Draw(0)) and after_sky_cell (rendered post-scene by
GameSky::Draw(1)). Bit 0x04 is independent — it gates whether the
object is instantiated at all when LScape::weather_enabled is false.
In Dereth's Rainy DayGroup this matters for the rain cylinders:
0x01004C42 Props=0x04 (bit 0x04 only) → pre-scene + weather-gated
0x01004C44 Props=0x05 (bits 0x01+0x04) → post-scene + weather-gated
0x01004C35 Props=0x02 (bit 0x02 only) → pre-scene (cloud, fog-hide)
Before this fix acdream put BOTH rain cylinders in the post-scene
pass (because both have bit 0x04). That double-rendered foreground
rain — explained why acdream's foreground rain looked thicker than
retail's. Now only 0x01004C44 is foreground; 0x01004C42 renders with
the sky dome.
Added SkyObjectData.IsPostScene (bit 0x01) with citations. Renamed
the internal RenderPass parameter weatherPass → postScenePass and
updated both the partition criterion and the -120m foreground-rain
Z offset to gate on it. Public RenderSky / RenderWeather entry
points kept their names for API stability; doc comments updated to
explain the bit semantics.
Independent confirmation from one of the user's external code-review
agents — the report's Setup-objects-silently-dropped finding is the
remaining defect in the same family (Setup IDs 0x020xxx aren't
loaded by EnsureMeshUploaded; deferred to a separate phase).
1227 tests pass.
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:
acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:
SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
sunVec.y = cos(P_rad) ← NOT scaled by DirBright
sunVec.z = DirBright × sin(P_rad)
PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)
Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.
Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).
Tests:
- Replaced the linear-interp assumption in
Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
(RetailSunVector_AtZenith, _AtHorizonNorth,
SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
with the new expected magnitude.
User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.
1227 tests pass.
Three retail-faithful sky/weather composite fixes (one cohesive commit
because they touch the same per-Surface flag plumbing path).
1. Surface.Translucency is OPACITY, not (1 - opacity).
Retail D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260)
computes `curr_alpha = _ftol2(translucency × 255)` and writes that
directly as vertex.color.alpha. ACViewer (TextureCache.cs:142) and
WorldBuilder (ObjectMeshManager.cs:1115) both use `1 - translucency`
and are wrong by the same misread. Cloud surface 0x08000023 has
Translucency=0.25; under the old (1-x) formula opacity was 0.75,
making clouds 3× too bright vs retail. Flipped to use translucency
directly. Gated on the Translucent flag (0x10) so non-Translucent
surfaces (which carry Translucency=0 in the dat) keep opacity 1.0
instead of going invisible.
2. Sky fog re-enabled with a "fog floor" mitigation.
Disabled 2026-04-24 because Dereth sky meshes are authored at radii
1050-1820m while storm-keyframe FogEnd is ~400m, which would saturate
the entire dome to flat fogColor and destroy stars/moon/dome texture.
Retail visibly DOES fog its sky, mechanism still un-pinned. Workaround:
clamp `vFogFactor` to a minimum of SKY_FOG_FLOOR=0.2 so the dome shows
AT LEAST 20% raw texture even at extreme distances. Tuned via dual-
client visual comparison; preserves stars/moon while letting the
horizon haze visibly in low-FogEnd keyframes.
3. Additive sky surfaces skip fog entirely.
Retail D3DPolyRender::SetSurface at 0x59c882 calls
SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) is set —
sun, moon, stars, additive cloud sheets render unfogged. Without this
gate the sun dimmed to fog color at horizon dusk/dawn instead of
staying bright. Plumbed via new `uApplyFog` shader uniform driven by
the existing SubMeshGpu.IsAdditive boolean (already set from
TranslucencyKind.Additive at upload time).
User visually verified all three vs retail screenshots in Holtburg.
Tests: 1223 pass.
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:
arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
arg3 = lerp(k1.amb_bright, k2.amb_bright, u)
final = (arg4.rgb * arg3, ...)
acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
- retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
- acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)
For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.
Refactor:
SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
SEPARATELY (raw, not pre-multiplied).
Computed properties SunColor and AmbientColor return the
post-multiplied product, keeping the shader uniform interface
(uSunColor / uAmbientColor) unchanged.
SkyStateProvider.Interpolate lerps each raw channel, then constructs
a new SkyKeyframe whose computed properties yield the correct
post-lerp multiply.
SkyDescLoader now stores raw values without pre-multiplying.
GameWindow comment updated; no functional change there.
Default factory + tests updated to use the new constructor parameters
with DirBright=AmbBright=1.0 (preserving exact existing behavior).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in calendar display (the CLOCK ITSELF was already correct):
1. **Month enum had wrong order + non-retail names.** Old enum:
Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ...
At day-of-year 83 this gave month index 2 = Leafdawning. Retail's
@timestamp at the same moment shows "Seedsow 24". Fixed enum to
chronological order starting at year-anchor month Morningthaw, with
retail-canonical names:
Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine,
Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap,
Coldeve, Wintersebb.
At day-of-year 83 → month 2 = Seedsow ✓
2. **ToCalendar returned relative year, not absolute Portal Year.**
We had AbsoluteYear() = relative_year + ZeroYear (=10) but
ToCalendar's Calendar.Year was the relative one. So acdream's
title bar showed "PY 106" while retail's @timestamp at the same
tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the
exposed Calendar.Year matches retail's display.
3. **GameWindow title bar now shows the calendar.** Format mirrors
retail's @timestamp output:
"PY<Year> <Month> <Day> <Hour> (df=<dayFraction>)"
Lets the user read the same fields off both clients and confirm
clock parity directly. Drift > 1 hour = real bug.
Tests:
- Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap)
- Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.)
- Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat
pinning a retail-known tick → retail-known calendar string.
The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`,
decomp 0x005a6400 line 434549) was already correct; an earlier-this-
session attempt to flip the sign was reverted in this same commit's
parent. The "few minutes drift" observed in dual-client comparisons
this session was a combination of:
- calendar label mismatch (this fix addresses)
- slot-boundary rounding (fixes itself)
- 1-minute wall-clock interpolation drift (within tolerance)
NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed
("Client clock drifts from retail"); plan to re-title or close in a
follow-up commit after the visual-divergence investigation lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud rendering parity with retail confirmed visually under Phase 0 of
the #27 fix plan: launched acdream with no DG override (LCG-picked
matches retail's pick), compared cloud coverage / color / edges /
movement at the same in-game time. User verdict: "Cloud and colors look
correct."
The original #27 observation from earlier in this session was a
side-effect of the broken `effEmissive=1.0` default that saturated every
sky mesh's vTint to white. That bug, plus the orthogonal `surface.Translucency`
plumbing gap, were both repaired in commit 4678b3e:
- Fix 1 (Translucency): cloud surface 0x08000023 has Translucency=0.25,
now plumbed end-to-end → clouds at 75% opacity instead of 100%.
- Fix 2 (Luminosity): cloud surfaces have Luminosity=0.0, so post-fix
they run through `vTint = ambient + sun·N·L` instead of saturating
to white — clouds pick up the keyframe time-of-day tint.
User also flagged that acdream's clock is "a few minutes ahead" of retail
(sun higher on the horizon at the same wall-clock moment). That is the
existing #3 (`Client clock drifts from retail after ~10 minutes —
periodic TimeSync missing`), reproducing exactly as documented. Out of
scope for the sky-fixes branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rain bug from `docs/research/2026-04-26-sky-investigation-handoff.md`
fully resolved this session. Three commits sequentially landed the
retail-faithful path:
3e0da49 — sky pass split + -120m weather Z offset
4678b3e — Surface.Translucency + Luminosity plumbing
d95a8d2 — delete legacy camera-attached particle emitter
Visual verification by user: rain renders as volumetric foreground,
direction matches retail when LCG-picked DayGroup matches retail's,
no cylinder rim visible looking up.
Two follow-up issues remain open from the visual-verify session:
#27 — cloud rendering parity (Translucency=0.25 partial fix landed
but cloud coverage still differs from retail, possibly
keyframe-tint related)
#28 — aurora/northern lights — research found NO evidence in retail
decomp, references, or DG composition; either misremembered
or emergent from cloud system at specific keyframes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-research workaround at GameWindow.UpdateWeatherParticles +
BuildRainDesc + BuildSnowDesc was acdream's stand-in for retail's
weather rendering. It emitted billboarded particles inside a 15m disk
attached to the camera ('AttachLocal'), with a broken alpha fade
(0.3 → 0 caused rain to vanish at exact ground level — Issue #1) and a
fixed disk that visibly framed the player even at speed.
Retail rain is the world-space mesh path (SkyRenderer.RenderWeather):
GfxObj 0x01004C42 / 0x01004C44 — hollow octagonal cylinder, 113m radius,
815m tall, anchored at player_pos + (0, 0, -120m) per
GameSky::UpdatePosition at 0x00506dd0 — drawn AFTER the landblock pass
per LScape::draw at 0x00506330. Snow renders identically when a Snowy
DayGroup is active: the partition by Properties&0x04 picks up snow
weather meshes for free.
The legacy emitter was gated behind ACDREAM_FAKE_RAIN_PARTICLES=1 in
the previous commit (3e0da49) so the world-space path could be
A/B-compared. Visual verification this session confirmed the world-
space path is correct; deleting the legacy code removes ~120 LOC plus
the env var, the gate, the _rainEmitterHandle / _snowEmitterHandle
fields, and the _lastWeatherKind state machine.
Files affected:
GameWindow.cs: drop UpdateWeatherParticles, BuildRainDesc, BuildSnowDesc,
emitter-handle fields, last-weather-kind state, and the gated call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent brightness bugs were compounding to make rain ~6.7×
too bright at the cylinder rim, and clouds full-bright instead of
time-of-day-tinted:
**Fix 1 — Surface.Translucency was never plumbed to the shader.**
Retail's D3DPolyRender::SetSurface at 0x59c767: when the Surface's
Translucent (0x10) bit is set, its translucency float drives per-vertex
alpha (curr_alpha = ftol(0.5 × 255) = 127). ACViewer
(TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both
encode the same as `opacity = (1 - x)`. acdream read only Surface.Type
and Surface.Luminosity in GfxObjMesh.Build() — Surface.Translucency
(the float) was never read, never stored, never reached the shader.
For the rain Surface 0x080000C5 (Translucency=0.5) this meant rain
streaks were at full alpha=1.0 instead of 0.5 — 2× brighter than retail
under the (SrcAlpha, One) blend.
Plumbed end-to-end:
GfxObjSubMesh.SurfTranslucency (init float, default 0)
GfxObjMesh.Build() reads surface.Translucency next to .Luminosity
SubMeshGpu.SurfTranslucency carries it to draw time
SkyRenderer.RenderPass writes uniform `uSurfTranslucency`
sky.frag final alpha: a = sampled.a × (1 - uTransparency) ×
(1 - uSurfTranslucency)
Bonus reach: cloud surface 0x08000023 has Translucency=0.25 → clouds
also dimmed by 25%, more retail-faithful overall.
**Fix 2 — Emissive default was 1.0 instead of the surface's actual Luminosity.**
The sky shader's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity`
fallback never fired because the local `luminosity` defaulted to 1f (always
> 0). Every sky mesh got effEmissive=1.0, saturating vTint to white before
the alpha blend. The comment claimed the fallback was active; the code
disagreed.
Empirical sky-surface LUMINOUS audit (RainMeshProbe a6e7108) found that
NO Dereth sky surface carries the SurfaceType.Luminous flag (0x40) —
the previous code comment that did was wrong. The differentiator is
purely the Surface.Luminosity FLOAT:
dome/sun/moon: Lum=1.0 → vTint saturates → texture passthrough
stars/clouds: Lum=0.0 → vTint = ambient + sun·N·L → time-of-day tint
rain: Lum=0.1484 → faint emissive baseline + lit additions
Refactored:
replaceLuminosity = NaN sentinel for "no replace override"
rep.Luminosity > 0 → set replaceLuminosity to override value
rep.MaxBright > 0 → cap replaceLuminosity at MaxBright
effEmissive = NaN ? sub.SurfLuminosity : replaceLuminosity
Dead uniform `uLuminosity` removed from sky.frag and SkyRenderer SetFloat
call — the redundant multiply was already commented-out earlier this
year (would have double-dimmed clouds), and the uniform value was unused
in the fragment.
Visual verification (Holtburg, live ACE, Rainy DG forced and natural
LCG-picked): rain rim is no longer visible; cloud direction matches
retail when the same DayGroup is active; sky lighting transitions through
day cycle with appropriate time-of-day tint on stars/clouds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added per-Surface dump that decodes Type bits and prints whether the
LUMINOUS (0x40) flag is set on each. Targets all 27 sky surface IDs
referenced by Holtburg's Region — every dome variant (0x010015EE/F0/F1/F2),
the inner sky/star sheet (0x010015EF), sun (0x01001F67/0x01001348), moon
(0x01001F6A), every cloud variant (0x01004C35..0x01004C3A, 0x010015B6),
and rain (0x01004C42/0x01004C44 — control row).
Result: zero of the 27 surfaces have the LUMINOUS bit set. The previous
SkyRenderer comment that claimed dome+clouds carried the bit was wrong;
the differentiator between "self-lit texture passthrough" and
"ambient+diffuse-tinted" sky meshes is purely the Surface.Luminosity
FLOAT (1.0 dome/sun/moon, 0.0 stars/clouds, 0.1484 rain). This fed
directly into the emissive-default fix in the next commit.
Bonus finding: cloud surface 0x08000023 has Translucency=0.25 (not 0)
which the Translucency plumbing fix in the next commit will also pick
up — clouds will render at 75% opacity, matching retail's curr_alpha
derivation (D3DPolyRender::SetSurface at 0x59c767).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>