Second piece of Phase D.2a: the ImGui-specific backend that implements
AcDream.UI.Abstractions' IPanelRenderer / IPanelHost. No GameWindow
hookup yet — compiles standalone for clean review before integration.
Packages:
* Hexa.NET.ImGui 2.2.9 (auto-generated from cimgui 1.92.2b)
* Hexa.NET.ImGui.Backends 1.0.18 (consolidated — OpenGL3 is here)
* Silk.NET.Input 2.23.0 + Silk.NET.OpenGL 2.23.0 (matches AcDream.App)
Files:
ImGuiBootstrapper.cs
One-shot static Initialize(glslVersion) / Shutdown() pair. Creates
the ImGui context, applies dark style, enables NavEnableKeyboard,
and boots ImGuiImplOpenGL3. Re-init is a no-op.
SilkInputBridge.cs
Event-driven Silk.NET -> ImGui IO bridge. Subscribes on construction;
Dispose() unsubscribes. Covers:
- KeyDown/Up -> ImGui.AddKeyEvent with modifier latching
(Ctrl/Shift/Alt/Super routed via both ModXxx flags AND named
key events so both IsKeyPressed checks and ImGui shortcut
matching work)
- KeyChar -> AddInputCharacter for text fields
- MouseMove -> AddMousePosEvent
- MouseDown/Up -> AddMouseButtonEvent (L=0, R=1, M=2)
- Scroll -> AddMouseWheelEvent (both axes)
Silk.NET.Input.Key -> ImGuiKey map covers WASD, arrows, modifiers,
letters, digits, function keys. Unmapped keys silently ignored.
BeginFrame(displaySize, dt) sets IO.DisplaySize + IO.DeltaTime.
ImGuiPanelRenderer.cs
IPanelRenderer impl — one-line wrappers on ImGui.Begin/End,
TextUnformatted, SameLine, Separator, ProgressBar. The ONLY place
Hexa.NET.ImGui types appear outside bootstrap/input plumbing. Panels
still never import ImGui.
ImGuiPanelHost.cs
IPanelHost impl. Dictionary keyed by IPanel.Id for idempotent
Register. RenderAll iterates visible panels and calls their Render.
Does NOT call ImGui.NewFrame / ImGui.Render — ownership belongs to
the caller (GameWindow) so GL state is explicit. Diagnostic `Count`
property.
No behavior change yet; next commit wires this into GameWindow behind
ACDREAM_DEVTOOLS=1 and ships the first visible VitalsPanel.
Covers the pure-logic surface of commit 1:
VitalsVMTests
* HealthPercent reads from CombatState.GetHealthPercent
* safe-default 1.0 when GUID unknown / not yet set / never updated
* SetLocalPlayerGuid reroutes lookup to the new guid (no stale cache)
* StaminaPercent / ManaPercent are null for D.2a scope
* ctor throws ArgumentNullException on null CombatState
PanelContextTests
* record-struct fields round-trip
* value-based equality
NullCommandBusTests
* Publish accepts any record type without throwing
* Instance is a true singleton
csproj template mirrors AcDream.Core.Tests (xUnit 2.9.3, Test.Sdk 17.14.1,
runner 3.1.4, coverlet 6.0.4, implicit Using Xunit). References only
AcDream.UI.Abstractions — no runtime / GL dependency, tests run fast.
Adds the backend-agnostic UI contract layer called for by the 2026-04-24
staged UI strategy (docs/plans/2026-04-24-ui-framework.md). This is the
stable layer both the Phase D.2a Hexa.NET.ImGui backend and the later
D.2b custom retail-look backend implement.
New module `src/AcDream.UI.Abstractions/`:
* IPanel — a drawable panel (id/title/visible/Render)
* IPanelHost — owns the panel list, drives per-frame dispatch
* IPanelRenderer — drawing primitives (Begin/End/Text/SameLine/
Separator/ProgressBar). Kept small + retail-friendly
on purpose — if a widget can't be expressed with
dat-sourced sprites+fonts later, don't add it here.
* ICommandBus — user-intent publisher; NullCommandBus is D.2a default
* PanelContext — per-frame record struct (DeltaSeconds + Commands)
* Panels/Vitals/
VitalsVM — reads CombatState.GetHealthPercent for the local
player. Stamina/Mana return null in D.2a; they await
a LocalPlayerState cache of PlayerDescription (0x0013)
which is filed as a follow-up issue.
VitalsPanel — first real panel. HP bar always drawn; Stam/Mana
appear automatically when the VM returns non-null.
Invariant documented in IPanel's XML doc: no `using Hexa.NET.ImGui` in
panel files, ever. If a widget needs something IPanelRenderer can't
express, the interface grows; panels never reach through.
References AcDream.Core for CombatState. Zero runtime/UI dependencies
— this project compiles headless, perfect for unit testing the
ViewModels.
No visible change yet. Next commits: (2) tests, (3) ImGui backend,
(4) GameWindow hookup + visible panel behind ACDREAM_DEVTOOLS=1.
Introduces `docs/ISSUES.md` as the tactical rolling list of known bugs +
small deferred features. Scope is strict: anything fitting in one-to-two
commits lives here; anything larger gets promoted to a Phase in the
roadmap. Complement, not replacement, of the strategic roadmap.
Schema per issue:
- Sequential integer ID (#1, #2, ...)
- Status (OPEN / IN-PROGRESS / DONE)
- Severity (HIGH / MEDIUM / LOW)
- Filed date, component, description, root cause, files, research,
acceptance
- DONE items move to "Recently closed" at the bottom with commit SHA
Four seeded issues from the 2026-04-24 sky-debug session:
#1 — Rain falls only to horizon, not to player (weather/particles)
#2 — Lightning visual not wired (dat-baked PES triggers; research done)
#3 — Client clock drifts from retail after ~10 min (periodic TimeSync)
#4 — Sky horizon-glow disabled (fog-mix skipped on sky meshes)
CLAUDE.md adds an "Issue tracking" section right after roadmap
discipline with the four maintenance rules: (1) scan OPEN at session
start, (2) add/close at session end, (3) reference IDs in commit
messages, (4) promote to a Phase if an issue grows. Calls out the
strategic-vs-tactical split so future sessions know when to reach for
the roadmap vs the issues file.
README.md gets one line pointing at `docs/ISSUES.md` below the existing
roadmap link for first-class discoverability.
No code changes; doc-only.
Landed the UI framework design in 2026-04-24-ui-framework.md yesterday;
this commit propagates the decisions across the documents that future
sessions touch first, so the three-layer pattern is discoverable without
re-reading the full plan.
Changes:
* NEW memory/project_ui_architecture.md — evergreen crib-sheet:
three-layer diagram, AcDream.UI.Abstractions contract, D.2a/D.2b
split, module layout, hard rules, why staged not pure-custom.
* CLAUDE.md: new paragraph describing the three-layer UI split, naming
AcDream.UI.Abstractions as the plugin-facing contract, pointing at
the full plan + memory crib.
* docs/architecture/acdream-architecture.md: new "UI Architecture"
companion-stack diagram after Layer 0-5 (doesn't renumber the main
stack), plus step 6a "UI tick" in Per-Frame Update Order.
* docs/plans/2026-04-11-roadmap.md Phase D tightened:
- D.2 split explicitly into D.2a (Hexa.NET.ImGui scaffold + abstraction
layer) and D.2b (custom retail-look backend, implements same contracts).
- D.3 AcFont / D.4 dat sprites / D.7 cursor flagged as D.2b dependencies.
- D.5 core panels / D.6 HUD flagged as abstraction-layer deliverables
— ship with D.2a, reskinned by D.2b.
- D.8 Sound marked superseded (shipped as Phase E.2).
- F.5 core panels + H.1 chat-window cross-references updated to say
they target AcDream.UI.Abstractions, unblocked by D.2a.
- Shipped-phases table untouched.
* docs/research/retail-ui/00-master-synthesis.md: scope note at top
clarifies the Keystone research is the D.2b (custom backend)
foundation, NOT where D.2a starts.
* ~/.claude/.../memory/MEMORY.md: one-line index entry pointing at the
new project_ui_architecture.md (so session auto-load surfaces it).
Zero code changes; doc-only. dotnet build stays green. All verification
greps pass (see plan file for exact checks).
Allows hostnames like `play.coldeve.ac` in ACDREAM_TEST_HOST. Previously
the env var fed `IPAddress.Parse` directly and threw "An invalid IP
address was specified" on anything that wasn't a literal dotted IP.
Now: `IPAddress.TryParse` first (fast path, unchanged for 127.0.0.1 /
literal IPs); on failure, fall back to `Dns.GetHostAddresses` and prefer
the first IPv4 address (ACE + retail both use IPv4 UDP exclusively).
Tested against `play.coldeve.ac:9000` — resolves to 51.79.80.150,
handshake succeeds, login to character Barris works end-to-end.
Two-stage rollout, one stable abstraction layer:
1. Short-term: Hexa.NET.ImGui as the backend. Wire up in days, iterate
game logic (chat, inventory, vitals) in weeks. Looks like a debugger,
acceptable while we prove the interaction logic end-to-end.
2. `AcDream.UI.Abstractions` — ViewModels + Commands + `IPanel` /
`IPanelRenderer` interfaces. Backend-agnostic. Plugin API targets
this layer; plugins never see ImGui.
3. Long-term: custom retail-look backend using dat assets. Swap panel
by panel. ImGui stays forever as the `ACDREAM_DEVTOOLS=1` overlay.
The new doc (`2026-04-24-ui-framework.md`) captures:
- Full design of the three-layer split
- Why Hexa.NET.ImGui over ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui
(AOT readiness, tracks upstream ImGui faster, cleaner native-lib
bundling)
- Alternatives considered and ruled out (Myra, Avalonia, NoesisGUI,
RmlUi, pure custom from day one)
- Implementation order (Sprint 1 vitals HUD → Sprint 2 interaction
panels → Sprint 3 plugin API → Sprint 4+ more panels → later
custom retail-look)
- Risks + mitigations and open questions deferred to implementation
Roadmap Phase D updated with a pointer to the new plan so future
sessions start from the latest strategy, not the original
all-custom-from-day-one Phase D description.
No code changes yet. Ready to start Sprint 1 when approved.
The dome is 5 flat quads meeting at edges. Under GL_REPEAT + LINEAR
filtering, the edge pixels of each wall wrap-sample to the OPPOSITE
edge of the SAME texture (repeat wraps 1.0→0.0). Those opposite-edge
pixels never match the adjacent wall's border, so every dome seam
showed up as a visible line — four vertical wall lines plus the top
cap formed a huge cube-outline in the sky.
Switch to GL_CLAMP_TO_EDGE for any sky submesh whose SkyObject has
TexVelocityX == TexVelocityY == 0 (i.e. not a scrolling cloud layer).
Scrolling clouds keep GL_REPEAT because their UV-offset accumulates
and needs seamless wrap. One-line heuristic on TexVelocity picks the
mode per-object per draw.
User confirmed both the cube-edges artifact and the "huge square in
the sky" artifact are gone after this change. Both were the same bug
— the square was the four wall seams forming a closed rectangle
across the view.
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:
* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
(0..400m at midnight, up to 2400m during day) is calibrated for
terrain; sky meshes are authored at radii 1050-14271m which sits
past FogEnd universally, causing every sky pixel to saturate to
fogColor (dark navy). Stars, moon, dome texture all got
obliterated. The horizon-glow trade-off is noted in the shader
comment; research item to find retail's sky-specific fog range
later.
* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
vertex lighting saturates properly for bright keyframes. Retail's
FUN_0059da60 non-luminous path writes rep.Luminosity into
material.Emissive via the cache +0x3c slot; we were instead using
it as a post-fragment multiply which could only dim, never brighten.
Net effect: daytime clouds now render saturated white, dome dims
correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
and moon unchanged.
* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
(DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
pure ambient rather than getting an 8% sun floor.
New research / tooling (no runtime impact):
* docs/research/2026-04-24-lambert-brightness-split.md — retail's
ambient-brightness formula pinned from PE .rdata read + live
RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
where scale constant 0x0079a1e8 = 0.2f exactly.
* docs/research/2026-04-23-lightning-real.md — research note on the
dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
explicit PES-triggered flash SkyObjects with 5ms time windows).
* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
backwards).
* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
and the 0x0079a1e8 scale-factor readout.
* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
(A8R8G8B8 128x128 texture, 4% bright-pixel ratio).
* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
clouds decoded with proper alpha" type questions.
README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.
All 742 tests green.
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>
Ports retail's AdminEnvirons (opcode 0xEA60) — the client-visible
weather-event channel distinct from the PlayScript path. Wire format
(chunk_006A0000.c: `[u32 opcode][u32 environChangeType]`).
EnvironChangeType range:
0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/Black/Black2)
0x65..0x75 — one-shot ambient sounds (Roar, Bell, Chant, etc)
0x76..0x7B — Thunder1..6 sounds (paired with a lightning PlayScript)
Dispatch:
- WorldSession decodes the packet, fires EnvironChanged event.
- GameWindow.OnEnvironChanged:
* Fog values (0x00..0x06) → WeatherSystem.Override. The enum
values line up byte-for-byte with our EnvironOverride enum
(deliberately mirrored from retail), so a direct cast works.
* Sound values (0x65..0x7B) → console log with retail name for
now. Actual OpenAL playback needs a EnvironChangeType →
WaveData lookup (indexed via SoundTable dat), which is a
separate follow-up. The event still fires so any future
audio subscriber can plug in.
Combined with Phase 6a-6c PhysicsScript/PlayScript wiring, the
complete retail lightning pipeline is now:
server sends PlayScript(0xF754, lightningGuid, scriptId=0x33xxxxxx)
→ runs the flash script via PhysicsScriptRunner
→ CreateParticleHook spawns the flash particles
server sends AdminEnvirons(0xEA60, Thunder3Sound=0x78)
→ OnEnvironChanged logs; audio binding TBD
Whether the user's ACE sends these packets depends on the server
(ACE 2.x vanilla does NOT — Agent #5 verified no lightning opcodes in
the default emit path). With the client port complete, any ACE mod
or extension that emits the right packets will Just Work in acdream.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754)
that retail uses for all server-triggered client-side visual effects.
Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336):
[u32 opcode=0xF754][u32 targetGuid][u32 scriptId]
New event `PlayScriptReceived(uint guid, uint scriptId)` fires on
every matching fragment. Unknown payloads (body < 12 bytes) are
silently ignored.
Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup,
subscribes to PlayScriptReceived, and ticks the runner every frame
BEFORE the ParticleSystem tick so a CreateParticleHook fired this
frame gets its emitter integrated in the same frame.
Anchor policy: use the camera's world position for the script anchor.
For Dereth-wide storm effects (lightning flashes) the camera is the
right reference frame — the flash is "around the player." Per-entity
effects (spell casts, emotes) dedupe by (scriptId, entityId) so
multiple simultaneous plays on different guids work; a follow-up will
look up the guid's last-known world pos from _worldState for accurate
per-entity anchoring.
The full pipeline now for a lightning flash:
1. ACE (or other retail-emulating server) sends
GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx).
2. WorldSession parses: PlayScriptReceived event fires.
3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play.
4. Runner loads the PhysicsScript from the dat, schedules every
(StartTime, AnimationHook) entry.
5. Per-frame Tick fires each hook at its scheduled time via
ParticleHookSink — CreateParticleHook spawns a particle emitter
(the flash), SoundHook plays thunder audio (Phase 5d), etc.
Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each
hook fire as `[pes]` log lines — useful for identifying which script
IDs your ACE server actually sends.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The central runtime for every client-visible scripted effect server
triggers via PlayScript (opcode 0xF754) — spell casts, emote
gestures, combat flinches, AND lightning flashes during storms.
Previously acdream parsed PhysicsScript from the dat (via DRW) but
had no runner; the PlayScript stub in ParticleSystem.cs was a
no-op.
Decompile provenance (`docs/research/2026-04-23-physicsscript.md`,
`docs/research/2026-04-23-lightning-real.md`):
FUN_0051bed0 — play_script(scriptId) public API — resolves the
dat id, queues into the owner's ScriptManager list.
FUN_0051be40 — ScriptManager::Start — alloc 16-byte node
{startTime, script*, next}.
FUN_0051bf20 — advance one hook, schedule next fire by next
hook's StartTime.
FUN_0051bfb0 — per-frame tick: while head.NextHookAbsTime ≤
globalClock, fire via vtable dispatch.
Port choices:
- Flat List<ActiveScript> vs retail linked list — iteration is
simpler, N is small.
- Scripts keyed by (scriptId, entityId) — replay replaces instead
of stacking, matches retail's "play_script on the same obj
doesn't double-schedule".
- Anchor world pos cached at Play() time — good enough for
short-lived effects (lightning, spell casts). Callers that
need fresh positions for long emote animations can Play()
again each frame (idempotent).
- Constructor takes Func<uint, PhysicsScript?> resolver so tests
don't need DatCollection; production uses the DatCollection
overload that wraps Get<PhysicsScript> with null-on-fail.
- CallPESHook recurses Play() with Pause baked into the
sub-script's StartTimeAbs. Matches retail semantics where
nested scripts fire on the NEXT tick (list iteration order).
Diag: ACDREAM_DUMP_PLAYSCRIPT=1 logs every Play() and every fire as
[pes] lines. Use this to identify the actual script IDs your ACE
server is sending so we can confirm the lightning pipeline when the
server sends a strike.
Test coverage (9 new tests, all passing):
- unknown script returns false, zero id silent-ignore
- hooks fire in order at their scheduled times
- entityId + anchor pass through to sink
- replay same (scriptId, entityId) replaces, doesn't stack
- different entities run independently
- StopAllForEntity cancels that entity's scripts only
- CallPES nested spawn semantics (fires next tick)
- CallPES with Pause delays correctly
No GameWindow wiring yet — Phase 6b handles the 0xF754 packet
handler and Phase 6c plugs the runner into the frame loop.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>