Commit graph

449 commits

Author SHA1 Message Date
Erik
7656fe0970 fix(anim): Phase L.1c animate server-controlled chase 2026-04-28 19:38:52 +02:00
Erik
b96b680a20 fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
2026-04-28 19:21:02 +02:00
Erik
4874d8595a feat(combat): Phase L.1c wire live attack input 2026-04-28 11:58:57 +02:00
Erik
d1fb68f419 test(world): serialize DerethDateTime offset tests 2026-04-28 11:58:50 +02:00
Erik
646246ba84 feat(anim): Phase L.1c select combat maneuvers 2026-04-28 11:44:17 +02:00
Erik
831392a7b2 feat(anim): Phase L.1c classify combat animation commands 2026-04-28 11:37:49 +02:00
Erik
268af82e28 fix(combat): Phase L.1c align attack type flags 2026-04-28 10:59:29 +02:00
Erik
25b9616703 feat(combat): Phase L.1c add outbound combat actions 2026-04-28 10:57:12 +02:00
Erik
29afc94b94 fix(net): Phase L.1c conform combat wire events 2026-04-28 10:54:50 +02:00
Erik
460f95cb42 fix(anim): Phase L.1b route motion commands 2026-04-28 10:46:22 +02:00
Erik
1c69670392 docs(anim): Phase L.1a animation system audit 2026-04-28 10:38:58 +02:00
Erik
1f82b7604e docs(plans): Phase C.1 PES particle rendering — handoff spec
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.
2026-04-28 10:11:44 +02:00
Erik
e4bc6de7ba chore(sky): post-merge cleanups — CullFace save/restore + stale comments
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.
2026-04-27 23:34:21 +02:00
Erik
f7c9e88b6a Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful 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
2026-04-27 23:30:50 +02:00
Erik
0c82d2c9e9 docs(issues): #28 root-caused (PES particles), #29 filed (residual cloud gap)
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.
2026-04-27 23:24:17 +02:00
Erik
646ccca85e feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
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.
2026-04-27 23:24:09 +02:00
Erik
375065ba94 fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
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.
2026-04-27 23:23:48 +02:00
Erik
034a684f02 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
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.
2026-04-27 22:43:14 +02:00
Erik
05a8a7209f fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
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.
2026-04-27 22:42:53 +02:00
Erik
97fc1b51d8 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
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.
2026-04-27 19:49:51 +02:00
Erik
63b50c5291 fix(sky): retail-faithful keyframe lerp — separate-channel color/bright
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>
2026-04-27 15:02:35 +02:00
Erik
dbe6690a4e fix(time): retail-canonical month enum + absolute Portal Year + title-bar calendar
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>
2026-04-27 14:43:49 +02:00
Erik
449e9c3540 docs(issues): close #27 (cloud parity) — DONE-via-Fix-2
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>
2026-04-27 13:18:02 +02:00
Erik
47e2c151f4 docs(issues): close #1 (foreground rain) — commits d95a8d2 + 4678b3e + 3e0da49
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>
2026-04-27 12:06:09 +02:00
Erik
d95a8d2a55 refactor(weather): delete legacy camera-attached rain/snow particle emitter
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>
2026-04-27 12:05:12 +02:00
Erik
4678b3ee6b fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather
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>
2026-04-27 12:04:55 +02:00
Erik
a6e7108122 tools(probe): extend RainMeshProbe with sky-surface LUMINOUS audit
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>
2026-04-27 12:04:23 +02:00
Erik
b8e0857b87 tools(probe): add RainMeshProbe — dumps rain mesh surface + polygons + build counts
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>
2026-04-27 08:50:02 +02:00
Erik
3e0da496e0 feat(sky): split SkyRenderer into pre-/post-scene passes + retail -120m weather Z offset
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>
2026-04-27 08:49:42 +02:00
Erik
a2e0bb5e2f Merge branch 'feature/settings-retail' — Phase L.0 Settings interface
Lands the full retail-style Settings interface developed in the
.worktrees/settings-retail worktree. 17 commits delivering:

== Phase L.0 — Settings interface ==

 7665cdf  tabbed Settings shell + IPanelRenderer tab API extension
 382f0ad  Display tab + settings.json persistence layer
 53b1878  Audio tab + live volume sliders driving OpenAL engine
 b7165e5  Gameplay tab — 14 retail CharacterOption-derived toggles
 356b5f2  Chat tab — channel filters + display prefs + font slider
 73749d1  Character tab — per-toon settings; Phase L.0 complete
 fc1e193  wire Display GL knobs + per-toon Character key
 4c75ced  chat Copy mode — read-only multi-line for select + Ctrl+C

== Drag-fix iteration ==

 6273255  first attempt at title-bar-only drag (Begin-level absorber)
 2818fcc  scope drag absorber to BeginChild (fixed Settings tabs)
 df9f2fd  wrap chat panel body in outer BeginChild (fixed chat drag)

== Pre-merge code review fixes ==

 944a036  rescue commit — orphaned FramebufferResize + ResetPanelLayout
          (working-tree changes that never got committed in the cwd
          shenanigans during earlier iteration)
 a37ebde  apply persisted Display + Audio settings without devtools gate
          (settings are runtime state, not devtools state); hide Music
          + Ambient sliders that were inert (R5 MIDI not shipped)
 23aa017  docs/plans/roadmap shipped table updated for K + L.0

== Net delivered ==

 · 6-tab F11 Settings panel: Keybinds (existing) + Display + Audio
   + Gameplay + Chat + Character
 · settings.json at %LOCALAPPDATA%\acdream\ — five sections coexist
   non-destructively, per-toon Character keying
 · Display: Resolution / Fullscreen / VSync / FOV / ShowFps live-wired
   to Silk.NET window + camera FovY + title-bar perf string
 · Audio: Master + SFX volume live-driving OpenAL engine
 · Gameplay/Chat/Character: persist for forthcoming server-sync wiring
 · Chat panel Copy mode (Ctrl+C selectable text)
 · Title-bar-only window drag (BeginChild absorber)
 · FramebufferResize handler — GL viewport + camera aspect + panel
   layout stay in sync on window resize
 · "Reset window layout" View menu item
 · IPanelRenderer extensions: tab API + TextMultilineReadOnly

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core; +87 net new tests
since fork).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:25:06 +02:00
Erik
23aa01738f docs(roadmap): mark Phase K + Phase L.0 shipped
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>
2026-04-27 06:24:24 +02:00
Erik
a37ebdebff fix(ui): pre-merge code review — apply persisted settings without devtools, hide inert sliders
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>
2026-04-27 06:22:35 +02:00
Erik
944a0364c5 fix(ui): commit FramebufferResize + ResetPanelLayout — orphaned during earlier cwd/sed shenanigans
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>
2026-04-27 06:19:12 +02:00
Erik
df9f2fd3da fix(ui): wrap chat panel body in outer BeginChild so drag-trap covers it
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>
2026-04-26 23:10:01 +02:00
Erik
2818fcca8c fix(ui): scope title-bar-only-drag absorber to BeginChild — Settings tabs work
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>
2026-04-26 23:04:10 +02:00
Erik
627325559c fix(ui): title-bar-only drag — absorb body clicks via InvisibleButton
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>
2026-04-26 22:58:05 +02:00
Erik
9567597814 docs(issues): close #26 (stars-as-square) + open #27 (clouds), #28 (aurora)
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>
2026-04-26 22:57:25 +02:00
Erik
7b88fde52d fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)
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>
2026-04-26 22:55:24 +02:00
Erik
991fb9a222 tools(probe): add StarsProbe to dump every SkyObject's geometry + UVs
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>
2026-04-26 22:19:28 +02:00
Erik
b86e77e837 fix(anim): instant-engage Falling cycle on jump start (skip transition link)
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>
2026-04-26 22:16:59 +02:00
Erik
ff504e9ec1 fix(anim): preserve Falling cycle while remote is airborne + reset on land
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>
2026-04-26 22:08:55 +02:00
Erik
0ebf0cad09 fix(net): VectorUpdate parser was reading guid from opcode bytes — remote jumps invisible
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>
2026-04-26 21:51:36 +02:00
Erik
4c75ced92b feat(ui): chat Copy mode — select + Ctrl+C any text in the chat tail
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>
2026-04-26 21:45:39 +02:00
Erik
68d521df4f fix(net): remote jumps were stuck at apex — let physics detect landing instead of UP-driven clear
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>
2026-04-26 21:42:26 +02:00
Erik
8db7a9ec28 docs(research): sky/weather investigation handoff + diagnostic tools
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>
2026-04-26 21:40:34 +02:00
Erik
fc1e1933aa feat(ui): wire Display GL knobs + per-toon Character key — Settings goes live
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>
2026-04-26 21:18:07 +02:00
Erik
a060f4fc98 fix(player): apply AttributeFormula to wire-derived Run/Jump skill — root cause of short jumps
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>
2026-04-26 20:56:38 +02:00
Erik
73749d176a feat(ui): Character tab — per-toon settings; Phase L.0 complete
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>
2026-04-26 18:27:07 +02:00
Erik
4b6fcffa01 fix(camera): pin chase-camera Z to last-grounded while airborne
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.
2026-04-26 18:23:02 +02:00
Erik
356b5f219e feat(ui): Chat tab — channel filters + display prefs + font slider
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>
2026-04-26 18:21:14 +02:00