Self-contained spec for the next session: PES (Particle Effect
Schedule) renderer that produces retail's "aurora light play",
portal swirls, chimney smoke, fireplace flames in one
implementation. Rolls up ISSUES.md #28 (root-caused this session
to PES on CelestialPosition.pes_id) and likely #29 (residual
cloud density gap).
Picks up after sky/weather session (merged at f7c9e88). Phase
E.3 already shipped the data layer (ParticleSystem,
EmitterDescLoader, ParticleHookSink, PhysicsScriptRunner,
VfxModel in src/AcDream.Core/Vfx/). C.1 is the visual half:
SkyDescLoader PesObjectId capture, SkyRenderer emitter spawn,
billboarded-quad GL renderer following WorldBuilder's
ParticleBatcher pattern.
Spec includes Step 0 grep targets, references in priority order
(decomp first, ACME/WorldBuilder second), the Dereth Rainy
DayGroup PES enumeration from tools/StarsProbe (notably
0x3300042C active 0.27-0.91 = "render this and confirm" target),
implementation outline (C.1.0 through C.1.6), pitfalls from
prior sessions, and the worktree setup commands.
To kick off the next session, point it at this file.
Three small hygiene items flagged by external code-review reports
during the sky/weather investigation:
1. CullFace state leak in SkyRenderer.RenderPass.
Disabled CullFace at the start of the sky pass without restoring it
on exit. Benign today — the global convention in this codebase is
CullFace=off and subsequent renderers (InstancedMeshRenderer,
StaticMeshRenderer) explicitly enable on entry / disable on exit —
but a future caller assuming culling stays on across the sky pass
would have silently broken. Wrap with an IsEnabled save / Enable
restore using TextRenderer.cs's pattern.
2. Stale comment in SubMeshGpu.SurfTranslucency doc.
Said "the shader multiplies output alpha by (1 - x)". After commit
97fc1b5 the shader uses translucency DIRECTLY as opacity per retail
D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260).
Updated to reflect the current formula.
3. Stale comment in sky.frag header.
Said "fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency)".
Updated to "× uSurfTranslucency" with citation.
Not addressed: Report 2's "uLuminosity declared but never referenced"
claim. Verified false — the uniform was already removed; the only
remaining uLuminosity references are in comments documenting the
historical removal (sky.frag header line 13-14 explicitly says
"removed 2026-04-26"). Report 2 was reading stale content.
1314 tests pass.
Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:
97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed
Net effect:
* Sun + ambient colors now use retail's |sunVec| magnitude formula
from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
blue-white sky tint at most keyframes.
* Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
cloud + correct rain alpha.
* Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
haze visible without flat-fogging the dome at storm keyframes.
* Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
425295 — sun stays bright at horizon dusk/dawn.
* Pre/post-scene partition is bit 0x01 (post-scene placement) instead
of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
at decomp 269036. Fixes double-rendered foreground rain.
* Translucent flag forces alpha-blend over Additive when ClipMap is
set, matching retail's blend resolution at decomp 425246-425260.
Cloud surface 0x08000023 now classified correctly.
* Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
instead of being silently dropped by EnsureMeshUploaded.
Tests: 1227 pass.
User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.
Outstanding:
* Issue #28 — PES particle rendering ("aurora light play"). Now
root-caused with implementation outline; defer to its own Phase.
* Issue #29 — residual cloud-density gap; likely rolls into #28.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
# src/AcDream.App/Rendering/GameWindow.cs
Updated #28 (aurora effect) from "unknown root cause" to "PES
particles attached via CelestialPosition.pes_id". Includes the
verbatim retail header struct, the StarsProbe-confirmed list of
PES-bearing entries in Dereth Rainy DG3 (notably PES 0x3300042C
active 0.27-0.91, which is the user's Warmtide screenshot), the
implementation outline, and decomp pointers to
CPhysicsObj::InitPartArrayObject + CPartArray::CreateSetup.
Filed #29 for the residual cloud-density gap that remained after
this session's Translucent-override fix (commit 375065b) and Setup
wiring (commit 646ccca). Two follow-up hypotheses captured —
likely rolls into #28 once PES rendering lands.
Independent code review by an external agent (2026-04-27) flagged
that SkyRenderer.EnsureMeshUploaded only ever called
_dats.Get<GfxObj>(...) — every 0x020xxx Setup ID returned null and
got cached as an empty submesh list, silently dropping every
Setup-backed sky object across the Dereth Region. In Rainy DG3
alone that's 6 dropped SkyObjects (0x02000714, 0x02000BA6 ×2,
0x02000588 ×4, 0x02000589 ×3 across various time-of-day windows).
Verbatim from retail's CelestialPosition struct at acclient.h:35451:
struct CelestialPosition {
IDClass<...> gfx_id;
IDClass<...> pes_id; // particle scheduler
float heading; float rotation;
Vector3 tex_velocity;
float transparent; float luminosity; float max_bright;
unsigned int properties;
};
Per the named retail decomp, CPhysicsObj::InitPartArrayObject (decomp
~280484) dispatches gfx_id by type prefix: type 6 → direct GfxObj,
type 7 → Setup via CPartArray::CreateSetup (decomp ~287490) which
walks Setup.Parts. Mirror that here: detect 0x020xxxxx in
EnsureMeshUploaded, route to a new EnsureSetupUploaded helper that
flattens via SetupMesh.Flatten (existing Phase-2 utility) and bakes
each part's transform into the vertex positions before upload.
Sky setups don't animate in any way that affects the static-mesh
visual we render here.
Probe extension: also added the Diffuse column to RainMeshProbe's
sky-surface audit so the (Type, Translucency, Luminosity, Diffuse)
quadruple is visible on every flag-bit row.
Visual impact at verification launch: not observable. The Setup
objects in Rainy DGs appear to be tiny placeholder meshes existing
mainly to anchor PES emitters. The dynamic "aurora-like" sheen the
user observes in retail comes from the PES particle layer, which
remains unimplemented (issue #28). Keeping this fix because the
geometry path is now decomp-correct and provides foundation for
the eventual PES wiring.
Issue #29 filed for the residual cloud-density gap. 1227 tests pass.
acdream's TranslucencyKindExtensions.FromSurfaceType picked Additive
first (priority order). Retail's D3DPolyRender::SetSurface at
0x0059c4d0 (decomp 425083+) has a different resolution: when the
Translucent flag (0x10) is set AND either Base1ClipMap (0x04) is set
OR the surface would otherwise be opaque (no Additive/Alpha/InvAlpha),
the blend is *forced* to (SrcAlpha, InvSrcAlpha) — i.e. standard
alpha-blend, not additive. Verbatim from decomp lines 425246-425260:
if ((curr_surface_type & 0x10) != 0) {
if (skipChk != 0 || ebx == 0 || arg3 == 1) {
edi_2 = BLEND_SRCALPHA; // src
ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend
}
curr_alpha = _ftol2(translucency * 255);
}
Where `arg3 == 1` is set after the Base1ClipMap branch and `ebx == 0`
is the opaque-base case in Branch 2.
Concrete impact: Dereth's inner cloud sheet GfxObj 0x01004C35 uses
surface 0x08000023 with Type=0x10114 (B1ClipMap|Translucent|Alpha|
Additive). Retail renders it alpha-blend; acdream was rendering it
additive. Additive on a dark cloud texture only brightens the
background — sun shines through unchanged — which doesn't match
retail's denser cloud appearance.
Rain surface 0x080000C5 (Type=0x10112 = B1Image|Translucent|Alpha|
Additive, NO ClipMap) hits Branch 1 → Additive, ClipMap branch is
skipped, the Translucent override doesn't fire (arg3 stays 0) → stays
Additive. Visual rain rendering is unchanged.
User reported no visible difference at the verification launch; the
remaining cloud-density gap likely lives in the PES particle layer
(issue #28). Keeping this fix because the classification is now
decomp-correct regardless of immediate visual impact — issue #29
documents the residual gap.
1227 tests pass.
The pre/post-scene sky pass split was using SkyObjectData.IsWeather
(bit 0x04) — the wrong bit. Per the named retail decomp:
GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp 269036:
MakeObject(this, gfx_id, &tex_velocity,
(properties & 1), // arg4: post-scene flag
(properties & 4)); // arg5: weather gate
GameSky::MakeObject at 0x00506ee0 / decomp 268656:
if (arg4 != 0)
AddObjectToSingleCell(result, after_sky_cell); // post-scene
else
AddObjectToSingleCell(result, before_sky_cell); // pre-scene
So bit 0x01 routes between before_sky_cell (rendered pre-scene by
GameSky::Draw(0)) and after_sky_cell (rendered post-scene by
GameSky::Draw(1)). Bit 0x04 is independent — it gates whether the
object is instantiated at all when LScape::weather_enabled is false.
In Dereth's Rainy DayGroup this matters for the rain cylinders:
0x01004C42 Props=0x04 (bit 0x04 only) → pre-scene + weather-gated
0x01004C44 Props=0x05 (bits 0x01+0x04) → post-scene + weather-gated
0x01004C35 Props=0x02 (bit 0x02 only) → pre-scene (cloud, fog-hide)
Before this fix acdream put BOTH rain cylinders in the post-scene
pass (because both have bit 0x04). That double-rendered foreground
rain — explained why acdream's foreground rain looked thicker than
retail's. Now only 0x01004C44 is foreground; 0x01004C42 renders with
the sky dome.
Added SkyObjectData.IsPostScene (bit 0x01) with citations. Renamed
the internal RenderPass parameter weatherPass → postScenePass and
updated both the partition criterion and the -120m foreground-rain
Z offset to gate on it. Public RenderSky / RenderWeather entry
points kept their names for API stability; doc comments updated to
explain the bit semantics.
Independent confirmation from one of the user's external code-review
agents — the report's Setup-objects-silently-dropped finding is the
remaining defect in the same family (Setup IDs 0x020xxx aren't
loaded by EnsureMeshUploaded; deferred to a separate phase).
1227 tests pass.
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:
acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:
SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
sunVec.y = cos(P_rad) ← NOT scaled by DirBright
sunVec.z = DirBright × sin(P_rad)
PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)
SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)
Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.
Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).
Tests:
- Replaced the linear-interp assumption in
Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
(RetailSunVector_AtZenith, _AtHorizonNorth,
SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
with the new expected magnitude.
User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.
1227 tests pass.
Three retail-faithful sky/weather composite fixes (one cohesive commit
because they touch the same per-Surface flag plumbing path).
1. Surface.Translucency is OPACITY, not (1 - opacity).
Retail D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260)
computes `curr_alpha = _ftol2(translucency × 255)` and writes that
directly as vertex.color.alpha. ACViewer (TextureCache.cs:142) and
WorldBuilder (ObjectMeshManager.cs:1115) both use `1 - translucency`
and are wrong by the same misread. Cloud surface 0x08000023 has
Translucency=0.25; under the old (1-x) formula opacity was 0.75,
making clouds 3× too bright vs retail. Flipped to use translucency
directly. Gated on the Translucent flag (0x10) so non-Translucent
surfaces (which carry Translucency=0 in the dat) keep opacity 1.0
instead of going invisible.
2. Sky fog re-enabled with a "fog floor" mitigation.
Disabled 2026-04-24 because Dereth sky meshes are authored at radii
1050-1820m while storm-keyframe FogEnd is ~400m, which would saturate
the entire dome to flat fogColor and destroy stars/moon/dome texture.
Retail visibly DOES fog its sky, mechanism still un-pinned. Workaround:
clamp `vFogFactor` to a minimum of SKY_FOG_FLOOR=0.2 so the dome shows
AT LEAST 20% raw texture even at extreme distances. Tuned via dual-
client visual comparison; preserves stars/moon while letting the
horizon haze visibly in low-FogEnd keyframes.
3. Additive sky surfaces skip fog entirely.
Retail D3DPolyRender::SetSurface at 0x59c882 calls
SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) is set —
sun, moon, stars, additive cloud sheets render unfogged. Without this
gate the sun dimmed to fog color at horizon dusk/dawn instead of
staying bright. Plumbed via new `uApplyFog` shader uniform driven by
the existing SubMeshGpu.IsAdditive boolean (already set from
TranslucencyKind.Additive at upload time).
User visually verified all three vs retail screenshots in Holtburg.
Tests: 1223 pass.
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:
arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
arg3 = lerp(k1.amb_bright, k2.amb_bright, u)
final = (arg4.rgb * arg3, ...)
acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
- retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
- acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)
For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.
Refactor:
SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
SEPARATELY (raw, not pre-multiplied).
Computed properties SunColor and AmbientColor return the
post-multiplied product, keeping the shader uniform interface
(uSunColor / uAmbientColor) unchanged.
SkyStateProvider.Interpolate lerps each raw channel, then constructs
a new SkyKeyframe whose computed properties yield the correct
post-lerp multiply.
SkyDescLoader now stores raw values without pre-multiplying.
GameWindow comment updated; no functional change there.
Default factory + tests updated to use the new constructor parameters
with DirBright=AmbBright=1.0 (preserving exact existing behavior).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in calendar display (the CLOCK ITSELF was already correct):
1. **Month enum had wrong order + non-retail names.** Old enum:
Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ...
At day-of-year 83 this gave month index 2 = Leafdawning. Retail's
@timestamp at the same moment shows "Seedsow 24". Fixed enum to
chronological order starting at year-anchor month Morningthaw, with
retail-canonical names:
Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine,
Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap,
Coldeve, Wintersebb.
At day-of-year 83 → month 2 = Seedsow ✓
2. **ToCalendar returned relative year, not absolute Portal Year.**
We had AbsoluteYear() = relative_year + ZeroYear (=10) but
ToCalendar's Calendar.Year was the relative one. So acdream's
title bar showed "PY 106" while retail's @timestamp at the same
tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the
exposed Calendar.Year matches retail's display.
3. **GameWindow title bar now shows the calendar.** Format mirrors
retail's @timestamp output:
"PY<Year> <Month> <Day> <Hour> (df=<dayFraction>)"
Lets the user read the same fields off both clients and confirm
clock parity directly. Drift > 1 hour = real bug.
Tests:
- Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap)
- Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.)
- Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat
pinning a retail-known tick → retail-known calendar string.
The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`,
decomp 0x005a6400 line 434549) was already correct; an earlier-this-
session attempt to flip the sign was reverted in this same commit's
parent. The "few minutes drift" observed in dual-client comparisons
this session was a combination of:
- calendar label mismatch (this fix addresses)
- slot-boundary rounding (fixes itself)
- 1-minute wall-clock interpolation drift (within tolerance)
NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed
("Client clock drifts from retail"); plan to re-title or close in a
follow-up commit after the visual-divergence investigation lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cloud rendering parity with retail confirmed visually under Phase 0 of
the #27 fix plan: launched acdream with no DG override (LCG-picked
matches retail's pick), compared cloud coverage / color / edges /
movement at the same in-game time. User verdict: "Cloud and colors look
correct."
The original #27 observation from earlier in this session was a
side-effect of the broken `effEmissive=1.0` default that saturated every
sky mesh's vTint to white. That bug, plus the orthogonal `surface.Translucency`
plumbing gap, were both repaired in commit 4678b3e:
- Fix 1 (Translucency): cloud surface 0x08000023 has Translucency=0.25,
now plumbed end-to-end → clouds at 75% opacity instead of 100%.
- Fix 2 (Luminosity): cloud surfaces have Luminosity=0.0, so post-fix
they run through `vTint = ambient + sun·N·L` instead of saturating
to white — clouds pick up the keyframe time-of-day tint.
User also flagged that acdream's clock is "a few minutes ahead" of retail
(sun higher on the horizon at the same wall-clock moment). That is the
existing #3 (`Client clock drifts from retail after ~10 minutes —
periodic TimeSync missing`), reproducing exactly as documented. Out of
scope for the sky-fixes branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rain bug from `docs/research/2026-04-26-sky-investigation-handoff.md`
fully resolved this session. Three commits sequentially landed the
retail-faithful path:
3e0da49 — sky pass split + -120m weather Z offset
4678b3e — Surface.Translucency + Luminosity plumbing
d95a8d2 — delete legacy camera-attached particle emitter
Visual verification by user: rain renders as volumetric foreground,
direction matches retail when LCG-picked DayGroup matches retail's,
no cylinder rim visible looking up.
Two follow-up issues remain open from the visual-verify session:
#27 — cloud rendering parity (Translucency=0.25 partial fix landed
but cloud coverage still differs from retail, possibly
keyframe-tint related)
#28 — aurora/northern lights — research found NO evidence in retail
decomp, references, or DG composition; either misremembered
or emergent from cloud system at specific keyframes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-research workaround at GameWindow.UpdateWeatherParticles +
BuildRainDesc + BuildSnowDesc was acdream's stand-in for retail's
weather rendering. It emitted billboarded particles inside a 15m disk
attached to the camera ('AttachLocal'), with a broken alpha fade
(0.3 → 0 caused rain to vanish at exact ground level — Issue #1) and a
fixed disk that visibly framed the player even at speed.
Retail rain is the world-space mesh path (SkyRenderer.RenderWeather):
GfxObj 0x01004C42 / 0x01004C44 — hollow octagonal cylinder, 113m radius,
815m tall, anchored at player_pos + (0, 0, -120m) per
GameSky::UpdatePosition at 0x00506dd0 — drawn AFTER the landblock pass
per LScape::draw at 0x00506330. Snow renders identically when a Snowy
DayGroup is active: the partition by Properties&0x04 picks up snow
weather meshes for free.
The legacy emitter was gated behind ACDREAM_FAKE_RAIN_PARTICLES=1 in
the previous commit (3e0da49) so the world-space path could be
A/B-compared. Visual verification this session confirmed the world-
space path is correct; deleting the legacy code removes ~120 LOC plus
the env var, the gate, the _rainEmitterHandle / _snowEmitterHandle
fields, and the _lastWeatherKind state machine.
Files affected:
GameWindow.cs: drop UpdateWeatherParticles, BuildRainDesc, BuildSnowDesc,
emitter-handle fields, last-weather-kind state, and the gated call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent brightness bugs were compounding to make rain ~6.7×
too bright at the cylinder rim, and clouds full-bright instead of
time-of-day-tinted:
**Fix 1 — Surface.Translucency was never plumbed to the shader.**
Retail's D3DPolyRender::SetSurface at 0x59c767: when the Surface's
Translucent (0x10) bit is set, its translucency float drives per-vertex
alpha (curr_alpha = ftol(0.5 × 255) = 127). ACViewer
(TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both
encode the same as `opacity = (1 - x)`. acdream read only Surface.Type
and Surface.Luminosity in GfxObjMesh.Build() — Surface.Translucency
(the float) was never read, never stored, never reached the shader.
For the rain Surface 0x080000C5 (Translucency=0.5) this meant rain
streaks were at full alpha=1.0 instead of 0.5 — 2× brighter than retail
under the (SrcAlpha, One) blend.
Plumbed end-to-end:
GfxObjSubMesh.SurfTranslucency (init float, default 0)
GfxObjMesh.Build() reads surface.Translucency next to .Luminosity
SubMeshGpu.SurfTranslucency carries it to draw time
SkyRenderer.RenderPass writes uniform `uSurfTranslucency`
sky.frag final alpha: a = sampled.a × (1 - uTransparency) ×
(1 - uSurfTranslucency)
Bonus reach: cloud surface 0x08000023 has Translucency=0.25 → clouds
also dimmed by 25%, more retail-faithful overall.
**Fix 2 — Emissive default was 1.0 instead of the surface's actual Luminosity.**
The sky shader's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity`
fallback never fired because the local `luminosity` defaulted to 1f (always
> 0). Every sky mesh got effEmissive=1.0, saturating vTint to white before
the alpha blend. The comment claimed the fallback was active; the code
disagreed.
Empirical sky-surface LUMINOUS audit (RainMeshProbe a6e7108) found that
NO Dereth sky surface carries the SurfaceType.Luminous flag (0x40) —
the previous code comment that did was wrong. The differentiator is
purely the Surface.Luminosity FLOAT:
dome/sun/moon: Lum=1.0 → vTint saturates → texture passthrough
stars/clouds: Lum=0.0 → vTint = ambient + sun·N·L → time-of-day tint
rain: Lum=0.1484 → faint emissive baseline + lit additions
Refactored:
replaceLuminosity = NaN sentinel for "no replace override"
rep.Luminosity > 0 → set replaceLuminosity to override value
rep.MaxBright > 0 → cap replaceLuminosity at MaxBright
effEmissive = NaN ? sub.SurfLuminosity : replaceLuminosity
Dead uniform `uLuminosity` removed from sky.frag and SkyRenderer SetFloat
call — the redundant multiply was already commented-out earlier this
year (would have double-dimmed clouds), and the uniform value was unused
in the fragment.
Visual verification (Holtburg, live ACE, Rainy DG forced and natural
LCG-picked): rain rim is no longer visible; cloud direction matches
retail when the same DayGroup is active; sky lighting transitions through
day cycle with appropriate time-of-day tint on stars/clouds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added per-Surface dump that decodes Type bits and prints whether the
LUMINOUS (0x40) flag is set on each. Targets all 27 sky surface IDs
referenced by Holtburg's Region — every dome variant (0x010015EE/F0/F1/F2),
the inner sky/star sheet (0x010015EF), sun (0x01001F67/0x01001348), moon
(0x01001F6A), every cloud variant (0x01004C35..0x01004C3A, 0x010015B6),
and rain (0x01004C42/0x01004C44 — control row).
Result: zero of the 27 surfaces have the LUMINOUS bit set. The previous
SkyRenderer comment that claimed dome+clouds carried the bit was wrong;
the differentiator between "self-lit texture passthrough" and
"ambient+diffuse-tinted" sky meshes is purely the Surface.Luminosity
FLOAT (1.0 dome/sun/moon, 0.0 stars/clouds, 0.1484 rain). This fed
directly into the emissive-default fix in the next commit.
Bonus finding: cloud surface 0x08000023 has Translucency=0.25 (not 0)
which the Translucency plumbing fix in the next commit will also pick
up — clouds will render at 75% opacity, matching retail's curr_alpha
derivation (D3DPolyRender::SetSurface at 0x59c767).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling of StarsProbe/WeatherEnumerator. Targets GfxObjs 0x01004C42 and
0x01004C44 (the two rain cylinders). For each: dumps the Surface raw
record (Type bits, Translucency, Luminosity, Diffuse, ColorValue,
OrigTextureId), every polygon's SidesType + Stippling + hasPos/hasNeg
emission flags (mirroring GfxObjMesh.Build's neg-side rule), and the
final GfxObjMesh.Build() submesh+index counts.
Built per independent code-review §5: "Run one targeted probe... if one
cylinder has more than 48 indices per side-equivalent, fix the
duplicate-side/cull behavior together with the surface-opacity uniform."
Probe results (rain_mesh_probe.log, not committed):
Surface 0x080000C5: Type=0x10112 (Base1Image|Translucent|Alpha|Additive),
Translucency=0.5000, Luminosity=0.1484, OrigTextureId=0x050016A6.
Polygons: all 8 are Stippling=Positive, SidesType=None, hasNeg=False.
Build output: 1 submesh, 24 verts, 48 indices = 8 walls × 2 tris × 3.
→ SINGLE-SIDED (the duplicate-side hypothesis is disconfirmed).
Confirmed: the rim brightness excess is purely from Translucency not
being plumbed (acdream draws rain at full alpha=1.0 instead of retail's
0.5). Bonus finding: surface.Luminosity=0.1484 is also ignored by the
renderer's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity`
fallback (the local `luminosity` defaults to 1.0 so the fallback never
fires) — but that's keyed on the LUMINOUS flag bit (0x40), which the rain
surface does NOT have. Filed as follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md:
rain mesh was only visible at horizon, not in the air between camera and
character. Two retail mechanisms ported here:
1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls
`GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and
`GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene
geometry so additive rain streaks paint on top of terrain and entities.
Acdream was rendering both passes pre-scene, so terrain immediately
painted over the rain.
Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather)
and `RenderWeather` (filter IsWeather) sharing a private `RenderPass`
core that takes a `weatherPass` bool. Partition is per-SkyObject by
`Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator).
Added `SkyObjectData.IsWeather` getter for the partition.
`GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/
particles (line ~4322) and `RenderWeather` after particles (line ~4368).
2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0,
lines 0x506e96..0x506e98:
if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
Weather objects (property bit 0x04 set, bit 0x08 unset) get their frame
origin set to player_pos + (0, 0, -120m). The rain cylinder GfxObjs
0x01004C42/0x01004C44 have local Z range 0.11..814.90 (815m tall, 113m
radius). Without the offset the cylinder bottom sat just above the
camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90)
so the camera is inside.
`SkyRenderer.RenderPass` applies the -120m model translation when
`weatherPass` is true (line ~253-254).
3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` —
the pre-research workaround that emitted camera-attached rain particles
(broken alpha fade, fixed disk around camera) — is now gated behind
`ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful
world-space mesh is the default path.
User-verified: rain is now visible in foreground from many perspectives,
but the cylinder's open-top rim is still visible when looking straight up.
That rim issue is a separate brightness-excess bug filed for follow-up
(Translucency float not plumbed to shader; surface.Translucency=0.5 ignored
so streaks render at 2× retail intensity).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
K shipped previously (commit f42c164) but never got a row in the
"Phases already shipped" table — only the per-sub-piece K.3 callout
in the Phase K section. Adding the K row here for completeness.
L.0 — full retail-style Settings interface — shipped this session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two should-fix items from the pre-merge code review pass:
1. Persisted settings now apply on startup unconditionally
(previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
underlying engine paths don't exist yet (R5 MIDI playback).
== 1. Settings load + apply outside DevToolsEnabled gate ==
Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.
Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.
The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.
== 2. Music + Ambient sliders hidden ==
OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.
Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.
dotnet build green; dotnet test 1,309 / 1,309 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These changes were referenced by commits fc1e193 / 6273255 / 2818fcc /
df9f2fd in their messages but the actual edits sat uncommitted in the
working tree — caught by the pre-merge code review pass. Without this
commit the merge to main would lose all the panel-layout fixes the
user already live-verified.
What was orphaned:
· _window.FramebufferResize += OnFramebufferResize (Run() wiring)
· OnFramebufferResize handler — updates GL viewport + camera aspect
on window resize; force-resets panel layout via ResetPanelLayout.
· ResetPanelLayout(ImGuiCond) — positions Vitals / Chat / Debug /
Settings panels at sensible defaults relative to current window
size. Called at startup with FirstUseEver (imgui.ini wins on later
launches) and on FramebufferResize / View menu item with Always
(force reset).
· View → "Reset window layout" menu item.
· OnLoad seeding ResetPanelLayout(FirstUseEver) after panel
registration so first-launch users don't see all panels stacked
at (0,0).
· DisplaySettings.Default.Resolution: "1920x1080" → "1280x720" so
the default matches the WindowOptions startup size — opening
Display + Save without edits is a complete visual no-op (the
alternative would have triggered an immediate resize on every
first-time Save).
dotnet build green; tests unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The InvisibleButton drag-trap inside BeginChild only catches clicks
inside that specific child. Chat had widgets OUTSIDE the inner
##chattail child (the Copy-mode Checkbox + a Separator at top, the
footer Separator + InputTextSubmit at bottom) — empty space around
those widgets fell through directly to the parent window's
window-drag init.
Fix: wrap the entire chat panel body in a single outer ##chatbody
BeginChild before drawing any content. The renderer's drag-trap
fires inside this outer child too, absorbing every empty-space
click in the chat panel body. The inner ##chattail child is now
nested inside it, which doesn't change its scroll-tail semantics
but does mean it gets its own drag-trap as a bonus.
Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight
was using Single(BeginChild) — there are now two BeginChild calls
(##chatbody outer + ##chattail inner). Switched to Single(... &&
Args[0] == "##chattail") so the test still pins the footer reserve
on the inner call where it lives.
dotnet build green; 1,309 / 1,309 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous fix put the InvisibleButton absorber inside Begin, which
covered the entire panel body — and the Settings panel's tab bar
has its hit-testing in that same area. Tabs lost click priority to
the absorber (their hover/click events were stolen) so the user
couldn't switch tabs. Worse, the chat-panel drag the absorber was
supposed to fix wasn't actually fixed because chat's body is
covered by a BeginChild for the scrollable tail — clicks land in
the child window, not the parent body, so the parent absorber
never sees them.
Right scope: scrollable BeginChild bodies. That's where the chat
panel's empty-space clicks actually land, and where the parent-
drag fall-through originates. Other panels (Settings, Vitals,
Debug) don't use BeginChild for content — their bodies are filled
with widgets that already absorb clicks naturally.
The fix:
· Begin reverts to ImGui default (title bar drags, body of widget-
filled panels naturally absorbs through the widgets themselves).
· BeginChild grows the InvisibleButton absorber inside, so empty-
space clicks inside a scroll region don't fall through to the
parent's window-drag init.
Net effect:
· Chat panel: empty clicks in the scroll tail no longer drag the
parent window.
· Settings panel: tabs are clickable again.
· Vitals, Debug: unchanged.
dotnet build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported that clicking anywhere in a panel (chat, settings, etc)
started a window drag. ImGui's default window-drag init fires on any
body click that doesn't land on an "active" widget — empty space
between Text widgets, BeginChild background pad, etc. all qualified.
Fix: right after Begin, place an InvisibleButton sized to the full
body content region, then reset the cursor so subsequent panel
content renders normally. ImGui's click-priority is "last drawn,
first checked" — so real widgets drawn afterwards still claim their
own clicks. The InvisibleButton catches ONLY clicks on empty body
space, marks itself as the active item, and ImGui's window-drag
check sees ActiveId != 0 → no drag.
Net effect: title bar still drags (ImGui default), body never
drags. Applies uniformly to every panel that calls
IPanelRenderer.Begin (chat / settings / vitals / debug).
dotnet build green (0 warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug B from the sky-investigation handoff is fixed in 7b88fde — file the
Recently closed entry. Two new observations from the visual-verify
session that the user flagged when they could finally see the sky
clearly: cloud coverage looks faint vs retail, aurora ("northern
lights") not rendered at all. Both LOW severity (aesthetic feature
parity, not gameplay-breaking) and out of scope for the current
worktree, which is heading to Bug A (foreground rain, #1) next per
docs/research/2026-04-26-sky-investigation-handoff.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug B in docs/research/2026-04-26-sky-investigation-handoff.md: stars
rendered as a small square in one corner of the sky instead of stretching
across the dome.
Root cause: the wrap-mode heuristic at SkyRenderer.cs:234-237 was
"GL_CLAMP_TO_EDGE unless TexVelocity != 0". That heuristic was tuned to
fix a separate symptom (the outer dome 0x010015EE/F0/F1/F2 shows
wall-seam bleed under GL_REPEAT because of bilinear-filter sampling at
texel boundaries). But it misclassified any *static* sky object whose
mesh UVs are deliberately authored outside [0,1] to tile the texture
across the geometry.
The smoking gun: GfxObj 0x010015EF is OI-1 in EVERY DayGroup (always
loaded), has TexVelocity = 0 (no scrolling), and authors UVs in
[0.398, 4.602] (texture tiles ~4× across each face). Under
CLAMP_TO_EDGE the bulk of the inner dome sampled the texture's edge
texels; only the small region where UVs happened to fall in [0,1]
showed actual texture content. Hence "a square in one corner".
Fix:
* GfxObjMesh.Build() now scans the resulting per-vertex UVs and sets
GfxObjSubMesh.NeedsUvRepeat true when any component lies outside
[0,1]. Mesh-time scan, not draw-time guess.
* SubMeshGpu carries the flag through to draw time.
* SkyRenderer uses `sub.NeedsUvRepeat || obj.TexVelocity != 0` to
decide REPEAT vs CLAMP_TO_EDGE. The dome (UVs in [0,1]) keeps
CLAMP — no seam regression. The inner star/sky layer 0x010015EF
(UVs outside [0,1]) gets REPEAT — texture tiles across the dome.
Cloud meshes (UVs outside [0,1] AND non-zero TexVelocity) keep
REPEAT via either branch.
Probe-driven: tools/StarsProbe (committed in 991fb9a) dumps every
SkyObject's geometry + UVs and flags meshes whose UV range exceeds
[0,1]. Run `dotnet run --project tools/StarsProbe -c Release` to
re-derive.
Verified visually by user against the live ACE server in Holtburg —
stars now stretch across the night sky instead of appearing as a
square in one corner. Build green, dotnet test 1222 pass.
Note: this is functionally retail-equivalent for the reported bug but
not the exact retail mechanism. Retail's GameSky::Draw at 0x00506ff0
relies on D3D's global default D3DTADDRESS_WRAP (i.e. REPEAT
everywhere). True retail-faithfulness would require investigating why
our pipeline shows seams on the dome under REPEAT (likely a bilinear
filter / non-seamless texture detail). The data-driven approach taken
here preserves working dome behavior while fixing the broken star
behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling of WeatherEnumerator/PesChainAudit. Walks every DayGroup in the
Dereth Region (0x13000000), prints each SkyObject (Properties bits,
TexVelocity, BeginTime/EndTime, gfx/pes ids), then dumps the underlying
GfxObj's vertices, UV ranges, and surfaces. The crucial diagnostic is
the per-GfxObj "UV range outside [0,1]" flag.
Built for Bug B (sky-investigation-handoff §"Bug B"): stars rendering as
a square in one corner of the sky. Smoking gun on first run: GfxObj
0x010015EF (OI-1 in every DayGroup, TexVelocity = 0) has UVs in
[0.398, 4.602] — meaning the texture tiles ~4× across each face, but
SkyRenderer's "CLAMP_TO_EDGE unless TexVelocity != 0" heuristic forces
clamp on it, so the whole inner dome samples edge texels except the
tiny region where UVs happen to fall in [0,1]. That tiny region is the
"square in one corner" the user observed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported transition from running to jumping looked
slow -- the character stood still for ~100 ms at the start
of the jump before the legs folded into Falling.
Root cause: AnimationSequencer.SetCycle resolves a
transition link (e.g. RunForward -> Falling) from the
motion table and enqueues those non-looping link frames
BEFORE the Falling cycle. The link is the "stop running,
prepare to fall" anim -- a few frames of standing-style
pose. While it drained, the character looked frozen.
Fix: SetCycle gains a skipTransitionLink parameter. When
true, the GetLink call is bypassed AND the entire queue is
cleared (so any in-flight non-cyclic frames from a
previous transition don't continue draining). Only the
target cycle gets enqueued, cursor goes straight to its
start.
Both call sites pass true for Falling:
- OnLiveVectorUpdated (remote-jump VectorUpdate handler)
- UpdatePlayerAnimation (local airborne path) when
animCommand == Falling. Other transitions
(Walk -> Run, Run -> Ready, etc.) keep the link --
smooth transitions stay smooth, only the jump start
is hard-cut.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from the K-fix16 jump-now-renders launch:
1. Mid-air movement broke the jump animation.
When a remote turned or ran while airborne, ACE broadcast
UpdateMotion with the new motion state. OnLiveMotionUpdated's
SetCycle call swapped Falling -> RunForward / TurnRight /
etc., breaking the visible jump pose. The arc still played
out (physics integrated body position correctly) but the
legs ran instead of folded.
Fix: skip the SetCycle in OnLiveMotionUpdated when
rm.Airborne is true. The InterpretedState DoMotion calls
below it still fire, so the body's velocity matches the
new motion command and the body keeps moving correctly --
only the visible cycle stays Falling.
2. Stuck in Falling pose after landing.
K-fix15 cleared rm.Airborne + restored ground state on
landing, but never told the sequencer to swap cycles. The
remote stayed in the Falling pose forever (legs folded)
until the server happened to send a fresh UpdateMotion
(e.g. when the player walked again). Idle landing left
them frozen.
Fix: post-land, read InterpretedState.ForwardCommand and
call SetCycle with that command + the recorded
ForwardSpeed. Default to Ready / 1.0 when the state is
blank. The next UpdateMotion from the server will refine
if needed (e.g. mid-strafe land), but the legs come out
of Falling immediately.
Drive-by: stripped K-fix16's unconditional [VU.recv] log
now that the parser is verified working.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: "in ACdream client, when other client is jumping,
nothing happens at all".
Diagnostic [VU.recv] revealed the parser was reading
guid = 0x0000F74E (= the opcode itself) and velocity values in
the billions:
[VU.recv] guid=0x0000F74E vel=(8589944832.00,0.00,0.00)
isLocal=False hasRemote=False
WorldSession.ProcessDatagram passes the FULL reassembled body
including the 4-byte opcode at offset 0 — every other parser
in src/AcDream.Core.Net/Messages/ verifies the opcode word
before reading payload (UpdateMotion.TryParse:77,
UpdatePosition.TryParse, etc.). VectorUpdate.TryParse skipped
that step and read every field shifted four bytes early,
making the guid the opcode bytes and the velocities random
floats from later in the buffer. With guid=0xF74E never
matching any tracked entity, OnLiveVectorUpdated returned
early and remote jumps rendered nothing.
Fix: read + verify opcode at offset 0 in TryParse, then read
guid at offset 4, velocity at 8/12/16, omega at 20/24/28,
sequences at 32/34. Body length now 4 (opcode) + 32 (payload).
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.
New IPanelRenderer primitive:
void TextMultilineReadOnly(string id, string content, Vector2 size);
ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).
ChatPanel rendering:
· A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
the panel toggles _copyMode.
· Off (default) — current per-line render with colored combat
entries. Visually unchanged from before.
· On — the chat tail becomes a single TextMultilineReadOnly
widget holding every visible line joined with newlines. Loses
per-line color, gains arbitrary-span text selection.
· Footer (separator + input field) renders identically in both
modes so the user can still type while in copy mode.
Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.
DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cause of "remote characters jump up and get stuck in the air":
K-fix9 cleared rm.Airborne on every UpdatePosition, but ACE
broadcasts UPs during the arc (peak / mid-fall / land) at
~5-10 Hz. The first UP after a jump:
1. Snapped body position to server mid-arc Z (often the apex).
2. Cleared rm.Airborne -- restored Contact + OnWalkable, removed
the Gravity flag.
3. Next per-tick: apply_current_movement reads
InterpretedState (Ready) and stomps Body.Velocity to
(X, Y, 0).
Body stuck at apex Z forever.
Fix: do not auto-clear Airborne on UP. The position snap stays
authoritative -- if ACE says the body is at Z=68 mid-arc we
render Z=68, but we keep integrating gravity from there.
Per-tick post-resolve now detects a real landing -- mirrors the
local-player landing path in PlayerMovementController: when the
resolver returns IsOnGround && Velocity.Z <= 0, clear Airborne,
restore Contact + OnWalkable, remove Gravity, zero residual
downward velocity, and call HitGround so the sequencer can swap
Falling to idle/locomotion.
ACDREAM_DUMP_MOTION=1 logs each landing as
"VU.land guid=0x... Z=...".
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures everything learned from a long worktree iteration on the
foreground-rain bug (ISSUES.md #1 / #26) plus a new star-rendering
bug observed in the same area. The code work from that worktree
(WeatherDispatcher, EmitterDescLoader.LoadFromDat, WeatherCellRenderer,
GameWindow integration) was reverted because it didn't visibly fix
the rain bug — but the research findings + diagnostic tools are
durable and should not have to be rediscovered.
What's added:
- docs/research/2026-04-26-sky-investigation-handoff.md
Comprehensive seed prompt for the next session. Covers:
* Bug A: foreground rain (#26) — what's open, what's confirmed,
what's been tried
* Bug B: stars rendering as square in corner (NEW, user-observed)
* 40-agent decomp scan findings — retail rain is NOT camera-
particles, NOT server-driven, NOT screen-space; the mesh IS
a hollow octagonal tube; only 5 weather GfxObjs in Dereth
* Things ruled out by trial (envelope, scaling, unlit, depth-
always alone, Setup loading)
* Things to try next (depth+zfar combined, full render-state
audit, frame ordering, star UV bug as easier first target)
* Acceptance criteria for "done"
- docs/research/2026-04-26-chorizite-pr-draft.md
Upstream PR draft for Chorizite/DatReaderWriter. Five generated
DBObj source files reference nonexistent enum values and are
silently excluded from the NuGet build:
ParticleEmitterInfo, Clothing, PaletteSet, DataIdMapper,
DualDataIdMapper. Fix: delete the duplicates. Independent of
the rain work — benefits the AC modding ecosystem broadly.
- docs/research/2026-04-26-datreaderwriter-reference.md
Developer reference for our DatReaderWriter usage. Version,
types we consume, known broken types, thread-safety caveats,
upgrade procedure, NuGet-vs-vendored decision matrix.
- tools/PesChainAudit/
Recursive PES walker — given a 0x33xxxxxx script id, walks all
CallPES references and dumps every hook + every referenced
ParticleEmitter's parameters. Used to prove no weather PES
emits rain particles.
- tools/TextureDump/
Dumps texture pixel statistics (alpha histogram, brightness,
max) and saves as PNG for visual inspection.
- tools/WeatherEnumerator/
Enumerates every DayGroup in a Region, lists weather SkyObjects
(Properties & 0x04), dumps GfxObj bounding boxes.
- tools/WeatherSetupProbe/
Loads a Setup id, dumps each part's GfxObj + frame + scale +
surface. Used to prove weather Setups are 5cm dummy carriers.
Worktree feature/sky-fixes is being deleted in a follow-up step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.
DISPLAY ↔ GL window:
· FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
pushed every frame so dragging is visible immediately. Brainstorm
said FOV is a live-preview slider; this delivers it.
· VSync → _window.VSync, change-detected per-frame so flipping the
checkbox is instant. Applied at startup too so saved-VSync takes
effect before the first frame.
· Resolution → _window.Size on Save (TryParseResolution parses
"WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
Save only.
· Fullscreen → _window.WindowState (Silk.NET borderless mode), also
on Save only.
· ShowFps → wraps the title-bar perf string. true → full perf line;
false → just "acdream" for a cleaner alt-tab. Default true matches
pre-L.0 behavior.
Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.
ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.
CHARACTER ↔ active toon:
· _activeToonKey field replaces the hard-coded "default" — starts as
"default" (used for any pre-login Settings interaction), gets
swapped to the actual character.Name immediately after EnterWorld
in BeginLiveSessionAsync.
· onSaveCharacter callback closes over _activeToonKey by reference
(lambda captures `this`), so saves always write to the current
toon's slot without rebinding the lambda.
· After EnterWorld lands the chosen toon's name, the host loads
that toon's bag via SettingsStore.LoadCharacter and calls a new
SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
AND draft atomically — HasUnsavedChanges stays false on login so
the user doesn't see a "pending changes" indicator just because
they switched toons.
Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".
2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found the underlying cause of the user's persistent
"jumps don't reach retail height" complaint. The wire's SkillEntry
`init` field is ONLY the InitLevel (training/specialized
chargen bonus, per ACE GameEventPlayerDescription.cs:317
"init_level, for training/specialized bonus from character
creation"). It does NOT include the AttributeFormula
contribution.
ACE's CreatureSkill.Current is computed as:
AttributeFormula(skill, attrs) + InitLevel + Ranks
+ augs + multipliers - vitae
Pre-fix13 we used `init + ranks` only — dropping the
AttributeFormula term, which is the DOMINANT component for
movement skills (50-100 points typical). For our character
that meant Jump skill 208 instead of the actual ~280-310,
giving a 3.11 m peak instead of the retail ~4 m peak. Hence
"feels like the upward acceleration is too slow and we don't
reach the same height".
Fix:
- GameWindow caches portal.dat's SkillTable (0x0E000004u) at
WireAll time. Each entry has a SkillFormula with attr1/
attr2/multipliers/divisor/additive constants
(formula: bonus = (attr1*M1 + attr2*M2)/Div + Additive).
- GameEventWiring.WireAll gains a
`resolveSkillFormulaBonus(skillId, attrCurrents)` callback.
GameWindow plugs in a resolver that looks up
SkillTable.Skills[skillId].Formula, applies the formula
using the player's current attribute values from PD.
- The PD handler builds attrId→current map (ranks+start) from
the parsed attributes before iterating skills, then passes
it to the resolver for Run (24) and Jump (22).
- Total skill = formulaBonus + InitLevel + Ranks. Matches ACE
Current minus augs/multipliers/vitae (close enough — those
add maybe ±10 % at most).
ACDREAM_DUMP_VITALS=1 logs add a per-skill line:
"vitals: PD-skill id=22 init=N ranks=N formulaBonus=N total=N"
so live testing can confirm the formula is applied.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (final) — last tab on the Settings shell. Per-toon
preferences keyed by toon name in settings.json under
character[<toonName>]. With this commit the L.0 build order
finishes and every approved tab is implemented.
CharacterSettings record (4 fields):
· DefaultChatChannel (string — Local / Allegiance / Fellowship / etc)
· AutoAttack (bool — continue swinging until target dies)
· ConfirmSalvage (bool — prompt before salvaging valuable items)
· ShowPickupMessages (bool — pickup lines in chat)
AvailableChannels static list exposes the 7 retail-routing targets
for the dropdown.
SettingsStore grows LoadCharacter(toonKey) / SaveCharacter(toonKey)
using JsonNode/JsonObject for the nested-toon write — the existing
SaveSection raw-text-preservation pattern handles top-level keys
but doesn't fit the nested per-toon mutation. The character map
preserves every other toon's settings on save, and other top-level
sections (display / audio / gameplay / chat) are preserved too.
SettingsVM grows the parallel character state machine. The host
owns the toonKey (currently hard-coded to "default" in GameWindow
because we don't have a current-character source plumbed yet) —
the VM just edits whatever bag the host loaded.
SettingsPanel.RenderCharacterTab replaces the L.0-shell placeholder
— a Combo for default chat channel + 3 Checkboxes for
AutoAttack / ConfirmSalvage / ShowPickupMessages. The
RenderPlaceholder helper is now removed (no callers); the old
"Placeholder_tabs_render_coming_soon_text_when_active" test is
replaced by an "all six tabs are implemented" guard test that
fails if any future commit adds a placeholder back.
GameWindow loads/saves character settings under toonKey "default"
with a TODO comment to swap in the real toon name once
CharacterList plumbing exposes a currentCharacter source.
18 new tests:
· CharacterSettings record (4) — defaults pinned, AvailableChannels
list shape, value equality, with-expressions
· SettingsStore character (6) — missing-file / toon-not-in-file →
defaults, round-trip, multi-toon preservation, preserves other
top-level sections, all five sections coexist
· SettingsVM character (5) — initial draft, SetCharacter marks
dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults
covers
· SettingsPanel character tab (3 net, after removing the
placeholder test) — combo+checkboxes render only when active,
channel combo uses AvailableChannels, all six tabs are now
non-placeholder
Phase L.0 final tally:
· 5 commits on feature/settings-retail (shell + 5 tabs)
· 6 tabs: Keybinds (Phase K) + Display + Audio + Gameplay + Chat + Character
· 5 settings sections in settings.json (display/audio/gameplay/chat/character),
coexisting non-destructively + a sixth file (keybinds.json) on the side.
dotnet build green (0 warnings); dotnet test 1,307 / 1,307 green
(243 Core.Net + 391 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
K-fix11 (150 ms exponential lag) wasn't aggressive enough — at
0.15 s time constant the camera catches up to ~96 % of the
player's Z before peak, so the visible "rise on screen" was
maybe ~0.5 m of the 3.11 m arc. User reported the jump still
looked short.
K-fix12: replace the lag with an explicit airborne-pin. The
camera's tracked Z follows player Z directly while grounded,
but stays PINNED while airborne and rising. Falling / dropping
catches up immediately so we don't end up below ground when
landing in a hole.
Effect: during a jump the player visibly rises 3 m above the
camera on screen, matching retail's "you can see yourself jump"
feel. After landing the camera's tracked Z snaps back to the
player Z so there's no lingering vertical offset.
ChaseCamera.Update gains an isOnGround parameter; GameWindow
passes result.IsOnGround from the per-frame movement controller.
The look-at point still uses raw player Z so the camera tilts up
to keep the airborne character framed.
Tests stay 1222 green.
Phase L.0 (cont.) — fourth tab on the Settings shell. Mixes retail's
CharacterOptions2 chat-channel filter bits (Hear*Chat / TimeStamp /
FilterLanguage / AppearOffline) with a font-size slider that has no
retail bitfield equivalent.
ChatSettings record (9 fields):
· 5 channel filters: HearGeneralChat, HearTradeChat, HearLFGChat,
HearRoleplayChat, HearSocietyChat
· 3 display flags: ShowTimestamps, FilterProfanity, AppearOffline
· 1 visual: FontSize (10..20 pt)
Local-only this phase per the brainstorm — Hear*Chat flags affect
client-side display filtering only; the server still streams every
channel. Server-sync arrives later when the protocol round-trip is
in place.
SettingsStore grows LoadChat / SaveChat using the existing generic
SaveSection helper. All four non-keybind sections (display, audio,
gameplay, chat) now coexist non-destructively in settings.json.
SettingsVM grows the parallel chat state machine. HasUnsavedChanges,
Save, Cancel, ResetAllToDefaults all cover chat. Constructor signature
adds two more params; existing call sites updated.
SettingsPanel.RenderChatTab replaces the L.0-shell placeholder —
8 Checkbox calls grouped under "Channel filters" + "Display"
headers, plus a font-size SliderFloat. The "Coming soon" placeholder
test was retargeted from "Chat" to "Character" since Chat is no
longer a placeholder.
GameWindow wires SettingsStore.LoadChat / SaveChat + a TODO comment
for the future ChatPanel filter integration (read SettingsVM.ChatDraft
when filtering inbound chat lines).
13 new tests:
· ChatSettings record (3) — defaults pinned, value equality, with-
expressions
· SettingsStore chat (3) — missing-file → defaults, round-trip, all
four sections coexist
· SettingsVM chat (5) — initial draft, SetChat marks dirty, Save
invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel chat tab (2) — checkboxes + slider render only when
active
dotnet build green (0 warnings); dotnet test 1,289 / 1,289 green
(243 Core.Net + 373 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins
build order. Subset of retail's CharacterOption + CharacterOptions2
bitfield flags ported as bools (see acclient.h:3404+ enum). Local-
only this phase per the brainstorm — server sync deferred to a
later phase that will marshal the draft into the retail
CharacterOption packet.
GameplaySettings record exposes 14 named flags grouped by usage:
· Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI,
VividTargetingIndicator
· Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, ShowHelm, ShowCloak
· Interface: AllowGive, LockUI, UseMouseTurning
Retail names + bit values are documented in field-level comments so
the future server-sync phase has a 1:1 mapping. Defaults are
typical-user starting points (NOT bit-exact to retail's
0x50C4A54A / 0x948700 masks); class-level remarks call out that
defaults will be re-anchored to retail values once the wire-format
is the load-bearing source.
SettingsStore grows LoadGameplay / SaveGameplay using the existing
SaveSection generic helper (added in the audio commit). All three
non-keybind sections (display, audio, gameplay) now coexist in
settings.json with non-destructive cross-section saves — verified
by a new "all three sections coexist" round-trip test.
SettingsVM grows the parallel gameplay state machine
(gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay).
HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover
gameplay too. Constructor signature adds two more params; existing
call sites (App startup + tests) updated.
SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder —
14 Checkbox calls grouped under three Text+Separator headers, plus
a footer note explaining the local-only-this-phase scope. The
"Coming soon" placeholder test was retargeted from "Gameplay" to
"Chat" since Gameplay is no longer a placeholder.
GameWindow construction site loads gameplay on startup + writes via
the SettingsStore on Save. Server-sync packet wiring is left as a
TODO comment in the onSaveGameplay callback (next phase, after the
protocol round-trip is in place).
14 new tests:
· GameplaySettings record (3) — defaults pinned, value equality,
with-expressions
· SettingsStore gameplay (4) — missing-file → defaults, round-trip,
partial-file fallback, all-three-sections coexist
· SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty,
Save invokes callback, Cancel reverts, ResetAllToDefaults covers
· SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render
only when active
dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green
(243 Core.Net + 360 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic from K-fix10 confirmed our local jump physics is
mathematically perfect — every full-charge jump produces
formulaPeak = actualPeakDz = vz²/19.6 to four-digit precision
(3.11 m for Jump skill 208). Yet the user observed retail
clients seeing the SAME character jump much higher than ACdream
sees of itself.
Root cause: ChaseCamera tracked player.Z 1:1. When the player
rises 3 m the camera rises 3 m too — the player's screen
position never changes during the arc, so the jump is visually
invisible. Retail's chase camera lags the Z follow, so an
observer sees the player visibly rise on screen.
Fix: low-pass filter the camera's Z target.
ChaseCamera.Update gains a dt parameter and an exponential
smoother:
alpha = 1 - exp(-dt / ZFollowTimeConstant)
smoothedZ += (player.Z - smoothedZ) * alpha
ZFollowTimeConstant defaults to 0.15 s — slow enough that a
~1 s jump arc shows up clearly on screen, fast enough that
slope walking still feels glued. The look-at point still uses
the raw player Z so the camera tilts up to keep the airborne
character in frame.
Drive-by: stripped K-fix10 jump diagnostic logging now that the
math has been confirmed correct.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.
AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).
SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.
SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.
GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.
SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.
16 new tests:
· AudioSettings record (3) — defaults pin engine constants, value
equality, with-expressions
· SettingsStore audio round-trip (5) — missing-file → defaults,
round-trip all fields, partial-file per-field fallback, save-audio-
preserves-display, save-display-preserves-audio
· SettingsVM audio state (5) — initial draft tracks persisted,
SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
ResetAllToDefaults covers audio
· SettingsPanel audio tab (3) — four sliders render only when active,
no SliderFloat emitted on inactive tabs, slider range is [0, 1]
dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report:
1. ACdream watching retail-client jump shows no animation at all
(legs don't fold during the arc).
2. Local jump arc in ACdream is shorter than what retail observes
for the same character — formula mismatch somewhere.
Item 1 (animation): K-fix9 wired the body velocity but didn't
swap the sequencer cycle. The remote kept playing whatever
locomotion cycle was active (Ready/RunForward/etc.) through the
arc, so the legs stayed running while the body went up.
OnLiveVectorUpdated now also calls
ae.Sequencer.SetCycle(currentStyle, MotionCommand.Falling, 1.0f)
when the velocity has +Z > 0.5 m/s. Mirrors the local-player
UpdatePlayerAnimation path that forces animCommand=Falling
whenever !IsOnGround. Style defaults to NonCombat (0x8000003D)
when the sequencer hasn't established one yet (rare on remotes).
Landing transitions back to the locomotion cycle naturally via
the next UpdateMotion the server sends after HitGround.
Item 2 (height): added per-jump diagnostic so we can compare
the formula-predicted peak (sentVz²/(2g) = sentVz²/19.6) with
the actually-rendered peak Δz. Logs:
[jump.send] extent=... sentVz=... formulaPeak=...m startZ=...
[jump.peak] sentVz=... formulaPeak=...m actualPeakDz=...m
startZ=... peakZ=... landZ=...
Strip after the height-mismatch root cause is found.
Drive-by: previous diagnostic left an if/else hijack in the
resolve branch that broke 3 PlayerMovementControllerTests. Fixed.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 (cont.) — first concrete tab on the new Settings shell, in
the Easy-wins build order agreed in the brainstorm
(Display → Audio → Gameplay → Chat → Character).
DisplaySettings (immutable record): Resolution / Fullscreen / VSync /
FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9
resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on,
75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement.
SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json
(coexists with keybinds.json — own load/save path stays put, no
migration needed). LoadDisplay falls back per-field when keys are
missing (partial-file tolerant) and falls back to defaults when the
file is corrupt or the JSON is unparseable. SaveDisplay round-trips
preserved — unknown top-level keys (e.g. an `audio` section written
by a future client) are kept on save so older builds don't silently
drop newer-tab data.
SettingsVM gains a parallel display-state machine: persistedDisplay +
draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both
keybinds and display deltas, Save/Cancel/ResetAll cover both
atomically from the user's POV (one Save commits everything, one
Cancel reverts everything). Constructor signature extends with two
new params; existing keybinds-only callers updated.
SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder —
Combo for resolution, Checkboxes for fullscreen/vsync/show-fps,
SliderFloat for FOV + gamma. Live-preview note in the panel body
matches the agreed UX: FOV + gamma update visibly while the user
drags; resolution / fullscreen / vsync apply on Save (live preview
would be too jarring).
GameWindow wires SettingsStore into the existing SettingsVM construct
site — load on startup, save on each tab Save. Errors print to
console and don't crash the panel.
19 new tests:
· DisplaySettings record (4) — defaults pinned, value equality, with-
expressions, AvailableResolutions sorted ascending
· SettingsStore (6) — round trip, missing-file → defaults, corrupt-
file → defaults, partial-file → per-field fallback, unknown-key
preservation, DefaultPath shape
· SettingsVM display (6) — initial draft tracks persisted, SetDisplay
marks dirty, Save invokes display callback, Cancel reverts,
ResetAllToDefaults covers display, Save-then-Cancel is no-op
· SettingsPanel display tab (3) — widgets render only when active,
resolution combo uses AvailableResolutions, no Combo emitted on
inactive tabs
dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green
(243 Core.Net + 330 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.0 — foundation for the complete retail-style Settings interface
agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11
panel into a tabbed shell whose first tab wraps the existing keybinds
content unchanged; the other five tabs (Display / Audio / Gameplay /
Chat / Character) render "Coming soon" placeholders so the shape the
user approved is visible immediately and gets filled in over the L.x
sub-phases (Display first per Easy-wins build order).
Why a tab API extension: retail had distinct Options UIs
(gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the
PDB at acclient_2013_pseudo_c.txt:170739+) and the existing
IPanelRenderer only exposed CollapsingHeader. ImGui maps
BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new
primitives stay backend-friendly — the future D.2b custom retail-look
backend implements them via the retail tab UIs without panel changes.
Save / Cancel / Reset-all stay above the tab bar so they remain global
across all tabs (Phase K's UX preserved). FakePanelRenderer grows
matching tab calls + an ActiveTabLabel knob so tests can target a
specific tab's content; default behavior treats the first tab item
seen as active so existing tests keep passing without changes.
5 new SettingsPanelTests assertions: tab bar opens once, six expected
tab labels emitted in order, Keybinds-tab section headers only render
when active, placeholders show "Coming soon" text on inactive-content
tabs, and Save/Cancel buttons render BEFORE the tab bar (regression
guard against accidentally moving them inside a tab item).
dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green
(243 Core.Net + 311 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>