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>
The single most important document in the project. Defines:
Architecture: 6-layer stack (Platform → Renderer → Network → World →
Game Objects → Plugin API). The code is modern C#; the behavior
matches the retail client exactly.
GameEntity: the unified entity class that replaces the current
scattered state (WorldEntity + AnimatedEntity + guid dicts + player
controller). Every world object is a GameEntity with PhysicsBody +
AnimationSequencer + CellTracker + MotionInterpreter + AppearanceState.
Per-frame update order: Network → Streaming → Input → Entity tick
(motion → physics → collision → cell → animation) → Render → Plugin.
Execution plan (R1-R8):
R1: GameEntity refactor (unify scattered state)
R2: Thin GameWindow (extract to proper systems)
R3: CellBSP + wall collision (indoor transitions)
R4: Complete animation state machine
R5: Lighting from decompiled AdjustPlanes
R6: Server compliance (authoritative Z, keepalive)
R7: Interaction (doors, NPCs, chat, inventory)
R8: Plugin API completion (Lua macros)
Also updates CLAUDE.md to establish the architect role and reference
the architecture doc as the single source of truth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 2 research for indoor transitions. Documents:
- ACE EnvCell.find_transit_cells: sphere-plane + BSP containment
- ACE SortCell/BuildingObj.check_building_transit: outdoor→indoor
- PortalSide semantics and portal polygon plane testing
- Gap analysis: acdream needs CellBSP, BldPortal list, VisibleCells
Key finding: full accuracy requires CellBSP (physics BSP from dat)
for sphere_intersects_cell. Current PortalPlane.IsCrossing is a valid
approximation. ACME's AABB PointInCell is an intermediate option.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:
- Sequence::update_internal (1021 bytes, the core frame advance loop)
- Sequence::advance_to_next_animation (node transitions)
- Sequence::append_animation (queue management)
- MotionTableManager::PerformMovement (1878 bytes, full state machine)
- AddAnimationsToSequence (transition link → sequence nodes)
- GetStartFramePosition / GetEndFramePosition (reverse playback support)
- AdjustNodeSpeed (negative speed = swapped start/end frames)
Key findings:
- framePosition is a 64-bit DOUBLE, not float
- Negative speedScale swaps startFrame↔endFrame at the node level
- update_internal handles both forward and reverse in one loop
- Frame triggers fire at every integer boundary crossing
- The keyframe slerp lives in the renderer, not the sequencer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for missing left-side animations.
The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.
Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.
Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
left→right with speed *= -1 (TurnLeft, SideStepLeft) or
speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
SetCycle (sequencer handles remapping internally), keeps legacy
fallback for non-sequencer entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete decompilation of the retail Asheron's Call client using
Ghidra 12.0.4 + pyghidra headless. 22,225 of 22,226 functions
successfully decompiled in 75 seconds.
Output: docs/research/decompiled/ (54 files, 688,567 lines of C)
Key findings already identified:
- CLandBlockStruct::ConstructPolygons at chunk_00530000.c:2270
(split direction formula with 0x0CCAC033 constants)
- Motion command handlers at chunk_00510000.c (0x45000005 etc)
- Motion interpreter at chunk_00520000.c
- Portal space UI at chunk_004D0000.c and chunk_00560000.c
Next: identify CPhysicsObj, CMotionInterp, collision, and movement
functions by cross-referencing against ACE's C# port.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exhaustive analysis of two working AC clients revealing three critical
findings that reshape acdream's movement system:
1. Server-authoritative Z: neither AC2D nor holtburger computes local
terrain Z for the player. AC2D sends keys, receives position. Holtburger
dead-reckons for smoothing but the server overrides.
2. Terrain split formula mismatch: AC2D and ACViewer's render path use
0x0CCAC033-based FSplitNESW; WorldBuilder (our source) uses a different
214614067-based physics formula. Our terrain mesh triangulation doesn't
match the real AC client's, causing Z mismatches on slopes.
3. Movement deduplication: MoveToState sent once per state change, not per
frame. AutonomousPosition heartbeat every 1 second.
Also adds AC2D to CLAUDE.md reference repos section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>