The obj_within_block check using Setup.SortingSphere.Radius rejects
far too many spawns. Sorting spheres for trees are 5-10m, creating
a wide exclusion zone around every landblock edge. WorldBuilder
produces correct scenery with just bounds+road+building+slope checks
and no bounding sphere check. Revert to match WorldBuilder's approach.
The single extra tree near the road at vtx=(4,8) in 0xA9B1 remains
as a known minor discrepancy from retail — root cause TBD.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Retail's CLandBlock::get_land_scenes creates a PhysicsObj for each
scenery spawn, then calls CPhysicsObj::obj_within_block (0x00461c30)
which verifies the model's sorting sphere stays within [r, 192-r] on
both axes. Edge-vertex spawns displaced close to the boundary (e.g.,
a tree at Y=190.97 from vertex y=8) get rejected because their sorting
sphere extends past the landblock edge.
We were missing this check, which caused a tree near a road at
~(85, 191) in landblock 0xA9B1 to appear in acdream but not retail.
The tree legitimately passed all other filters (road, building, slope)
but its Setup sorting sphere radius (~2-5m) meant it overflowed the
boundary.
Fix: look up each unique Setup's SortingSphere.Radius from the dat
(cached per objectId) and apply the within-block bounds check after
the slope filter, matching retail's order. GfxObj objects (0x01) use
radius 0 (permissive) since they're typically small single-mesh items.
Also: remove temporary ACDREAM_SCENERY_DIAG logging, fix duplicate
xmldoc on IsOnRoad, update road check reference to named-retail PDB
symbol (CLandBlock::on_road at 0x0052FFF0).
Co-Authored-By: Claude Opus 4.6 <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>
User report: trees that exist in retail are missing in ACdream.
SceneryGenerator had an extra heuristic filter at lines 169-180
that rejected scenery whose cell-origin vertex was a road vertex,
on top of the proper retail post-displacement road check
(FUN_00530d30 port via IsOnRoad). The comment admitted it
wasn't in the retail decomp -- it was added to widen road
margins visually. Side effect: any cell whose SW corner
happened to touch a road vertex had ALL of its scenery
dropped, even when the displaced position was well clear of
the road ribbon.
Removing the extra guard. The retail FUN_00530d30 ribbon test
already handles road exclusion correctly; the heuristic was
strictly subtractive and silently dropped trees the retail
client renders.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The pre/post-scene sky pass split was using SkyObjectData.IsWeather
(bit 0x04) — the wrong bit. Per the named retail decomp:
GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp 269036:
MakeObject(this, gfx_id, &tex_velocity,
(properties & 1), // arg4: post-scene flag
(properties & 4)); // arg5: weather gate
GameSky::MakeObject at 0x00506ee0 / decomp 268656:
if (arg4 != 0)
AddObjectToSingleCell(result, after_sky_cell); // post-scene
else
AddObjectToSingleCell(result, before_sky_cell); // pre-scene
So bit 0x01 routes between before_sky_cell (rendered pre-scene by
GameSky::Draw(0)) and after_sky_cell (rendered post-scene by
GameSky::Draw(1)). Bit 0x04 is independent — it gates whether the
object is instantiated at all when LScape::weather_enabled is false.
In Dereth's Rainy DayGroup this matters for the rain cylinders:
0x01004C42 Props=0x04 (bit 0x04 only) → pre-scene + weather-gated
0x01004C44 Props=0x05 (bits 0x01+0x04) → post-scene + weather-gated
0x01004C35 Props=0x02 (bit 0x02 only) → pre-scene (cloud, fog-hide)
Before this fix acdream put BOTH rain cylinders in the post-scene
pass (because both have bit 0x04). That double-rendered foreground
rain — explained why acdream's foreground rain looked thicker than
retail's. Now only 0x01004C44 is foreground; 0x01004C42 renders with
the sky dome.
Added SkyObjectData.IsPostScene (bit 0x01) with citations. Renamed
the internal RenderPass parameter weatherPass → postScenePass and
updated both the partition criterion and the -120m foreground-rain
Z offset to gate on it. Public RenderSky / RenderWeather entry
points kept their names for API stability; doc comments updated to
explain the bit semantics.
Independent confirmation from one of the user's external code-review
agents — the report's Setup-objects-silently-dropped finding is the
remaining defect in the same family (Setup IDs 0x020xxx aren't
loaded by EnsureMeshUploaded; deferred to a separate phase).
1227 tests pass.
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:
acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:
SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
sunVec.y = cos(P_rad) ← NOT scaled by DirBright
sunVec.z = DirBright × sin(P_rad)
PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)
Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.
Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).
Tests:
- Replaced the linear-interp assumption in
Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
(RetailSunVector_AtZenith, _AtHorizonNorth,
SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
with the new expected magnitude.
User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.
1227 tests pass.
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>
Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md:
rain mesh was only visible at horizon, not in the air between camera and
character. Two retail mechanisms ported here:
1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls
`GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and
`GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene
geometry so additive rain streaks paint on top of terrain and entities.
Acdream was rendering both passes pre-scene, so terrain immediately
painted over the rain.
Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather)
and `RenderWeather` (filter IsWeather) sharing a private `RenderPass`
core that takes a `weatherPass` bool. Partition is per-SkyObject by
`Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator).
Added `SkyObjectData.IsWeather` getter for the partition.
`GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/
particles (line ~4322) and `RenderWeather` after particles (line ~4368).
2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0,
lines 0x506e96..0x506e98:
if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
Weather objects (property bit 0x04 set, bit 0x08 unset) get their frame
origin set to player_pos + (0, 0, -120m). The rain cylinder GfxObjs
0x01004C42/0x01004C44 have local Z range 0.11..814.90 (815m tall, 113m
radius). Without the offset the cylinder bottom sat just above the
camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90)
so the camera is inside.
`SkyRenderer.RenderPass` applies the -120m model translation when
`weatherPass` is true (line ~253-254).
3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` —
the pre-research workaround that emitted camera-attached rain particles
(broken alpha fade, fixed disk around camera) — is now gated behind
`ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful
world-space mesh is the default path.
User-verified: rain is now visible in foreground from many perspectives,
but the cylinder's open-top rim is still visible when looking straight up.
That rim issue is a separate brightness-excess bug filed for follow-up
(Translucency float not plumbed to shader; surface.Translucency=0.5 ignored
so streaks render at 2× retail intensity).
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>
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>
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>
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>
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>
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>
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>
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.
Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
one shader + two draw calls (rect then text) for panel backgrounds
under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
are properly committed in this commit
Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
the default neutral angle
Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
physics Setup bounds
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: MarkPersistent(chosen.Id) stored the SERVER GUID (e.g.
0x5000000A) but RemoveLandblock checked entity.Id which is the LOCAL
sequential counter (e.g. 42). Different number spaces → never matched
→ persistent rescue never triggered → player entity lost on unload.
Fix:
- Added WorldEntity.ServerGuid field (0 for dat-hydrated scenery)
- Live entity spawn sets ServerGuid = spawn.Guid
- RemoveLandblock checks entity.ServerGuid against _persistentGuids
- MarkPersistent still stores the server GUID (correct)
This bug has been reported across multiple sessions as "character
disappears when walking far." The neverCullLandblockId fix only
prevented frustum culling but didn't prevent the entity from being
removed from the render list entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Performed a side-by-side comparison of every LCG formula in SceneryGenerator.cs
against the decompiled retail acclient.exe (Ghidra output):
Scene-selection hash chunk_00530000.c:1144 — MATCH (0x2a7f2b89·x+0x6c1ac587)·y - 0x421be3bd·x + 0x7f8cda01
Per-object frequency chunk_00530000.c:1168-74 — MATCH accumulator pattern cellMat2*(0x5b67+j)
X displacement chunk_005A0000.c:4858-66 — MATCH offset 0xb2cd=45773
Y displacement chunk_005A0000.c:4871-78 — MATCH offset 0x11c0f=72719
Quadrant rotation chunk_005A0000.c:4880-4902 — MATCH constants 0x6f7bd965/0x421be3bd/-0x17fcedfd
Object rotation hash chunk_005A0000.c:4924-26 — MATCH offset 0xf697=63127
Scale hash ACViewer ObjectDesc.cs — MATCH offset 0x7f51=32593 (chunk not dumped)
Key finding: the decompiled client normalises signed-int LCG values with
"if (val < 0) val += 2^32" before dividing by 2^32. Our unchecked((uint)(...))
is exactly equivalent. ACViewer's reference omits this cast for some formulas
(displacement, rotation) and is subtly wrong for those; our implementation
already had the correct uint cast throughout.
Added inline decompiled-source citations to all five algorithm sites plus
an updated class-level doc comment noting the audit status and implementation note.
No behaviour change — comments only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four fixes from the ACME StaticObjectManager cross-reference:
1. GfxObjMesh: normalize vertex normals (1d). Dat normals may not be
unit-length; without normalization, lighting is wrong per-vertex.
2. SetupMesh: add third-fallback placement frame (2a). If neither
Resting nor Default exists, use the first available frame from
PlacementFrames. Matches ACME's GetDefaultPlacementFrame.
3. SceneryGenerator: building cell exclusion (4d). Compute which
terrain vertices have buildings (from LandBlockInfo.Objects +
Buildings), skip scenery spawns in those cells. Prevents trees
from spawning inside building footprints.
4. SceneryGenerator: slope filter (4e). Compute terrain normal Z at
each displaced position and check against ObjectDesc.MinSlope /
MaxSlope bounds. Prevents trees from spawning on cliff faces.
Also confirmed 4f (scenery Z=0) is NOT a bug — GameWindow's hydrator
lifts scenery to terrain Z at line 1213. The Z=0 in SceneryGenerator
is a placeholder correctly overridden at render time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four targeted fixes for user-reported movement/visual bugs:
1. Player entity disappearing: GpuWorldState now supports persistent
entities (MarkPersistent/DrainRescued). The player character survives
landblock unloads and gets re-injected into the streaming window at
the current center landblock.
2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController
keeps the character model above terrain z-fighting edge cases.
3. Camera after portal teleport: ChaseCamera.Update now called
immediately after teleport snap so the camera recenters on the new
position instead of lingering at the pre-teleport location.
4. Scenery on roads: SceneryGenerator now checks road status at the
final displaced position (not just the origin vertex), catching
objects that drift from non-road vertices onto road cells.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The IsRoadVertex check and helper were dropped by a linter pass after the
previous commit. Re-adding them explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Makes NPCs and other server-spawned entities actually move and
transition animations based on the live server feed. Before this,
Phase 6.6/6.7 only parsed the messages and fired events that nothing
consumed, so NPCs stayed frozen at their CreateObject spawn point
playing one idle cycle forever.
Changes:
- GameWindow now keeps a parallel _entitiesByServerGuid dictionary
built at CreateObject hydration time so motion / position updates
can find the target entity by its server guid.
- WorldEntity.Position and Rotation become get/set (like MeshRefs did
in Phase 6.4) so the position-update handler can reseat an existing
entity in place without reallocating MeshRefs.
- OnLiveMotionUpdated re-resolves the cycle via MotionResolver using
the server's new (stance, forward-command) override and either
swaps the AnimatedEntity's current cycle or removes it from the
animated set if the new pose is static.
- OnLivePositionUpdated translates the new landblock-local position
into acdream world space (same math as CreateObject hydration) and
writes it back onto the entity.
Subscriptions are added alongside the existing EntitySpawned hook so
the three handlers run synchronously on the UDP pump thread, matching
the existing pattern.
194 tests green (98 Core + 96 Core.Net).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.
MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.
WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.
GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.
OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.
160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the other half of ObjDesc: SubPalettes (palette-range
overlays) that repaint palette-indexed textures with per-entity color
schemes. Ported algorithm from ACViewer Render/TextureCache.IndexToColor
after the user pointed out I was prematurely implementing from scratch
instead of checking all the reference repos.
The Nullified Statue of a Drudge sends (setup=0x020007DD with a drudge
GfxObj animPart replacing part 1, plus 2 texChanges targeted at part 1,
plus 1 subpalette id=0x04001351 offset=0 length=0). The TextureChanges
swap fine detail surfaces; the SubPalette with length=0 ("entire palette"
per Chorizite docs) remaps the drudge's flesh-tone palette to stone.
Without this commit, the statue looked like a normal flesh drudge
because palette-indexed textures decoded with the base flesh palette.
Added:
- Core/World/PaletteOverride.cs: per-entity record carrying
BasePaletteId + a list of (SubPaletteId, Offset, Length) range
overlays. Documents the "offset/length are wire-scaled by 8"
convention and the "length=0 means whole palette" sentinel.
- WorldEntity.PaletteOverride nullable field. Per-entity (same across
all parts), in contrast to MeshRef.SurfaceOverrides which is per-part.
- TextureCache.GetOrUploadWithPaletteOverride: new entry point that
composes the effective palette at decode time. Composite cache key
is (surfaceId, origTexOverride, paletteHash) so entities with
equivalent palette setups share the GL texture.
- ComposePalette: ports ACViewer's IndexToColor overlay loop:
for each subpalette sp:
startIdx = sp.Offset * 8 // multiply back from wire
count = sp.Length == 0 ? 2048 : sp.Length * 8 // sentinel
for j in [0, count):
composed[j + startIdx] = subPal.Colors[j + startIdx]
Critical detail: copies from the SAME offset in the sub palette, not
from [0]. Both base and sub are treated as full palettes sharing an
index space.
- StaticMeshRenderer.Draw: three-way switch on (entity.PaletteOverride,
meshRef.SurfaceOverrides) picks the right TextureCache path:
- Both → palette override (it handles origTex override internally)
- Only tex override → GetOrUploadWithOrigTextureOverride
- Neither → plain GetOrUpload
- GameWindow.OnLiveEntitySpawned: builds PaletteOverride from
spawn.BasePaletteId + spawn.SubPalettes when the server sent any.
Reference note: the user asked "but I mean THIS MUST BE IN WORLDBUILDER"
which was the right push. WorldBuilder is actually a dat VIEWER and its
ClothingTableBrowserViewModel is a 10-line stub — it doesn't apply
palette overlays because it doesn't need to. The actual algorithm lives
in ACViewer (a MonoGame character viewer), which I should have checked
earlier. CLAUDE.md updated with a standing rule: always cross-reference
all four of references/ACE, ACViewer, WorldBuilder, Chorizite.ACProtocol,
plus holtburger. A single reference can be misleading; the intersection
is usually the truth.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.
The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.
Diagnosed via Phase 1 systematic debugging with resolve-level logging:
live: spawn +Acdream texChanges=20
live: texChange part=0 old=0x05000BB0 new=0x0500025D
...
live: resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
live: resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
... 10/10 lines [MATCH]
The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.
Fix:
- TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
loads the base Surface dat for its color/flags/palette, but
substitutes the override SurfaceTexture id in the decode chain.
Caches under a (surfaceId, origTexOverride) composite key.
- MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
Surface id, value = replacement OrigTextureId. Null means no
overrides.
- GameWindow.OnLiveEntitySpawned now does TWO passes when texture
changes are present:
1. Group the raw server changes by PartIndex into (oldOrigTex →
newOrigTex) dicts
2. For each affected part's post-animPartChange GfxObj, iterate
its Surfaces list, resolve each Surface → OrigTextureId, and
if that matches a raw change's oldOrigTex, write an entry
Surface id → newOrigTex into the final override map
- StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.
Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.
Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.
Cross-referenced against two new references this session:
* references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
Chorizite.ACProtocol.git on user's suggestion) — confirms the
ObjDesc field order and PackedDword-of-known-type convention.
* references/WorldBuilder/... (already in repo) — confirms the
Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
P8/INDEX16 palette decode path.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds SceneryGenerator.Generate which walks Region.TerrainInfo.TerrainTypes
+ Region.SceneInfo.SceneTypes for each landblock vertex, selects a scene
using the AC client's pseudo-random LCG hash of global cell coordinates,
then rolls each ObjectDesc's frequency, computes a displaced cell-local
position, random scale, and random rotation — the exact algorithm
ACViewer ports from the retail AC client's get_land_scenes().
Phase 2 rendered 239 explicit Stab+Building entities on the 3x3 Holtburg
grid but was missing every procedurally-placed tree, bush, rock, fence,
and small decoration because these are not stored as LandBlockInfo entries.
This adds 419 scenery entities across the same 9 landblocks, bringing the
total to 658.
Integration in GameWindow.OnLoad: after the existing Stab/Building
hydration loop, iterate each landblock's scenery spawns, resolve each
to a GfxObj or Setup via the same mesh pipeline, bake the random scale
into each MeshRef's PartTransform so the static mesh renderer doesn't
need a scale field on WorldEntity, and sample the landblock heightmap
bilinearly for the ground Z (simpler than ACViewer's find_terrain_poly
slope-aware placement).
Deliberate deferrals for first pass:
- No slope-based rejection (obj.MinSlope/MaxSlope). Trees may end up on
cliffs they shouldn't be on.
- No road-overlap rejection. Scenery may spawn in roads.
- No building-overlap rejection. Scenery may clip buildings.
- No WeenieObj handling (those are dynamic spawns, not static scenery).
All three filters will be added in a follow-up phase when we have the
walkable-polygon infrastructure they need.
Build clean, 48 tests still pass, smoke verified: "scenery: spawned 419
entities across 9 landblocks", process runs without exceptions.
Addresses the user visual feedback after Phase 2b: "some extra details
are missing, like a tree and the statue on top of the foundry". The tree
issue is now fixed (419 trees/bushes/rocks/etc placed). The foundry
statue may still be missing if it's a hierarchical Setup part (Phase 2a's
SetupMesh.Flatten intentionally doesn't walk ParentIndex) — that's a
separate fix if smoke verification shows it's still missing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>