Retail applies linear vertex fog with 3D range distance
(D3DRS_FOGVERTEXMODE=3=LINEAR, D3DRS_RANGEFOGENABLE=1,
D3DRS_FOGTABLEMODE=0=NONE) to ALL mesh draws including sky. Only
FOGCOLOR / FOGSTART / FOGEND are lerped per keyframe; the mode flags
are init-only.
Verified in `docs/research/2026-04-23-sky-fog.md`:
- chunk_005A0000.c:3361-3389 device-init sets the modes.
- Sky meshes render at world origin (translation zeroed, rotation-
only) with intrinsic mesh radii in the thousands of meters
(WorldBuilder's SkyboxRenderManager.cs:247 comment confirms).
- With keyframe MaxWorldFog = 2400m, the dome saturates to
WorldFogColor at its horizon band. THAT is retail's dusk/dawn
horizon-glow mechanism.
Port:
`sky.vert` now computes the vertex fog factor:
worldPos = uModel × aPos (camera-centered since view translation=0)
dist = length(worldPos.xyz)
fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
— outputs as varying vFogFactor. 1.0 means no fog contribution,
0.0 means full fog color.
`sky.frag` applies the mix BEFORE the lightning-flash bump:
rgb = mix(uFogColor.rgb, rgb, vFogFactor)
Uses the existing SceneLighting UBO's uFogParams (x=start, y=end,
z=flash, w=mode) and uFogColor — no new uniforms, no C# change.
Expected visual:
- Dome at dawn/dusk: horizon band blends toward keyframe fogColor
(warm orange at sunset, cool blue at dawn), matching retail's
sky/fog coupling.
- Close sky objects (sun disk at typical mesh radius): unaffected
since dist < fogStart.
- Clouds at intermediate distance: partial fog blend, subtly
muting their saturation with distance.
Note on lightning: the flash channel (uFogParams.z) stays wired but
is currently always 0 because no code drives it. Agent #5 is
researching retail's real lightning mechanism (PlayScript / SetLight
PhysicsScript / other). This commit does not attempt to port it.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression 2026-04-23: acdream spawned rain particles
when retail showed no rain at the same server tick. Root cause: my
Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain →
rain particle emitter. That's not what retail does.
Parallel decompile research confirms:
- Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives
at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it
from NOWHERE.
- Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render
loop) never reads SkyObject.DefaultPesObjectId — the field is dead
at render time. Rain/snow particles in retail come from a separate
camera-attached weather subsystem that has NOT yet been located.
So the correct behavior is: DayGroup name should only drive
fog/ambient tone (via keyframes, already in the Snapshot path),
never spawn particle emitters. Any retail-faithful particle rain
belongs to a future phase once we find the camera-attached weather
subsystem driver.
Change: MapDayGroupNameToKind now maps all weathery substrings
(storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only
visuals, no particle spawn. Clear names stay Clear. The Rain, Snow,
Storm enum values remain and are still accessible via ForceWeather()
for debug overrides.
Tests updated (WeatherSystemTests): the name→kind theory now expects
Overcast for Rainy/Snowy/Stormy variants.
Also commits the four research docs from this session's parallel
hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding),
lightning timer (negative finding — agent #3), fog on sky
(positive: retail applies fog to sky geometry).
NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE
RANDOM TIMER hypothesis for lightning. User confirms retail does have
visible lightning + thunder. A follow-up agent (#5, in flight as of
this commit) is hunting the real mechanism — PlayScript opcode,
SetLight PhysicsScript hooks, AdminEnvirons side effects, or the
weather-volume draw. This commit does NOT attempt to port lightning.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures where we stand after Phase 4b and lays out the remaining
retail-faithful port work across four phases (5-8):
- Phase 5: PhysicsScript loader + runtime + sky lifecycle. Replaces
WeatherSystem's crude "DayGroup name contains Rainy → spawn rain"
shortcut with retail's actual PES-driven particle emission.
- Phase 6: Fog on sky meshes. The sky frag currently ignores fog
uniforms; retail's D3D fog applies to sky.
- Phase 7: Lightning flash trigger + thunder audio for storm keyframes.
- Phase 8: Weather / DayGroup crossfade (DAT_008427a9 / _DAT_008427b8
lerp) + AdminEnvirons override → fog crossfade.
User observation 2026-04-23 during Phase 4b verification: "Now it is
raining when it should not be." Root cause traced to the
SetKindFromDayGroupName string match firing rain particles on a "Rainy"
DayGroup regardless of whether that DayGroup actually has a visible
rain-emitting SkyObject. Proper fix requires porting PhysicsScript.
Also commits the earlier research from agent Q1-Q6:
`docs/research/2026-04-23-sky-material-state.md`.
Four parallel decompile agents are in flight as of this commit:
- PhysicsScript dat + runtime
- Sky↔PES wiring + emitter lifecycle
- Lightning + weather crossfade
- Fog on sky + vertex distance
Phase 5 implementation starts once those land.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Phase 4 landed the per-vertex lighting formula, user observed
acdream was still "a bit too bright" vs retail. Root cause:
- My Phase 4 shader deliberately left vTint unclamped so D3D-style
overbright contributions to emissive meshes (dome has Emissive=1 → lit
could reach 2.0 with ambient + sun) would clamp naturally at the
framebuffer.
- But the frag cap was 1.2 (leaving "headroom for lightning flash"),
letting dome vertices run 20% hotter than retail's per-channel 1.0.
Retail's D3D fixed-function pipeline clamps vertex lit colour at
D3DRS_COLORCLAMP=1 (default) BEFORE texture modulation. We now match:
- Clamp `vTint = clamp(lit, 0, 1)` in sky.vert so the saturate happens
at the vertex stage, exactly like D3D.
- Drop normal-frame frag cap from 1.2 → 1.0 (the 3.0 flash relaxation
stays so lightning strobes still visibly blow out).
Expected visual:
- Dome: identical appearance (was clamping to framebuffer 1.0 anyway),
but pure retail-spec rendering so no sneaky 20% headroom.
- Clouds: unchanged (already < 1.0 at morning Rainy keyframe).
- Fragment flash during storm: unchanged — cap relaxes to 3.0 on flash.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-enables the Phase 2 lighting formula that was reverted in Phase 3b
due to a "blue-green-yellow sweep" across clouds. Root cause of that
earlier regression was NOT the formula — it was that we rolled the
wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm
sun against a sky that should have been rendered with diffuse
overcast light. After Phase 3g pinned the LCG multiplier to 360
(DaysPerYear) so retail + acdream agree on DayGroup, the same
per-vertex formula now faithfully reproduces retail's visuals.
The formula is verified in decompile agent Q2+Q4+Q6 results,
`docs/research/2026-04-23-sky-material-state.md`:
D3DRS_LIGHTING = ON (FUN_0059da60:10648)
D3DRS_AMBIENT = 0 (never written after init)
Material.Emissive = (Luminosity, Luminosity, Luminosity, 1)
Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous
light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight)
light.Diffuse = keyframe DirColor × DirBright
Fixed-function lighting per vertex:
lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0)
= Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0)
Fragment: texture × lit × SkyObjectReplace.Luminosity.
Expected visual:
- Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1
→ texture passthrough, baked gradient preserved.
- Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L`
→ purple haze at night (ambient dominates, sun below horizon);
→ warm tan at dusk (ambient + warm sun on west-facing vertices);
→ pale cool gray at noon (ambient + white sun from above).
- Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome +
additive blend — stays bright regardless.
The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive)
were already wired in the C# renderer from Phase 2; Phase 3b just
stopped using them in the shader. This commit re-activates them.
No clamp at the vertex — retail's D3D lighting allows Emissive+sum
to exceed 1, relies on the framebuffer per-channel saturation. We
keep the 1.2 ceiling in the frag (for lightning flash overbright
headroom) consistent with that convention.
No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add
in a follow-up if horizon looks too bright).
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran a live memory probe against retail acclient.exe (new tool:
tools/RetailTimeProbe/) to read the TimeOfDay struct at
DAT_008ee9c8 and compare against our computed values. The decompile
agent's identification of TimeOfDay+0x10 as "SecondsPerDay (int
copy)" turned out to be WRONG — the live value is **360**, which is
GameTime.DaysPerYear.
The retail FUN_00501990 LCG seed is:
seed = Year × (*+0x10) + DayOfYear
= Year × DaysPerYear + DayOfYear
= flat "total days since epoch" day-index
Our previous Phase 3c port passed 7620 (DayLength in ticks) as the
multiplier, producing seed=883,967 against retail's seed=41,807 —
completely different LCG outputs, completely different DayGroup
picks. That's why the user's retail kept showing stormy/rainy while
acdream showed sunny/clear (or vice versa) even after Phases 3c.1
and 3f aligned Year and DayOfYear.
Also confirmed by the probe:
- EpochBase / ZeroTimeOfYear = 3600 ✓ Phase 3f already correct
- BaseYear / ZeroYear = 10 ✓ DerethDateTime.ZeroYear
- Year=116, DayOfYear=47 ✓ our AbsoluteYear / DayOfYear
- SecondsPerDay float (+0x0C) = 7620 ✓ DayTicks
- SecondsPerYear = 2,743,200 ✓ YearTicks
One "finding that's not a fix": retail's +0x48 DayFraction is a
sub-period fraction (fraction through current day/night window)
NOT a full-day fraction. CurDayEnd - CurDayStart = 2857.5 = 0.375
of a day = 6 Dereth hours = night duration. Not relevant for our
keyframe bracket interpolation, which correctly uses a full-day
0..1 scale matching the SkyTime.Begin values. Documented in the
probe research doc so future work doesn't trip on it.
Changes:
- tools/RetailTimeProbe/ — new P/Invoke tool. Forced x86 target to
match retail's bitness so hardcoded DAT_xxxxxxxx addresses are
pointer-width-correct. Handles ASLR relocation via
Process.MainModule.BaseAddress.
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay
passes 360 (DaysInAMonth × MonthsInAYear) not 7620.
- src/AcDream.Core/World/SkyDescLoader.cs: ActiveDayGroup(ticks)
and DefaultDayGroup same.
- docs/research/2026-04-23-retail-memory-probe.md — full probe
results + decompile-agent correction.
- AcDream.slnx — add tools/ folder.
Build + 733 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final piece of the retail-sync puzzle. Live Dereth dat has
GameTime.ZeroTimeOfYear = 3600 (verified 2026-04-23 diagnostic dump).
Our DerethDateTime hardcoded +7/16 × DayTicks = 3333.75, copied from
ACE's DerethDateTime.cs comment "tick 0 = Morntide-and-Half". The dat
is authoritative; ACE's comment is wrong by 266.25 ticks (~33 Dereth
minutes).
User-observed regression (2026-04-23):
acdream: middle-of-night (Darktide), clear, DayGroup "Sunny"
retail: near-pre-dawn (Foredawn), thunderstorm, stormy DayGroup
(both connected to the same ACE at PortalYearTicks=291134079)
Same server tick → different calendar extraction → the offset skewed
dayFraction AND pushed DayOfYear across a boundary at certain ticks,
feeding a different LCG seed into the DayGroup picker (FUN_00501990).
A single 266.25-tick offset error explains both the time mismatch and
the weather mismatch.
Code changes:
- DerethDateTime.OriginOffsetTicks — runtime-settable static, default
= DayFractionOriginOffsetTicks (3333.75, the legacy fallback).
Applied in DayFraction, Year, DayOfYear, ToCalendar.
- DerethDateTime.SetOriginOffsetFromDat(double) — called at Region
load.
- SkyDescLoader.DumpRegionSkyDesc dumps GameTime fields (and all 16
TimesOfDay entries) when ACDREAM_DUMP_SKY=1.
- GameWindow.LoadRegion adopts the dat's ZeroTimeOfYear after
LoadFromRegion, logs the before/after values.
Also dumps every Dereth TimeOfDay hour-boundary (0..15) so any future
calendar weirdness has authoritative ground truth in the log.
Build + 733 tests green (no test depended on the hardcoded offset).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported rain in acdream while retail showed a clear sunny sky
after Phase 3d landed. Root cause: two independent weather systems
running in parallel.
1. Retail DayGroup picker (FUN_00501990 port, Phase 3c/3c.1) —
selected DayGroup[6] "Sunny" correctly.
2. WeatherSystem.Tick (legacy stub from pre-decompile era) —
kept rolling its own hardcoded PDF every day (60% Clear, 20%
Overcast, 12% Rain, 5% Snow, 3% Storm), independent of the
DayGroup picker. Its output drove the rain/snow particle
emitters via UpdateWeatherParticles. If its hash happened to
land on Rain for today's dayIndex, rain rendered even on a
Sunny DayGroup day.
Retail has ONE source of truth for weather: the DayGroup roll. There
is no separate weather state machine — rain/snow/storm are implied by
the DayGroup name and its per-keyframe SkyObjectReplace settings.
Fix (Phase 3e):
- WeatherSystem.SetKindFromDayGroupName(string?) — loose substring
match on the retail DayGroup name: "storm" → Storm, "snow" → Snow,
"rain" → Rain, "cloud"/"overcast"/"dark"/"fog" → Overcast, else
Clear. Case-insensitive. Covers the names observed in the live
Dereth dat dump (Sunny, Clear, Cloudy, Rainy + inferred variants).
- WeatherSystem._externallyDriven flag disables the internal
RollKind auto-roll once SetKindFromDayGroupName has been called at
least once. Tests that drive Tick() directly keep the legacy
hash-roll behavior (offline fallback). ForceWeather still works
for debug overrides.
- GameWindow.RefreshSkyForCurrentDay calls
Weather.SetKindFromDayGroupName(grp.Name) right after it installs
the new SkyStateProvider. Logs the resulting WeatherKind on the
same line as the DayGroup pick for correlation.
- New WeatherSystemTests.SetKindFromDayGroupName_MapsRetailNames
(theory, 14 cases) + SetKindFromDayGroupName_DisablesInternalRoll.
Expected effect: Sunny/Clear DayGroups → no rain emitter. Rainy/Stormy
DayGroups → rain emitter active. The user's specific scenario
(DayGroup[6] "Sunny") now correctly maps to WeatherKind.Clear and no
particles spawn.
Build + 733 tests green (+16 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the in-game sky time in acdream consistently trails
retail's, even after Phases 3a-3c.1 aligned the DayGroup selection.
Diagnosed it as a rate mismatch between our client-side extrapolation
and the ACE server's tick advancement.
ACE advances PortalYearTicks at 1.0 ticks per real-second:
Timers.cs: PortalYearTicks += worldTickTimer.Elapsed.TotalSeconds
Our WorldTime was using SkyDesc.TickSize (0.8 in the live Dereth dat)
as the extrapolation rate:
NowTicks = lastSync + elapsed * TickSize // with TickSize=0.8
Between the server's ~20s TimeSync gap, we fell 4 ticks behind. Every
sync yanked us back, but in the window between syncs the sky interp
was rendering at a stale (earlier) dayFraction — visible as "acdream
is behind retail" when the user had retail running alongside.
Root cause: we misread r12 §1.2's definition of SkyDesc.TickSize.
Agent C's decompile (`docs/research/2026-04-23-sky-decompile-hunt-C.md`
§5 and the 2026-04-23 sky-retail-verbatim synthesis §5) showed
SkyDesc.TickSize is consulted at `FUN_005062e0:6241` as:
_DAT_00842798 = SkyDesc.TickSize + _DAT_008379a8 // next deadline
i.e. the per-frame sky-subsystem update PERIOD. It's a throttle, not a
clock-rate multiplier. SkyDesc.LightTickSize=15 plays the same role for
lighting interpolation (re-run every 15 real-seconds).
Fix: remove the SkyDesc.TickSize → WorldTime.TickSize assignment. Keep
WorldTime.TickSize at its default 1.0, matching the server's rate.
SkyDesc.TickSize stays on the LoadedSkyDesc for a future Phase 4 port
of the actual retail throttle logic.
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live verification (2026-04-23, Phase 3c launch): acdream picked
DayGroup[17] "Rainy" for PY106 day46 while retail at the same server
tick showed clear blue sky with white clouds (Sunny-ish). Root cause:
our port passed the RELATIVE year (106, i.e. years since tick-0) into
the LCG seed, while retail's TimeOfDay+0x64 is ABSOLUTE Year =
floor(...) + ZeroYear (baseYear=10 for Dereth GameTime). The offset
seeds the LCG with `seed = 106×7620+46` vs retail's `seed =
116×7620+46` — `10 × SecondsPerDay = 76200` apart, guaranteed to
land on a different DayGroup index.
Fix:
- DerethDateTime.ZeroYear constant (= 10) + AbsoluteYear(ticks) helper.
- GameWindow.RefreshSkyForCurrentDay feeds AbsoluteYear into the picker.
- LoadedSkyDesc.ActiveDayGroup(ticks) same.
- Calendar display and generic Year(ticks) stay relative; only the
LCG-seed path uses the offset. Matches retail FUN_005a7510:5300 which
explicitly adds baseYear to the relative year before stashing in
TimeOfDay+0x64.
Build + 717 tests green. Next visual check should show matching
weather with retail client side-by-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decompile agent located the retail DayGroup selection function at
FUN_00501990 (chunk_00500000.c:1276). It is a straight-line 32-bit
signed LCG — NOT a ChanceOfOccur-weighted CDF. Replaces the SplitMix64
approximation from Phase 3a.
Algorithm (verbatim from the decompile):
seed = year * secondsPerDay + dayOfYear // TimeOfDay+0x64/+0x10/+0x68
hash = seed * 0x6A42FDB2 + 0x8ABE1652 // signed 32-bit LCG
index = floor(dayGroupCount * (uint)hash / 2^32)
if (index >= dayGroupCount) index = 0 // float-rounding safety
Uniform over all DayGroups. Dereth's 20 groups all carry ChanceOfOccur=5.0
so uniform matches the statistical intent; the weighted walk Phase 3a
attempted is NOT what retail does. The SecondsPerDay multiplier is
load-bearing — without it, adjacent years would share adjacent LCG
seeds and divergence from retail would recur annually.
Result (this session's local ACE):
server: PY106 ColdMeet 17 MorntideAndHalf, ticks=291130073
→ year=106, dayOfYear=(106×0 + 17 across ColdMeet) via DerethDateTime
→ retail picker returns a deterministic uniform index from LCG.
Acdream and retail now agree on the pick for any (Year, DayOfYear)
since both drive from the same server PortalYearTicks.
Changes:
- src/AcDream.Core/World/DerethDateTime.cs: add Year(ticks) and
DayOfYear(ticks) helpers (match retail TimeOfDay+0x64 / +0x68).
- src/AcDream.Core/World/SkyDescLoader.cs:
- SelectDayGroupIndex signature: (year, secondsPerDay, dayOfYear)
instead of the flat dayIndex used by the SplitMix64 approximation.
- Body: retail LCG line-by-line port with decompile citations.
- ACDREAM_DAY_GROUP env var still overrides (for A/B verification).
- src/AcDream.App/Rendering/GameWindow.cs: RefreshSkyForCurrentDay now
feeds Year / DayOfYear / SecondsPerDay=7620 to the picker instead
of a flat dayIndex. Composite `year*360+dayOfYear` still tracked
internally as the day-change key for provider-rebuild idempotence.
- docs/research/2026-04-23-daygroup-selection.md committed with the
full decompile trail (new agent-produced research).
Build + 717 tests green. User visual verification (retail side-by-side)
next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 added a per-vertex lighting path to the sky shader based on the
Phase 1 dump showing dome surfaces with Luminosity=1.0 and cloud
surfaces with Luminosity=0.0. Live visual verification vs retail at
MorntideAndHalf (dayFraction=0.48, user-observed 2026-04-23) disproved
the hypothesis:
retail: clean blue sky + white clouds
acdream: blue-green-yellow sky sweep + greyish clouds
The "sweep" is exactly the signature of per-vertex `diffuse × sunColor`
where sunColor=(250,215,151) warm gold at ~63° east: the west-facing
cloud faces get the gold tint, east-facing stay cool, and interpolation
across the mesh produces the color sweep. Retail's clean white clouds
at the same time of day means retail is NOT applying per-vertex lighting
to sky meshes.
Revised model (unlit + SkyObjectReplace modulation):
fragment.rgb = texture.rgb * uLuminosity
fragment.a = texture.a * (1 - uTransparency)
The "purple haze night / warm dusk" effect users describe from retail
comes from SkyObjectReplace per-keyframe Luminosity dimming + Transparent
fading, NOT from a shader ambient multiply. At midnight, for example,
Replace[0] dims the dome to 11% (Luminosity_raw=11) and Replace[2]
fully hides the drifting cloud (Transparent_raw=100) — so the camera
sees the dome texture at 11% × baked gradient colors, and any purple
the user perceives is baked into the dome texture's night gradient.
The retail-authoritative Surface.Luminosity flag probably feeds a
separate render path (material system? D3D emissive vs diffuse
coefficients?) that is NOT per-vertex GL lighting. A future phase can
revive it if the decompile hunt for the DayGroup selection algorithm
surfaces it.
Code change: sky.vert + sky.frag only. The C# renderer still pushes
uAmbientColor/uSunColor/uSunDir/uEmissive uniforms — they are declared
in the shaders but unused in Phase 3b. No renderer change needed; these
uniforms cost nothing and keep the port-forward path open.
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnosed the "retail shows early night, acdream shows early day" time
mismatch: server time sync was working correctly (ticks=291079558 →
dayFraction=0.8546 → EvensongAndHalf, hour 14 of 16). The mismatch was
the hardcoded DayGroup index.
Dereth's SkyDesc carries 20 DayGroups (Sunny / Clear / Cloudy / Storm /
etc), each weighted at ChanceOfOccur=5.0. Retail rolls one per server
day by `ChanceOfOccur` as a PDF (r12 §11). We were always rendering
DayGroup 0 = "Sunny" regardless of day, so at EvensongAndHalf we showed
SkyTime[7]@0.84 — sun still 20° above the western horizon, warm golden
— i.e. pre-sunset rather than the dimmer pre-night appearance retail
shows after rolling a cloudier group.
Fix (Phase 3a):
- LoadedSkyDesc.SelectDayGroupIndex(dayIndex) — deterministic roller:
SplitMix64 hash of dayIndex → normalize to [0, sumChances) → walk the
cumulative distribution. Same dayIndex on every client = same weather
on every client, zero network sync needed.
- LoadedSkyDesc.ActiveDayGroup(ticks) / BuildProviderForDay(ticks) —
convenience wrappers that compute dayIndex from raw server ticks.
- ACDREAM_DAY_GROUP=<N> env var override. Set to 10 "Clear", 12 "Cloudy",
etc. for A/B visual verification against retail.
- SyncFromServer gains a [sky-dump] log: `ticks=X dayFraction=Y
calendar=PY{year} {month} {day} {hour}` so the time-sync state is
auditable from a single grep.
- GameWindow: tracks _loadedSkyDayIndex + _activeDayGroup. Calls
RefreshSkyForCurrentDay on every server sync — swaps WorldTime's
provider + caches the group only when the day index crosses a
boundary (idempotent within a single day). SkyRenderer.Render now
consumes _activeDayGroup instead of the legacy DefaultDayGroup.
Observed (this session, local ACE):
server sent ticks=291079558 → PY106 ColdMeet 10 EvensongAndHalf
SplitMix64(day 38197) will deterministically pick one of 20 groups.
Build + 717 tests green. Ready for user visual verification with
retail side-by-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the sky port. Empirically confirmed from the Phase 1 dump
(ACDREAM_DUMP_SKY=1 on the live Dereth region): retail distinguishes
self-illuminated sky meshes from lit ones by the `Surface.Luminosity`
FLOAT field (0..1), NOT by the `SurfaceType.Luminous` flag bit (none of
Dereth's sky meshes have the flag set).
Observed values on the 4 currently-visible sky GfxObjs:
GfxObj 0x010015EE (dome, 4 surfaces) Luminosity = 1.0
GfxObj 0x010015EF (upper cloud) Luminosity = 0.0
GfxObj 0x01004C36 (lower drift cloud) Luminosity = 0.0
GfxObj 0x01001348 (sun/moon additive) Luminosity = 1.0
Retail uses this as an emissive coefficient in the per-vertex lighting
formula (decompiled chunk_00500000.c:7535 FUN_00508010 + chunk_00530000.c
AdjustPlanes per-vertex math):
tint = clamp(vec3(Luminosity) + AmbColor*AmbBright
+ max(dot(N, -sunDir), 0) * DirColor*DirBright,
0.0, 1.0)
fragment = texture * tint
When Luminosity=1.0 the clamp saturates → full texture brightness
regardless of time of day (dome gradient preserved; sun/moon always
bright). When Luminosity=0.0 only the ambient + diffuse term drives the
tint, so clouds pick up the time-of-day ambient (purple at midnight
per AmbColor=(200,100,255)×AmbBright=0.4 ≈ (0.31,0.16,0.40); warm tan
at dusk; pale-cool at noon).
Also empirically confirmed: raw SkyObjectReplace Transparent/Luminosity
/MaxBright are in 0..100 percent range (observed 11, 15, 22, 66, 100,
and -1 sentinel). The `/100` divide in SkyDescLoader (eeae83a) is
retail-correct; `_DAT_007a1870` in the decompile must be 0.01f.
Code changes:
- src/AcDream.Core/Meshing/GfxObjSubMesh.cs: new `Luminosity` field on
the per-submesh record (0..1, defaults to 0 for non-sky meshes).
- src/AcDream.Core/Meshing/GfxObjMesh.cs: pull Surface.Luminosity when
building submeshes (alongside existing Translucency capture).
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs:
- SubMeshGpu gains SurfLuminosity, propagated from GfxObjSubMesh.
- Render() pushes uAmbientColor/uSunColor/uSunDir once per frame from
the interpolated keyframe; uEmissive once per submesh.
- uTint uniform removed (replaced by the vTint varying computed in
the vertex shader).
- src/AcDream.App/Rendering/Shaders/sky.vert: computes vTint per-vertex
using the retail AdjustPlanes formula.
- src/AcDream.App/Rendering/Shaders/sky.frag: consumes vTint, drops
uTint uniform. uLuminosity (the per-keyframe SkyObjectReplace
override) still applied as a final scalar multiply.
Expected visual difference from Phase 1 baseline:
- Dome gradient: IDENTICAL (Luminosity=1 saturates).
- Sun / moon: IDENTICAL (Luminosity=1 saturates, additive blend).
- Clouds: now tinted by time of day. Midnight → purple haze. Noon →
pale cool. Dusk → warm tan.
Open questions (unchanged from Phase 1 doc):
- Does the 15s LightTickSize throttling need porting? Phase 3.
- Does FUN_00532440 (AdjustPlanes per-cell terrain relight) need
porting for non-sky geometry to follow the sky? Phase 3.
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The uncommitted uTint=AmbientColor-for-alpha-submeshes experiment (from
the 2026-04-22 inference) dimmed the sky dome's baked gradient — a
user-verified visual regression. Reverting to the eeae83a baseline
(uTint=Vector4.One for every submesh) while we execute the proper
retail-verbatim port.
Research: three parallel decompile-hunt agents landed verifying
retail's ground-truth sky pipeline for the first time (prior audits
searched for stripped symbol names; the trail opened via the Region
dat-type-index 0x1c registration at chunk_00410000.c:12952). Key
retail functions now mapped in chunk_00500000.c:1097-7535:
- FUN_00501530: keyframe bracket-picker (with 1.0f wrap denominator)
- FUN_00501600: sun+ambient interpolator (sunVec = DirBright ×
(sin yaw·cos pit, cos yaw·cos pit, sin pit))
- FUN_00501860: fog interpolator
- FUN_00502820: SkyDesc::Unpack (2 doubles + DayGroup list)
- FUN_00502a10: build per-frame sky-object table
- FUN_00505f30: apply light state + per-cell AdjustPlanes relight
- FUN_005062e0: per-frame sky tick (throttled by LightTickSize)
- FUN_00508010: sky-object render loop (enqueues through the NORMAL
mesh pipeline via FUN_00514b90 — not a bespoke path)
Surprise findings:
- D3DRS_AMBIENT is set to 0 once at init and NEVER changes per-frame
(chunk_005A0000.c). The r12-inferred "clouds = texture × D3DRS_
AMBIENT" formula is falsified. Retail instead routes keyframe
AmbColor through per-vertex lighting on non-Luminous sky meshes
via _DAT_008682bc/c0/c4.
- Retail does NOT anchor the sky to the camera or use a separate
sky projection. Sky meshes live in world space and follow the
camera via scene-graph parent.
- FUN_00532440 (AdjustPlanes) re-lights every terrain cell on every
keyframe tick — the "terrain follows the sky" effect we don't yet
reproduce.
Phase 1 code change (this commit):
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs: revert uTint to white
for all submeshes (the per-submesh blend split stays — sun gets
additive, clouds get alpha). Keep the `keyframe` parameter in the
signature for Phase 2 readiness. Comments now cite the retail
functions and reference docs instead of the (disproven) r12 formula.
- src/AcDream.Core/World/SkyDescLoader.cs: ACDREAM_DUMP_SKY=1 logs
the entire Region SkyDesc on load — DayGroups, SkyObjects, every
SkyTimeOfDay keyframe, and every SkyObjectReplace with RAW pre-/100
Transparent/Luminosity/MaxBright values so we can settle the unit
question empirically.
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs: ACDREAM_DUMP_SKY=1
additionally logs each sky GfxObj's Surfaces and their SurfaceType
flags on first load, so we can identify which meshes carry the
Luminous bit (dome? sun? moon? stars?) vs which are lit.
- src/AcDream.App/Rendering/GameWindow.cs: passes the interpolated
keyframe to the sky renderer (kept — needed for Phase 2).
Research docs (pushed as part of this commit):
- docs/research/2026-04-23-sky-retail-verbatim.md: full synthesis
with retail function map, struct layouts, globals, pseudocode, and
a 4-phase port plan.
- docs/research/2026-04-23-sky-decompile-hunt-{A,B,C}.md: raw hunt
outputs.
- docs/research/2026-04-23-sky-references-crossref.md: WorldBuilder/
ACE/ACViewer/holtburger/Chorizite coverage.
- docs/research/2026-04-23-sky-dat-schema.md: full dat schema + unit
analysis.
- docs/research/2026-04-22-sky-lighting-decompile.md: prior agent's
(superseded) inference — kept for provenance.
Phase 2 will port Surface.Luminous-flag-aware per-vertex lighting for
sky submeshes once the dump resolves the open questions (Luminous-flag
distribution per Dereth sky mesh; _DAT_007a1870 scale constant value).
Build + 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's Region dat stores SkyObjectReplace.Luminosity / Transparent /
MaxBright as percentages in the 0..100 range. Our shader expects
fractions in 0..1. We were passing raw values (luminosity up to 100)
straight into the sky fragment shader's rgb-multiplier:
rgb = sampled.rgb * uTint.rgb * uLuminosity;
At the "Sunny" DayGroup's noon keyframes (verified via live diag),
Luminosity = 100 → shader multiplied the cloud texture RGB by 100 →
min(rgb, vec3(1.2)) clamped all channels to 1.2 → pure white sky.
Also gave the dawn/dusk purple sky effect on top of the pale texture.
Fix: SkyDescLoader.ConvertTimeOfDay divides Luminosity, Transparent
and MaxBright by 100 when loading each SkyObjectReplace. The Rotate
field stays as degrees (values like 270° are genuine headings, not
percentages).
Transparent was accidentally surviving via a 0..1 clamp downstream,
but we fix it for consistency and so brightness-attenuating values
in the 0..99 range (partial fade during dawn/dusk) work correctly
instead of rounding to full-transparent.
WorldBuilder's SkyboxRenderManager does NOT apply these fields at
all — that's why they never hit this bug. Our port applies them for
per-keyframe day-night fades, so we needed the unit conversion.
Also picked up in this commit (incidental, already running):
- Sky render: per-submesh blend mode from TranslucencyKind.Additive
for sun/moon-style self-bright objects (Additive bit 0x10000).
Luminous flag 0x40 intentionally NOT mapped to additive — that
flag is on the sky dome + cloud sheets and making them additive
produced the previous "fully white" iteration of this bug.
- ToD default seed: DayTicks/16 (Midsong = hour 9 = true noon)
instead of DayTicks*0.5 which landed on Gloaming-and-Half (sunset)
due to DerethDateTime's +7/16 day-fraction offset. Pre-TimeSync
view now correctly starts at noon.
- Lightning flash: brighter white-blue (vec3(1.5,1.5,1.8)) instead
of dim grey; ceiling relaxed during flash so the strobe actually
blows out. Cadence (strike intervals, decay) unchanged.
- Saved docs/research/2026-04-21-sky-deep-audit.md with the
decompile+ACE+ACME+WorldBuilder research done to corner this bug.
Open follow-up (not fixed here): sky clouds are white at noon /
don't get the dusk/night purple tint. Our sky shader is fully unlit
— doesn't apply sun/ambient directional light like the terrain
shader does. AmbientColor in the keyframe data carries the right
tint (purple at midnight, magenta at dusk) but we pass
uTint = Vector4.One instead of the keyframe value. Next commit will
wire directional-sun + ambient into sky.frag so cloud meshes pick
up the time-of-day color.
All 717 tests green. User-confirmed: sky colors are now "much
better" after this change (previously fully white).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ground-truth audit of acdream's animation pipeline against retail
decompile (chunk_*.c), cross-referenced line-by-line with our code.
Previous audit relied on ACE and got wiring claims wrong (said our
PlayAction path was orphaned when it's wired via OnLiveMotionUpdated).
Findings:
- PerformMovement dispatcher (FUN_00529a90) matches our MotionInterpreter.
- apply_current_movement cycle priority (FUN_00529210) matches our
OnLiveMotionUpdated sequencer path.
- Commands list → PlayAction wiring matches retail.
- Falling / Jump / Dead substate routing matches.
- Frame-timing epsilon + negative-speed playback matches.
The agent's "hit-react missing" claim turned out to be wrong: the
referenced FUN_0048d760 call passes 32-bit IDs shaped like MotionCommand
values but user-confirmed retail shows NO body animation on damage, so
vtable +0x9c is almost certainly emit-effect / play-sound / spawn-
particle — not a motion play. Not an animation gap.
Open follow-up: CreateObject initial Commands list is parsed but not
replayed when the entity hydrates (minor; rare case).
Not a follow-up: on-hit combat feedback (particles, damage numbers).
That's a separate feature, not an animation pipeline concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled physics fixes that together resolve "+Acdream walks on top of
water instead of submerged" and "brief Falling animation when running up
steep hills".
## 1. Water depth = physics adjustment, not rendering
Retail has NO separate water surface mesh. Characters visually submerge
in water because ValidateWalkable adds `waterDepth` to its signed-distance
check (ACE ObjectInfo.cs:124), letting the character's feet sit below the
terrain plane by that amount before the push-up fires. Rendered character
below rendered terrain = looks submerged.
Our ValidateWalkable didn't carry a waterDepth, so feet were always
snapped exactly to the plane. Water cells looked like walking on water.
Added:
- TerrainSurface now carries per-vertex water flags (bits 2-6 of
TerrainInfo → SurfChar lookup) and per-cell classification.
- TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry),
0.45 (partial-water near water corner), 0.9 (entirely water). Deviates
from retail's 0.1 fallback for "dry corner of partial-water cell" —
that 0.1 destabilizes the "feet exactly on plane" contact-touch check
in ValidateWalkable (dist > EPSILON, SetContactPlane skipped,
ValidateTransition clears OnWalkable, gravity applies, character
micro-falls each frame).
- PhysicsEngine.SampleWaterDepth is the world-space wrapper.
- FindEnvCollisions samples the per-point depth and forwards it.
- ValidateWalkable adds +waterDepth to the signed-distance check (this
is the ACE-line-124 port).
GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo
ushort and passes it to the TerrainSurface ctor so classification works.
## 2. AdjustOffset safety-push threshold on sloped planes
The LocalSphere is positioned at `(0, 0, radius)` — center along world
+Z from the character root. On a tilted plane the sphere center's
perpendicular distance to that plane is `radius * Normal.Z`, NOT
`radius`. The original threshold `dist < radius - EPS` therefore fires
spuriously on every slope and the follow-up push-up lifts feet by
`radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°.
The steep-slope lift is large enough to break ValidateWalkable's
contact-touch check, ValidateTransition then clears OnWalkable,
calc_acceleration applies gravity, and the character flickers into the
Falling animation for ~0.3s while running uphill. User-observed on steep
hills after today's water-depth work made the artifact visible (before
that, general hover masked it).
Fix: the threshold is `radius * Normal.Z` (the natural resting distance
of a Z-axis sphere on the plane). The push fires only when feet are
actually penetrating below natural resting, not on any sloped plane.
ACE's Transition.cs AdjustOffset has the original threshold but the bug
is invisible server-side.
All 717 tests green. Water submersion + steep-slope running both
user-visually verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Our previous FindEnvCollisions built a FLAT contact plane (Normal = +Z)
at the sampled terrain Z, discarding the triangle's actual slope.
Retail uses the real terrain polygon's plane (ACE Landblock.cs:125-137
find_terrain_poly → walkable.Plane) which IS sloped.
Without a true slope normal, AdjustOffset's projection of horizontal
velocity onto the plane produces no slope-aligned Z component — fine
for step-subdivision on flat ground, visibly wrong whenever the contact
plane is carried across frames (via PhysicsBody.ContactPlane persistence
from commit 93cbabb): the projection is a no-op and movement is purely
kinematic. With the real slope normal, projected motion correctly
follows the slope.
Not a user-visible bug fix by itself (DIAG LocalZ shows delta≈0 for the
local player everywhere; the "looks too high in water" issue the user
reported is actually a missing water-rendering feature, not a physics
bug). Landing it anyway because it matches retail behavior and removes
the "flat-plane-is-fine" assumption that would bite on any future
contact-plane-dependent code.
Additions:
- TerrainSurface.SampleSurface(localX, localY) → (Z, Normal), deriving
the plane normal analytically from the triangle's height gradient.
Matches the same triangle SampleZ already interpolates through.
- PhysicsEngine.SampleTerrainPlane(worldX, worldY) → System.Numerics.Plane,
the wrapper that bridges terrain space to transition space.
- TransitionTypes.FindEnvCollisions uses SampleTerrainPlane instead of
synthesizing a flat plane from SampleTerrainZ.
All 717 tests green. Flat-plane case is unchanged (Normal.Z = 1 when
the triangle is level, identical to the old plane).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two linked issues both rooted in skipping parts of the retail physics chain.
## 1. Remote staircase on slopes — Euler never integrated between UPs
TickAnimations called rm.Body.update_object(now) for remote integration, but
PhysicsBody.update_object gates on MinQuantum = 1/30s (retail FUN_00515020
early-return). At our 60fps render tick (~16 ms), deltaTime < MinQuantum on
almost every frame → early return AND LastUpdateTime never advances → position
effectively never integrates. Remote Position changed only on UP hard-snap,
producing visible teleport strides uphill (the "staircase" the user reported).
Fix: call UpdatePhysicsInternal(dt) directly for the remote tick — the same
pattern PlayerMovementController.cs:358 uses for the local player. Wire
ResolveWithTransition in afterwards so the remote's Euler-advanced position
gets swept through the same retail collision chain (find_env_collisions +
find_obj_collisions + step_down + 6-path BSP dispatcher) that the local
player already goes through.
New field RemoteMotion.CellId tracks the remote's cell across frames; set
from UpdatePosition.p.LandblockId and updated from transition output.
## 2. Local player floating on downhill slopes — ContactPlane not persisted
Running a character down a slope faster than ~0.5 m/s vertical: per-frame
Euler moves feet horizontally (no Z component since velocity is world-XY).
After Euler, feet are above the new-XY terrain. ValidateWalkable takes the
"above surface" branch without setting a contact plane, DoStepDown probes
~4 cm down (the retail StepDownHeight default), fails to find the surface
8-10 cm below, and the character stays at the old Z. Over a sustained
descent this accumulates into a visible hover.
Retail's PhysicsObj carries ContactPlane + ContactPlaneCellID as persistent
fields (ACE PhysicsObj.cs:2598-2604 get_object_info → InitContactPlane).
Each transition call seeds CollisionInfo.ContactPlane from the previous
frame's plane. That seed is what lets AdjustOffset project horizontal
velocity onto the slope surface — so the Euler offset acquires a Z
component matching the slope and the sphere tracks terrain without needing
step-down to do the catch-up every frame.
Fix: add PhysicsBody.ContactPlane* fields mirroring PhysicsObj's. Extend
ResolveWithTransition with an optional `body` parameter; when provided, seed
the transition's CollisionInfo from body.ContactPlane at the start, copy
back (preferring current, falling back to LastKnown) at the end. Both local
(PlayerMovementController) and remote (TickAnimations) pass their body.
Verified live: DIAG samples showed pre/post/resolved Z all exactly equal
before the MinQuantum bypass (Euler frozen). After bypass, deltas dropped
to floating-point noise on slopes for remotes. Local hover on downhill
resolved in separate visual pass.
All 717 tests green. No API breaks (ResolveWithTransition's body param is
optional, backwards-compatible).
Cross-refs:
- decompile: FUN_00515020 update_object, FUN_005111D0 UpdatePhysicsInternal,
FUN_005148A0 transition init
- ACE: PhysicsObj.cs:2586-2621 get_object_info, Transition.cs:613-620 InitContactPlane
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.
Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.
Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
- LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
- terrain.vert: corner-index tables updated to match.
- TerrainSurface.SampleZ: swapped the two branches' interpolation.
After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.
Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).
Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the full retail-faithful remote-entity motion port that shipped
today (commit 340dabb). Documents the wire-format discoveries (correct
MovementStateFlag bits, ACE stop signals, absent-HasVelocity semantics),
the architecture (per-remote PhysicsBody + MotionInterpreter), and the
5+ failed approaches we worked through before landing on the right one.
Key pickup for next session: investigate retail observer view of ACdream
player — user reported "not perfect" right before calling it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.
## What changed
### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40
Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).
### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).
On UpdateMotion:
- ForwardCommand flag absent → stop signal (reset to Ready) per
retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
- Forward + sidestep + turn each route through DoInterpretedMotion,
exactly as retail FUN_00528F70 does.
- Animation cycle selection: forward wins if active, else sidestep,
else turn, else Ready. Matches the user's observation that retail
plays turn animation when only turning.
- Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
MotionData.Omega.Z ≈ π/2 per decompile).
- Turn absent → ObservedOmega = 0 (stops rotation immediately).
On UpdatePosition:
- Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
set_frame (direct assignment, no slerp — retail does not soft-snap).
- HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
- ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
alt releases W); previously we defaulted to 1.0, causing the "slow
walk that never stops" symptom.
Per-tick:
- apply_current_movement → Body.Velocity via get_state_velocity
(retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
rotated by orientation).
- Manual omega integration: Orientation *= quat(ObservedOmega × dt).
Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
was eating every-other-tick rotation updates at our 60fps render
rate — the cause of the persistent "rotation snaps every UP" bug.
- update_object still called for position integration and the motion
subsystem it drives.
### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.
### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.
## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs in the motion/animation pipeline:
1. Player's local animation was getting reset to speedMod=1.0 every ~100ms.
ACE's BroadcastMovement echoes the player's own motion back via
UpdateMotion. When ACE's ForwardSpeed == 1.0, the ForwardSpeed flag is
omitted (InterpretedMotionState.BuildMovementFlags), so our wire parser
returns null and we default to speedMod=1.0 — clobbering the
locally-authoritative 2.375 × runRate that UpdatePlayerAnimation just
set. Legs would crank up to full cadence for one frame then get slammed
back to walking rate.
Fix: for the player's own guid, skip the wire-echo SetCycle entirely.
UpdatePlayerAnimation is the authoritative driver for the local
player's animation; the server echo is only useful for observers of
other characters. User-confirmed: legs now hold their full cadence.
2. Remote entities teleported between UpdatePositions because the
sequencer's CurrentVelocity was always zero (Humanoid dat ships every
locomotion MotionData with Flags=0x00, so EnqueueMotionData leaves
CurrentVelocity at Vector3.Zero). Dead-reckoning's Priority 1
(sequencer velocity) never triggered, falling through to EMA which
has bootstrap lag + gets polluted by teleport-class server snaps.
Fix: synthesize CurrentVelocity in SetCycle from the retail locomotion
constants (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25)
× speedMod, matching the decompiled get_state_velocity (FUN_00528960)
which uses these same constants directly instead of MotionData.Velocity.
The dat's HasVelocity field is reserved for non-locomotion motions
(kick-off velocities, flying creatures, etc).
Diag confirmed synthesis fires and DR picks it up with src=seq and
correct magnitude. More visual polish may still be needed for the
"lagging remote" symptom — see follow-up.
Also adds `PlayerMovementController.BodyVelocity` utility getter for HUD/
debug use, and `ACDREAM_ANIM_SPEED_SCALE` env var as a tunable knob for
visual pacing overrides.
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The decompiled get_state_velocity (FUN_00528960) literally computes
`RunAnimSpeed * ForwardSpeed` — a 4.0 × runRate world velocity. That
matches retail only when the character's MotionTable happens to bake
MotionData.Velocity.Y = 4.0 on RunForward (true for Humanoid, not
necessarily for other creatures or swapped weapon-style cycles).
When MotionData.Velocity ≠ RunAnimSpeed, the body's world velocity
drifts away from the animation's baked-in root-motion velocity, and
you see the classic "legs cycle too slowly for how fast the body is
sliding" visual bug. User reports ~30% discrepancy ("running animation
is too slow"), consistent with Humanoid RunForward's actual dat
Velocity being ~3.0 rather than the 4.0 constant.
The fix per r03 §1.3: physics body velocity = MotionData.Velocity ×
speedMod. That's exactly what AnimationSequencer.CurrentVelocity
already exposes. Route it into MotionInterpreter via an opt-in
Func<Vector3> accessor. When wired, get_state_velocity uses the
sequencer's cycle velocity as the primary forward-axis drive; when
unwired (tests, physics bodies without a sequencer), falls back to
the decompiled constant path — byte-compatible with retail on the
shapes where it actually matters.
The RunAnimSpeed × rate max-speed clamp at the bottom of
FUN_00528960 stays intact — Option B only replaces the *drive*, not
the clamp. 20 m/s phantom MotionData can't teleport the player.
Wiring: GameWindow attaches `playerAE.Sequencer.CurrentVelocity` to
`_playerController` on Tab-player-mode entry. The sequencer is always
built before the player enters chase mode, so timing is safe.
Sidestep continues to use SidestepAnimSpeed — the sequencer only
tracks the current forward cycle, so strafe is a separate axis.
6 new MotionInterpreterTests verify: accessor overrides constant path,
zero Y falls back to constant (link transitions), clamp still applies,
Ready state doesn't leak accessor value, sidestep axis is untouched.
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the live-server loop: ACE at 127.0.0.1:9000, +Acdream test char,
the canonical PowerShell launch command with env vars, the 3-5 s logout
delay (exit 29 otherwise), diagnostic env vars, and the distinction
between 'own view' vs 'retail observer view' when triaging motion bugs.
This captures workflow that's been implicit across many sessions so any
Claude instance picking up the project can launch against the live server
on its first try instead of re-discovering the incantation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 1: remote chars never animate, just teleport.
Root cause: when OnLiveMotionUpdated transitions a remote entity from
Ready to a locomotion cycle (cmd=0x0007 RunForward), the
_remoteLastMove timestamp is still pegged to the last position update
from BEFORE the motion change (often >300ms old). On the very next
TickAnimations, stop-detection signal 1 immediately fires
(now - last.Time > 300ms), and the sequencer is flipped straight back
to Ready. Result: the run cycle flashes for one frame and is gone.
Fix: when we enter a locomotion cycle from a non-locomotion one, stamp
_remoteLastMove[guid].Time = now and drState.LastServerPosTime = now
so the stop-timer starts a fresh 300ms window from the transition.
Bug 2 + 3: Our own player's walk/run toggle not broadcast when only
Shift toggles mid-move.
Root cause: PlayerMovementController's motion-state-change detection
compared only (ForwardCommand, SidestepCommand, TurnCommand). When
the user walks (W) then adds Shift mid-stride, ForwardCommand stays
WalkForward but outForwardSpeed jumps 1.0 -> runRate and localAnimCmd
swaps Walk -> Run. 'changed' stayed false, no MoveToState broadcast,
server still thought we were walking. Retail observers saw walking.
Fix: extend the diff to include outForwardSpeed, input.Run (hold-key),
and localAnimCmd. Any of them flipping forces a new MoveToState.
Bug 4: Wrong MotionCommand class byte reconstruction.
Root cause: OnLiveMotionUpdated's heuristic OR'd the sequencer's
current-motion class byte with the wire-received low 16 bits, producing
values like 0x41000007 for RunForward (actual retail value is
0x44000007). Cycle key lookup uses only low 24 bits so the animation
mostly-worked, but the wrong class byte broke stance-aware code paths
and any downstream consumer that keys off the class.
Fix: route ForwardCommand through MotionCommandResolver.ReconstructFullCommand
(same path already used for Commands[] items) — retail-exact class
byte recovery via a reflection-built enum lookup table.
Build + 711 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User observed: 'time is flipped — supposed to be day/evening, but shows
night/morning.' That's a ~half-day offset.
Root cause in ACE DerethDateTime.cs line 23:
private const double dayZeroTicks = 0; // Morningthaw 1, 10 P.Y. - Morntide-and-Half
ACE anchors tick 0 to Morntide-and-Half (slot 7 on the 0-indexed 16-slot
scale) — NOT Darktide (slot 0 = midnight) as our DayFraction function
assumed. Confirmed by DerethDateTime.cs:145:
private int hour = (int)Hours.Morntide_and_Half;
Fix: shift DayFraction by +7/16 * DayTicks (3333.75) so tick 0 maps to
its real calendar slot. Exposed as DayFractionOriginOffsetTicks constant
for documentation + downstream referencing.
Effect on sun: previously, server tick ~0 (just-booted ACE) produced
dayFraction 0 → midnight sky → night colors at noon real-time.
Now dayFraction 7/16 = 0.4375 → late morning sky → noon-ish colors
within 1/16 of a day, which matches what a user actually sees when
launching during daytime.
Tests updated for the corrected convention:
- DerethDateTime.DayFraction(0) = 7/16 (not 0).
- CurrentHour(0) = MorntideAndHalf (not Darktide).
- IsDaytime(0) = true.
- Midnight (Darktide, slot 0) is 9/16 of a day past tick 0.
- SkyState + WorldTimeDebug tests retargeted to the new frame.
Build green, 711 tests pass.
Ref: references/ACE/Source/ACE.Common/DerethDateTime.cs:23-25 + :145.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two runtime blockers discovered after merging the sky/weather/lighting
branch:
1. GLSL reserved word: mesh.frag + mesh_instanced.frag used \`int active\`
as a local. On GLSL ES / some drivers \`active\` is a reserved identifier
and compile fails hard (\"ERROR: 0:38: 'active' : Reserved word\").
Renamed to \`activeLights\`.
2. SkyRenderer.EnsureMeshUploaded called DatCollection.Get<GfxObj>
without the _datLock that wraps the streaming pipeline's dat reads.
DatBinReader has shared buffer state; concurrent reads race and
throw ArgumentOutOfRangeException from Vec2Duv.Unpack deep in the
mesh parse. Wrapped both Get<GfxObj> and GfxObjMesh.Build in
try/catch and cache a null entry on failure so we don't retry every
frame and crash the render loop. Full fix would plumb _datLock into
the sky renderer, left as a TODO.
Client now stable end-to-end — in-world, spawn stream flowing,
animation + audio + sky + light UBO all live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships full retail-faithful sky-object rendering, 5-kind weather with
deterministic per-day roll + storm lightning, dynamic-lighting shader
UBO with retail hard-cutoff semantics, per-entity torch LightSource
registration via Setup.Lights, ParticleRenderer for rain/snow, and
TimeSync handshake wiring. F7 / F10 debug keys for time/weather
cycling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update the roadmap's 'shipped' table with the G.1+ entry covering the
end-to-end visual integration (sky renderer, weather system, particle
renderer, UBO-backed shader lighting, server time sync) — not just the
data-plumbing layer that went out yesterday.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small polish commit:
- Clamp ClearColor inputs to [0, 1] because retail keyframes store
sun/fog colors pre-multiplied by their brightness scalars, which can
exceed 1.0; some drivers treat ClearColor > 1 as a saturate-bright
hint and produce visible color shifts at the edges.
- 4 new tests cover WorldTimeService.SetDebugTime / ClearDebugTime /
SyncFromServer-clears-override / SetProvider hot-swap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WorldSession now surfaces the server's PortalYearTicks via a new
ServerTimeUpdated event, fired from two sources per r12 §12:
1. Initial ConnectRequest handshake (ConnectRequestServerTime field
of the optional block — seeds the clock on login).
2. Every subsequent packet carrying the TimeSync header flag
(0x01000000) — keeps the client clock within one TimeSync period
of authoritative server time.
GameWindow subscribes the event into WorldTimeService.SyncFromServer,
so the day/night cycle + keyframe interpolation runs from real server
time in live mode. Offline mode (ACDREAM_LIVE=0) still uses the
seeded-to-noon fallback from OnLoad.
DebugOverlay now exposes sky + weather + lighting state:
time 0.50 Midsong (day fraction + hour name)
wx Clear parts 0 (weather kind + live particle count)
lit 1/4 (active / registered lights)
F7 cycles a debug time override through
(none → midnight → dawn → noon → dusk → none)
F10 cycles weather through
(Clear → Overcast → Rain → Snow → Storm).
These keybinds satisfy the visual-verification tier so a user can
flip through every state from the running client without touching
the code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills in the test coverage gap for the rotational side of the
sequence-wide physics. Symmetric to the existing
CurrentVelocity_ScalesWithSpeedMod test: at speedMod=2.0 a
MotionData.Omega of (0,0,1) surfaces as (0,0,2). This is what the
omega rotation-integrator in TickAnimations reads each tick. 660
tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the server re-sends CreateObject for the same guid (visibility
refresh, appearance update, landblock crossing) we already drop the
old WorldEntity + animated entry + physics registration. Now also
clear the dead-reckon + last-move dicts keyed by the server guid so
the next UpdatePosition doesn't see leftover SnapResidual or
LastServerPos from the previous incarnation — which would make the
first position update look like a soft-snap transition.
Small fix, no new tests. 659 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Register dat-defined LightInfos as runtime LightSources when entities
stream in. Every Setup (0x02xxxxxx) with a non-empty Lights dictionary
gets its per-part lights pulled via LightInfoLoader, which converts
the local Frame + ColorARGB + Intensity + Falloff + ConeAngle fields
into world-space LightSource records owned by the entity id.
Wire the LightingHookSink into the animation-hook router so retail's
SetLightHook animations (ignite-torch, extinguish-lamp) flip the
matching LightSource.IsLit latches. One hook may own multiple lights
(lamp-posts with two LightInfo entries) — the sink maintains an
owner-indexed map so all get toggled together.
Unregister on landblock unload: the streaming controller's
removeTerrain callback grabs the loaded landblock's entity list (new
GpuWorldState.TryGetLandblock helper) and drops every owner from the
sink before the entities disappear — otherwise walking across
landblocks accumulates stale LightSources.
9 new tests (LightingHookSink routing + LightInfoLoader conversion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: when the dead-reckoner's prediction and the server's
UpdatePosition disagreed, the hard reassignment caused a visible 1-frame
teleport. Even a small 0.3m prediction error (common when velocity ≠
server's ground truth by a bit) looked like a stutter-step.
Now: on each UpdatePosition, we compute the error
preSnapPos - newServerPos
and stash it as SnapResidual. Each tick the residual decays at
SnapResidualDecayRate (~8/sec, so ~300ms to fade from 1m to 0.05m). The
rendered Entity.Position = authoritative_DeadReckonedPos + residual.
Authoritative position and rendered position are now separated:
- DeadReckonedPos: server truth + velocity*dt integration (used by
clamp logic, collision, shadow registration — anything that needs
accuracy).
- Entity.Position: DeadReckonedPos + SnapResidual (what the camera
sees — smooth blend through prediction errors).
Large errors (> SnapHardSnapThreshold = 5m) are treated as
teleports/rubber-bands and hard-snap with no residual, so a portal
transition doesn't produce a 300ms slow-drift.
No new tests — the visual smoothing is a GPU-side behavior. The
integration tests already cover the authoritative DeadReckonedPos
correctness (via CurrentVelocity scaling + retain-through-link).
659 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remote entities in a Turn cycle had no rotational dead-reckoning: their
Rotation quaternion only updated on UpdatePosition arrival, making
in-place turns look jumpy when the server sent updates at 5-10Hz. The
sequencer exposes Omega (radians/sec per axis) via the same SetVelocity/
SetOmega pair retail uses, so all we need to do is integrate it.
Implementation in TickAnimations:
float angle = |omega| * dt;
Quaternion delta = CreateFromAxisAngle(normalize(omega), angle);
entity.Rotation = normalize(entity.Rotation * delta);
Gated on the low-byte motion being TurnRight (0x0D) or TurnLeft (0x0E)
so we don't apply spin to non-turning cycles that happen to carry a
nonzero omega (e.g. creature sway emotes). Matches ACE
Sequence.apply_physics L221-L229:
frame.Rotate(Omega * quantum)
which treats the argument as a local-axis scaled rotation.
No new tests — Omega is the rotational dual of Velocity, already covered
by the velocity-integration tests. 659 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ParticleRenderer that draws every live particle from the shared
ParticleSystem as a billboarded quad. Unit quad VBO + per-instance
(pos, size, color) VBO with glVertexAttribDivisor for one draw call
per emitter. Billboards using the camera's basis vectors so quads
always face the viewer.
Fragment shader does a procedural radial falloff (no texture pipeline
needed — raindrops / snowflakes read as soft dots). AttachLocal
emitters get re-centred on the camera each frame so the rain volume
follows the player per r12 §7.
Two-pass render splits additive from alpha-blend emitters so blend
state flips once per kind rather than per-emitter.
Wired into GameWindow.OnRender after static-mesh draw with depth
write off (particles occluded by walls but don't self-occlude).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
- 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
- Ambient RGB + active light count
- Fog start/end/mode + color + lightning flash scalar
- Camera world position + day fraction
The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.
Shader changes:
- mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
fragment using the retail no-attenuation hard-cutoff model
(r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
Additive lightning flash + linear fog layered on top. Saturate
clamps per-channel to 1.0.
- terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
fog + flash on top of the baked vertex color.
- mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
stage can do per-pixel lighting against world-space positions.
- New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.
SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.
GameWindow integration:
- OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
WorldTime's provider to the dat-accurate keyframes. Seeds to noon
for offline rendering. Creates the SceneLightingUboBinding and the
SkyRenderer.
- OnRender: set clear color from atmosphere fog, tick WeatherSystem,
spawn/stop rain/snow camera-local emitters on kind change, feed
sun to LightManager (zero intensity indoors — r13 §13.7), tick
LightManager against viewer pos, build + upload the UBO, draw
sky before terrain, draw terrain + static + instanced using the
shared UBO.
5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).
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>
Two tightly-related refinements that complete the speed-scaling and
observer-stop story:
1. Local player animation speed now reflects ForwardSpeed.
UpdatePlayerAnimation previously called SetCycle(style, motion) with
the default speedMod=1.0, so the local character's anim played at
fixed rate regardless of RunRate. Now:
- Pass result.ForwardSpeed through to SetCycle, so a 1.5× RunRate
player's run loop plays at 1.5× framerate (same timing as the
server-broadcast value remote observers see).
- Fast-path tracks both _playerCurrentAnimCommand AND
_playerCurrentAnimSpeed; a speed-only change still goes through
SetCycle, which then hits the rescale-in-place fast-path via
MultiplyCyclicFramerate.
Retail matches: the footsteps plant at the right world positions
because animation rate × physics rate stay aligned.
2. Remote stop-detection is more responsive.
Previously the 400ms stale-position heuristic was the sole stop
signal. Added a second signal: EMA observed velocity below 0.2 m/s
means the entity is stationary regardless of how recent the last
UpdatePosition was (common case: server IS sending position updates
but all at the same coordinates). Both signals gate on sequencer
CurrentVelocity also being low, so we don't flip-flop when the
motion data itself carries non-zero velocity but the entity happens
to be paused mid-stride. Stop-idle timer also tightened from 400ms
→ 300ms to match typical NPC heartbeat cadence.
Tests unchanged — both changes are small behavior tweaks around the
already-tested speed-scaling path. 654 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.
Three parts:
1. New MotionItem wire record in ServerMotionState — carries Command
(u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.
2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
now read the full InterpretedMotionState: all 7 flag fields
(CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
MotionItem tail. The packed u32 encodes flags in low 7 bits and
command count in bits 7+ (see ACE InterpretedMotionState.cs:131).
3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
class byte from a 16-bit wire value via a reflection-built lookup
of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
(ACE InterpretedMotionState.cs:139) and we need the class to route:
- 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
PlayAction (resolves from Modifiers or Links dict, overlays on
current cycle)
- 0x40xxxxxx SubState → SetCycle (cycle change)
4. OnLiveMotionUpdated in GameWindow dispatches each command:
- SubState class (0x40xxx) → SetCycle (treated same as
ForwardCommand)
- Action/Modifier/ChatEmote → PlayAction — the link animation
plays once then drops back to the current cycle naturally
(matches retail's action-queue pattern in CMotionInterp
DoInterpretedMotion, decompile FUN_00528F70).
Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).
Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: remote characters stutter-hop between UpdatePosition broadcasts
(typical 100-200ms interval), looking lagging-forward during continuous
motion. The retail client hides this gap by integrating velocity forward
each tick — apply_current_movement in chunk_00520000.c L7132-L7189,
mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs.
Strategy:
1. RemoteDeadReckonState per remote entity tracks the last authoritative
server position + rotation, an EMA-smoothed observed velocity from
position deltas, and any server-supplied HasVelocity vector.
2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity
to the server position, then update the dead-reckon state. The
observed-velocity is a 50/50 EMA against the running average so a
single jitter sample doesn't blow out the velocity.
3. TickAnimations: each tick, for every remote entity in a locomotion
cycle, integrate Entity.Position += worldVelocity * dt. World velocity
is pulled in priority order:
- Sequencer's MotionData.Velocity rotated by Entity.Rotation (the
primary source; matches MotionData's "world-space on the object"
convention per r03 §1.3)
- Server-supplied HasVelocity from UpdatePosition (already world-space)
- EMA-observed position-delta velocity (fallback for NPC motion
tables with HasVelocity=0)
4. Cap: if the predicted position drifts more than velocity ×
DeadReckonMaxPredictSeconds (1.0s) from the last server position,
clamp back toward the server. This prevents runaway when sequencer
velocity and server reality disagree (e.g. server rubber-banding).
Result: remote chars now move smoothly between position updates,
matching the retail client's visual feel. When UpdatePosition arrives
the entity snaps to the authoritative position and the dead-reckon
origin resets, so there's no accumulating drift.
Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying
that the sequencer's CurrentVelocity accurately reflects speedMod changes
across both SetCycle's rebuild path and its rescale path. Combined with
the existing MultiplyCyclicFramerate tests, this validates the
downstream-visible velocity surface the dead-reckoner reads. 633 tests
green (was 632).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expand the SkyKeyframe record with retail-exact fog fields (FogStart,
FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained
for backwards compat with tests that pin it; new shipping code reads
FogStart / FogEnd / FogMode directly.
Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition
ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights
are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned
against retail observations. Storm mode triggers lightning flashes
every 8–30 s via an exponential-decay (200ms τ) flash level that the
shader consumes as an additive scene bump.
Add SkyDescLoader that parses the Region dat (0x13000000) into
LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window +
arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready
SkyStateProvider builder. Sun/ambient colors are pre-multiplied by
DirBright/AmbBright so the shader never needs to know about retail's
scalar brightness field.
19 new tests (weather determinism, transition ease, environ override
tint, flash decay, dat-load conversion with fog + pre-mult colors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>