Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.
Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.
Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.
Bumps IsOnRoad to internal so the OnRoad conformance test can call it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.
Field mapping (TerrainInfo → TerrainEntry):
TerrainInfo.Road (bits 0-1) → TerrainEntry.Road
TerrainInfo.Type (bits 2-6) → TerrainEntry.Type
TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
LandBlock.Height[i] → TerrainEntry.Height
The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.
Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):
1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
cells. Edge vertices (x=8 or y=8) produce valid spawns when the
per-object displacement shifts the position back into [0, 192).
Confirmed by named retail decomp do-while condition, WorldBuilder
vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].
2. Building suppression: check at the DISPLACED position's cell
(CSortCell::has_building per spawn), not at the loop vertex index.
Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.
3. Slope filter: replace finite-difference gradient approximation
with triangle-aware normal sampling via new static method
TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
polygon->plane.N.z and WorldBuilder's GetNormal().
Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.
Co-Authored-By: Claude Opus 4.6 <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>
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>
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.
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>
Final pre-decompile-era invention cleanup. Snapshot() now returns
the keyframe's fog (color, start, end) directly in all cases.
AdminEnvirons override replaces fog COLOR only; distances stay at
the keyframe's MinWorldFog/MaxWorldFog.
Removed:
- FogForKind(kind, kf): the per-WeatherKind fog table with
invented constants (Overcast 40-150m grey, Storm 25-90m dark,
Rain 40-150m blue, Snow 60-200m white). Retail has no such
logic — Agent #3's decompile scan found zero per-Kind fog
manipulation in chunk_005* / chunk_006*. The SkyTimeOfDay
keyframe interp (FUN_00501860) does all fog value selection.
- OvercastFogStart/End, StormFogStart/End constants.
- Storm-kind random lightning timer + _strikeJitter. Retail's
lightning is server-driven via PlayScript (Phase 6), not a
client timer — Agents #3 + #5 both rule this out.
- Per-Kind cross-fade (_transitionT and TransitionSeconds-based
lerp). Retail has a different crossfade — SkyTimeOfDay step
blending via LightTickSize gating (_DAT_008427b8 + _DAT_007c7208)
— which is the deferred Phase 5c "polish" item.
Result:
- Clear: keyframe fog passthrough — unchanged behaviour.
- Overcast / Rain / Snow / Storm: now ALSO keyframe passthrough.
Previously these clobbered the keyframe with the invented
constants, producing a grey-wall sky that extended no further
than ~150m. User observation 2026-04-23: "retail sky extends
all the way into the horizon, we cap at a grey wall." Fixed.
- EnvironOverride (AdminEnvirons RedFog, BlueFog, etc):
substitutes the fog COLOR preset, keeps keyframe distances.
WeatherKind enum retained as purely informational (debug overlay,
telemetry). Internal RollKind fallback retained for offline tests
that drive Tick() directly without SetKindFromDayGroupName.
TriggerFlash()/flash decay retained as a test-only hook for the
UBO's lightning-flash channel — production flash stays 0 since
retail drives lightning visuals through particle emitters, not
through a UBO uniform.
Tests updated: `Transition_EasesAcrossTenSeconds` deleted (codified
the Storm=dense-fog invention we just removed) and replaced by
`Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind` which asserts
every WeatherKind returns the keyframe fog directly.
Build + 742 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>
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 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>
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>
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>
Client-side deterministic sky + weather + day/night system per R12.
Retail's model is 95% client-side: the server just delivers its
current PortalYearTicks (double, seconds since boot-seed) at login and
in TimeSync packets; the client computes everything else locally from
the constants in r12 §1.2 + ACE DerethDateTime.cs.
Core layer (AcDream.Core/World):
- DerethDateTime: retail-exact calendar (16 hours/day, 30 days/month,
12 months/year, 7620 ticks/day, 2,743,200 ticks/year). HourName enum
covers all 16 named half-hour slots (Darktide → GloamingAndHalf);
MonthName covers the 12 Derethian months (Snowreap → Frostfell).
DayFraction, CurrentHour, IsDaytime, ToCalendar.
- SkyKeyframe + SkyStateProvider: 4-keyframe default day/dawn/noon/dusk
with linear color + angular-wrap heading interpolation + slerp-like
shortest-arc lerp so heading wraps 350° → 10° don't tween backwards
through 180°. Default keyframe colors tuned to retail screenshots
(sunrise warm, noon white, sunset red, midnight deep blue).
- WorldTimeService: owns the live clock. SyncFromServer(ticks) sets
baseline; NowTicks advances by real-time elapsed. Exposes DayFraction,
CurrentSky, CurrentSunDirection, IsDaytime for the render thread.
This is the foundation Phase G.2 (dynamic lighting) consumes: lighting
uniforms are fed from CurrentSky's SunColor / AmbientColor / sun
direction, varying smoothly across the day.
Tests (16 new):
- DerethDateTime: midnight, half-day, wrap, Dawnsong, Midsong,
day/night flag at dawn vs Darktide-Half, year rollover, month
advance.
- SkyState: 4-default keyframes, noon-exact matches frame data,
midpoint lerps between neighbours, wrap across midnight doesn't
produce NaN, sun direction returns unit vector, WorldTimeService
sync + DayFraction at noon.
Build green, 587 tests pass (up from 570).
Ref: r12 §1 (Portal Year math), §2 (sky objects), §4 (color lerp).
Ref: ACE DerethDateTime.cs + NetworkSession TimeSync handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
StepUpHeight: when Tab enters player mode, read Setup.StepUpHeight from the
player entity's dat and apply it to the controller (fallback 2f for non-Setup
entities or when the dat value is zero). Previously hardcoded to 5.0 which
made step-up too permissive.
Road exclusion: SceneryGenerator now skips terrain vertices where bits 0-1 of
the raw terrain word are non-zero. These bits encode the road type (GetRoad()
in ACViewer's Landblock.cs). Trees, rocks and bushes will no longer be placed
on road surfaces.
Added SceneryGenerator.IsRoadVertex(ushort) public helper + 9 unit tests
(theory + fact) verifying the road-bit convention matches TerrainInfo.Road.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>