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>
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>
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>
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>
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>
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.
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>
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>
Remote-player jumps were silently dropped — we never parsed the
VectorUpdate broadcast that carries the jump launch velocity, so
the remote body's Z velocity stayed at 0 and the jump animation
showed without any vertical motion.
ACE Player.cs:954 enqueues GameMessageVectorUpdate (opcode 0xF74E)
on every jump in addition to the bracketing UpdateMotion. Wire
layout (GameMessageVectorUpdate.cs):
u32 opcode (= 0xF74E)
u32 objectGuid
3xf32 velocity (world-space, post-rotation)
3xf32 omega
u16 instanceSequence
u16 vectorSequence
This commit:
1. Adds VectorUpdate.TryParse + VectorUpdated session event.
2. WorldSession.ProcessDatagram dispatches 0xF74E.
3. GameWindow subscribes via OnLiveVectorUpdated:
- Sets remote PhysicsBody.Velocity from the wire vector.
- When velocity.Z > 0.5 m/s, marks the remote as Airborne,
clears Contact + OnWalkable bits, and enables the Gravity
state flag — so calc_acceleration returns (0, 0, -9.8) and
UpdatePhysicsInternal produces a parabolic arc.
4. The per-tick remote update (TickAnimations remote-physics
block) now SKIPS the "force OnWalkable + apply_current_movement"
step when Airborne. Otherwise that path stomps the +Z velocity
each frame — same shape as the bug the local jump hit before
K-fix7.
5. ResolveWithTransition for remotes now passes
isOnGround: !rm.Airborne. Mirrors K-fix7's local-player gate —
airborne resolves must NOT pre-seed the ContactPlane,
otherwise AdjustOffset's snap-to-plane branch zeroes the
upward offset.
6. UpdatePosition handler clears the airborne flag and restores
ground-contact bits, so the server's authoritative re-grounding
ends the arc cleanly at the new ground location.
ACDREAM_DUMP_MOTION=1 logs each VectorUpdate as
"VU guid=0x... vel=(...) airborne=...".
Tests stay 1222 green. Live verification pending — watch a remote
character jump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit, PlayerWeenie used hardcoded ACDREAM_RUN_SKILL
(default 200) and ACDREAM_JUMP_SKILL (default 300) regardless of
the actual character skill. PlayerDescription's skill table HAS
been parsed since Phase H, but the values weren't plumbed into
PlayerMovementController, so a high-Jump character still got the
3-4m default arc instead of their real 5m+ arc, and a low-Jump
character got too much.
GameEventWiring.WireAll gains an optional `onSkillsUpdated`
callback. The PlayerDescription handler scans the parsed skill
table for SkillId 24 (Run) and SkillId 22 (Jump) — ACE Skill enum
ordinals from references/ACE/.../Enum/Skill.cs:11-37 — and fires
the callback with `init + ranks` for each (the holtburger-named
"init" field is the attribute-derived initial component, ranks
is XP-bought additions; closest sane approximation of ACE's
CreatureSkill.Current short of porting Aug + Multiplier + Vitae
chains).
GameWindow stores the most recent values in _lastSeenRunSkill /
_lastSeenJumpSkill and pushes them into the controller at two
points:
* Immediately if _playerController already exists (PD arriving
mid-session, e.g. after a relog).
* Inside EnterPlayerModeNow when constructing a fresh
controller (the auto-entry path: PD always arrives at login
before auto-entry fires, so this is the normal path).
Both sites also log "applied server skills run=X jump=Y" so live
testing can confirm the right values reached the formula.
Console output (ACDREAM_DUMP_VITALS=1) gains a "vitals: PD-skills
run=X jump=Y" line on every PlayerDescription with skill data.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live diagnostic (extent=1.000, vz=9.09 — formula peak 4.21m) showed
the body's Velocity.Z stayed at ~9 m/s but Position.Z never
advanced past 66.000 even after 575 frames airborne. The collision
resolver was snapping the player back to ground every step.
Root cause: PhysicsEngine.ResolveWithTransition unconditionally
pre-seeded the Transition's CollisionInfo from body.ContactPlane
before each resolve (a slope-walking continuity hack). Once
airborne, that pre-seed makes Transition.CollisionInfo's
ContactPlaneValid stay true. Then in AdjustOffset's "Have a contact
plane" path, when collisionAngle > 0 (offset moving AWAY from the
plane = jumping up), the code calls Plane::snap_to_plane on the
offset which ZEROES the Z component for flat ground (Normal.Z=1,
plane.D=0 → snap_to_plane sets vec.z = 0). The horizontal X/Y
parts of the offset survived; vertical Z was destroyed every step.
Position.Z only ever got the gravity drift back down, so the
"jump" was literally a sub-frame upward blip followed by 575
frames of stuck-at-ground while gravity ate vz.
Retail's CTransition::init at retail address 0x509dd0
(named-retail line 271954) explicitly sets
contact_plane_valid = 0 at the start of every transition resolve.
ValidateWalkable then re-establishes it during the sweep when
the foot sphere bottom is within EPSILON of the terrain plane —
so for grounded motion the plane is set fresh per frame, and for
airborne motion no plane interferes.
Fix: only seed the contact plane when isOnGround is true.
Airborne resolves now start with no plane, so AdjustOffset
preserves the upward Z and the integrator's positional update
actually lands. Slope-walking continuity is preserved because
the seed still fires whenever the body is grounded.
Diagnostic logging stripped after the fix.
Tests stay 1222 green. Live verification pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two animation/movement issues from live verification:
1. Walk-backward leg twitches forward two times before the cycle
reverses (X key glitch).
Root cause: AnimationSequencer.GetLink only implemented the
forward-direction lookup path. ACE's MotionTable.get_link
(MotionTable.cs:395-426) takes BOTH the substate and the new
motion's speeds, and switches lookup branches when EITHER speed
is negative:
* Forward path: Links[(style<<16) | substate][motion]
* Reversed path (any negative speed): Links[(style<<16) |
motion][substate]
For Ready → WalkBackward we adjust_motion to WalkForward at
speed -0.65 (negative). Our previous code looked up
Links[Ready][WalkForward] — the "start walking forward"
transition. Played in reverse, the cursor stranded at the
wrong cycle frame and produced the user-visible "left leg
twitches forward two times" before the cycle stabilized.
With the reversed key Links[WalkForward][Ready] (the "stop
walking → ready" anim) played at the cycle's negative speed,
the link smoothly transitions Ready → start-of-cycle, then
the cycle reverses cleanly.
GetLink signature changed from (style, fromMotion, toMotion)
to (style, substate, substateSpeed, motion, speed). Both
call sites updated: SetCycle passes CurrentSpeedMod +
adjustedSpeed; the Action-overlay path passes 1f, 1f
(action overlays are always forward).
2. Jump too low.
Two changes after deep investigation in named-retail decomp:
a) Charge rate sped up from 1.0/s → 2.0/s. Retail's PowerBar
charge constant is illegible in the named decomp (the
divisor was clobbered in GetPowerBarLevel's FPU stack
reordering at 0x0056ade0). 2.0/s (full charge in 0.5s)
matches retail muscle memory better — a tap gives a
noticeable hop, half-hold a meaningful jump, full-hold
the maximum.
b) Default jumpSkill bumped 200 → 300. Retail formula:
height = (skill / (skill + 1300)) × 22.2 + 0.05
At extent=1.0:
skill=200 → 3.01m max (felt too low)
skill=300 → 4.21m max (closer to retail mid-tier "I
can clear that fence" hop)
Override via ACDREAM_JUMP_SKILL env var.
Long-term fix is issue #7 — parsing PlayerDescription's
skill block to apply the server's authoritative skill
values. Until then, this default is the right baseline.
(Velocity formula sqrt(height × 19.6) is unchanged and
matches retail byte-for-byte; we only changed how much
extent-feeding skill we default to.)
Tests stay 1222 green. The walk-backward fix has no new test
because GetLink is private; the cycle-transition behavior
will be exercised live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: when running backward (X) or strafing (Z/C) at run
speed, the visual moves faster but the animation cycle continues
playing at walk pace, looking disjointed.
Root cause: GameWindow's player-anim driver fed the sequencer's
SetCycle speed from result.ForwardSpeed, but PlayerMovementController
intentionally pins ForwardSpeed = 1.0 for WalkBackward (ACE expects
this for the auto-upgrade) and SidestepSpeed isn't used by the anim
path at all. So Forward+Run played the RunForward cycle at runRate ×
(correct), but Backward+Run + Strafe+Run used speedMod = 1.0 even
though the body was moving at runRate × velocity.
Fix: split the visual-pacing field from the wire-correctness field.
Added MovementResult.LocalAnimationSpeed — runRate when any
directional input is held with Run, else 1.0. GameWindow's
SetCycle path now uses this instead of ForwardSpeed. The wire
output stays unchanged; only the local animation cycle pace
shifts.
Effect:
- Forward+Run: runRate × cycle pace (unchanged behavior).
- Backward+Run: runRate × cycle pace (was 1×; now matches
velocity).
- Strafe+Run: runRate × cycle pace (was 1×; now matches
velocity).
- Anything not in Run: 1× (unchanged).
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported "I can't turn off collision wires" — root cause was
two-fold: the wires defaulted to ON at startup so the user saw
them every launch, and the DebugPanel's keybind cheat-sheet
still listed the pre-K.1c retail-default-conflicting bindings
(F1/F2/F3/F7/F8/F9/F10 etc.) instead of the Ctrl+F* aliases the
retail-faithful keymap moved them to.
Changes:
- _debugCollisionVisible defaults to FALSE. Ctrl+F2 toggles it
on (toast: "Collision wireframes ON"); the DebugPanel →
Diagnostics → "Toggle collision wires" button toggles too.
- DebugPanel "Help" cheat-sheet rebuilt to reflect the actual
retail-default + Phase K bindings: Ctrl+F1 (debug), Ctrl+F2
(collision wires), Ctrl+Shift+F (free-fly), F11 (Settings),
W/X = forward/back, A/D = turn, Z/C = strafe, Q = autorun
toggle, Shift = walk modifier, Y/G/H/B = postures,
Hold MMB = instant mouse-look, Hold RMB = orbit, Tab =
focus chat input. The user no longer has to read the source
to find a working binding.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four issues from the K-fix2 launch (2026-04-26 user report):
1. Can't return from free-fly to player view.
CameraController.ToggleFly only swaps Fly↔Orbit, so a user who
flew out of player mode landed in orbit (Holtburg) on
toggle-back instead of the chase camera. Added
ToggleFlyOrChase() helper that prefers Fly→Chase /
Chase→Fly when _playerMode is true and a chase camera is
available; falls back to the original Fly↔Orbit toggle for
offline / pre-login flows. Wired into all three free-fly
entry points: keyboard shortcut (Ctrl+Shift+F), Camera menu
item, and DebugPanel button.
2. Shift while moving STOPS instead of dropping to walk.
Root cause: InputDispatcher.IsChordHeld required
_keyboard.CurrentModifiers to match chord.Modifiers EXACTLY.
So with W bound as (W, None), holding W and then pressing
Shift made CurrentModifiers=Shift mismatch chord (None) →
IsActionHeld(MovementForward) returned false → Forward flag
dropped → player stopped. Fixed by relaxing IsChordHeld:
when chord.Modifiers is None, Shift is allowed to coexist
(it's the retail walk-modifier). Other modifiers
(Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
distinct chord from W.
+2 tests pinning the new permissive-Shift / strict-Ctrl
semantics.
3. Backwards too slow when running.
forwardCmdSpeed for the WalkBackward branch was hardcoded
to 1.0; localY was hardcoded to -(WalkAnimSpeed * 0.65).
Neither honored input.Run. With Run=true (default),
backward now scales by runRate (~2.4×) so X = "run
backwards" matches the forward run pace × the 0.65
backward animation cycle ratio.
4. Strafe too slow when running.
localX for SideStepLeft / SideStepRight was hardcoded to
±SidestepAnimSpeed regardless of Run. Same fix: when Run
is held, scale by runRate so strafe at default speed
matches the run-forward pace.
Tests: 1220 → 1222 (the two new IsChordHeld tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from the K-fix1 launch (2026-04-26 user report):
1. Mouse pointer invisible after login.
Root cause: CameraController.EnterChaseMode invokes
ModeChanged?.Invoke(IsChaseMode) — passing TRUE when chase
becomes active. The OnCameraModeChanged handler interpreted
that bool as `isFlyMode`, so chase entry wrongly triggered
the Raw cursor branch (raw = invisible pointer). The bool is
unreliable: ToggleFly passes IsFlyMode, ExitChaseMode passes
IsFlyMode, but EnterChaseMode passes IsChaseMode. Read the
controller state directly inside the handler instead — fly
mode IS the only state that needs Raw, everything else stays
Normal so the user can click panels / future selectables.
2. No way to enter free-fly mode.
The DebugPanel already had a "Toggle Free-Fly Mode" button
wired in K.2, but the user didn't know to look there. Added
two more discovery paths:
- Keyboard shortcut: Ctrl+Shift+F → AcdreamToggleFlyMode
in RetailDefaults() (retail leaves Ctrl+Shift+F unbound;
Ctrl+F is unused too, so this is conflict-free).
- View → Camera submenu in the ImGui MainMenuBar with a
"Enter / Exit Free-Fly Mode" entry whose label flips with
the active state. Shortcut hint shows "Ctrl+Shift+F".
The keyboard handler now also cancels _playerModeAutoEntry on
manual fly toggle (matches the DebugPanel button + new menu
entry — user's choice wins, the chase camera doesn't snap on
top of the fly camera mid-inspection).
Also corrected the View → Debug menu shortcut hint (was "F1",
actual binding is Ctrl+F1 since K.1c).
Tests still 1220 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four issues from K.3 live verification (2026-04-26 user report):
1. Default movement speed should be RUN, not walk.
PlayerMovementController.MovementInput.Run was sourced from
IsActionHeld(MovementRunLock) (Q held). Inverted to
!IsActionHeld(MovementWalkMode) (Shift held = walk; default = run).
Also fixed RetailDefaults() — MovementWalkMode was bound to
(ShiftLeft, ModifierMask.None), but when LShift IS the primary
key the OS keyboard reports CurrentModifiers=Shift and the
chord lookup mismatches. Bind both LShift+Shift and RShift+Shift
to match (the same fix AcdreamCurrentDefaults already had).
2. Q is autorun TOGGLE, not hold-to-run. Added _autoRunActive
field; OnInputAction toggles it on MovementRunLock Press;
MovementInput.Forward now ORs in _autoRunActive so autorun
stays latched until canceled. Pressing Backup / Stop /
StrafeLeft / StrafeRight clears the latch (deliberate movement
wins, retail-faithful). Pressing Forward AGAIN does NOT cancel —
matches retail's stack semantics.
3. Mouse cursor visible by default in chase mode + no Y-axis
steering without an explicit hold input. OnCameraModeChanged
now uses CursorMode.Normal for chase (was Raw — invisible
pointer). MouseMove handler's "neither RMB nor MMB held"
branch dropped its AdjustPitch call — pitch is gated to
deliberate hold inputs only. Fly mode keeps Raw (continuous
look-and-fly affordance).
Restored AcdreamRmbOrbitHold binding in RetailDefaults() —
K.1c silently dropped it when SelectRight took the RMB Press
slot; the Hold-type binding coexists with Press so RMB orbit
still works in addition to (future) SelectRight click.
4. Holtburg flashes briefly at live login. Added
IsLiveModeWaitingForLogin gate (true iff ACDREAM_LIVE=1 AND
chase camera has not yet been entered) that:
* suppresses StreamingController.Tick in OnUpdate so no
landblocks load around the hardcoded startup center
0xA9B4 (Holtburg);
* skips terrain + entity rendering in OnRender via a
SkipWorldGeometry label after the sky pass.
Sky still draws so the user sees a live, time-of-day-correct
sky during the connection / character-list / EnterWorld
handshake. Latches off once chase mode has been entered, so
later fly-mode toggles render the world normally.
Tests still 1220 green.
Also commits .gitignore tmp/ rule (left over from K.3
session) — gitignored per-session scratch (commit message
drafts, ad-hoc temp files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase K final commit. Settings panel with click-to-rebind UX on top of
the K.1+K.2 input architecture, plus the roadmap / ISSUES / memory
updates that retire Phase K.
InputDispatcher gains BeginCapture / CancelCapture / IsCapturing /
SetBindings — modal capture suppresses normal action firing for the
next chord. Esc cancels (returns sentinel default chord); modifier-only
keys don't complete capture; non-modifier key down with current
modifier mask completes.
IPanelRenderer + ImGuiPanelRenderer + FakePanelRenderer gain
BeginMainMenuBar / EndMainMenuBar / BeginMenu / EndMenu / MenuItem
primitives.
SettingsVM owns a draft copy of KeyBindings with explicit Save /
Cancel / Reset semantics. Click-to-rebind enters dispatcher capture
mode; on chord captured, conflict-detect against draft (excluding the
action being rebound itself); surface a ConflictPrompt when the chord
collides; ResolveConflict(replace=true|false) commits or reverts.
ResetActionToDefault restores a single action to RetailDefaults();
ResetAllToDefaults rebuilds the entire draft. Save invokes the
onSave callback (which writes JSON + swaps the live dispatcher's
bindings).
SettingsPanel renders 8 retail-keymap-categorized CollapsingHeader
sections (Movement, Postures, Camera, Combat, UI panels, Chat,
Hotbar, Emotes). Per action: name + current binding(s) summary +
"Rebind"/"Reset" buttons. Conflict prompt at the top when pending.
Save / Cancel / "Reset all to retail defaults" at the top.
GameWindow registers SettingsPanel + wires F11 →
ToggleOptionsPanel → IsVisible toggle, plus a top-of-frame ImGui
MainMenuBar with View → Settings/Vitals/Chat/Debug entries (calls
ImGui directly — the abstraction methods exist for backend
portability but the host doesn't own a menu-bar surface).
Tests: +37 across InputDispatcherCaptureTests (7),
IPanelRendererMainMenuBarTests (9), SettingsVMTests (13),
SettingsPanelTests (8). Solution total 1220 green.
Roadmap (docs/plans/2026-04-11-roadmap.md) appends Phase K shipped
section after Phase J with K.1a–K.3 commit SHAs. ISSUES.md files
Phase L deferred work as #L.1–#L.8 (hotbar UI, spellbook favorites,
combat-mode dispatch, F-key panels, floating chat windows, UI layout
save/load, joystick bindings, plugin input subscription) and adds
#21–#25 to Recently closed. project_input_pipeline.md updated to
shipped state. CLAUDE.md gets an input-pipeline reference.
Closes Phase K.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five changes:
1. PlayerModeAutoEntry — testable guard class that fires once after
EnterWorld + WorldSession.State.InWorld + player entity present +
PlayerController.State == InWorld. GameWindow arms the entry
after EnterWorld; per-frame Tick checks all four guards and
invokes the same fly-to-player transition the Tab handler runs.
User-initiated fly toggle (DebugPanel button) Cancel()s pending
entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit
stays default for testing.
2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to
InputAction.CameraInstantMouseLook. GameWindow subscribes:
- Press: hide cursor, capture position, _mouseLookActive = true.
- Release: restore cursor, deactivate.
- WantCaptureMouse=true while held → suspend (release cursor).
- MouseMove while active: combined drive — chase camera yaw +
character heading move together (retail's signature mouse-look
behavior). Camera Y still pitches camera-only.
3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode
action delegate — replaces the F-key as the primary discovery
path for free-fly. Gated on DevToolsEnabled.
4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere
primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls
_chatPanel.FocusInput() so Tab moves focus to the chat input
field. Replaces the K.1c TODO stub.
5. WantCaptureMouse gating reinforcement on surviving mouse handlers
(no new code; verified intact from K.1b).
21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel
focus). 1183 total green. 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the parallel direct keyboard/mouse polling that K.1a left in
GameWindow alongside the new dispatcher. Now every input flows
through InputDispatcher; legacy IsKeyPressed/KeyDown/MouseDown/MouseUp/
Scroll handlers in GameWindow are deleted (~220-line refactor).
Bindings remain acdream-current (W/S/A/D/Z/X movement, Shift run,
F-key debug surface). K.1c flips them to retail.
Pieces:
- InputDispatcher.IsActionHeld(InputAction): per-frame held-state
query for movement (W/X/A/D/Z/X/Shift/Space) so PlayerMovement-
Controller can read action state without polling raw keys.
Internally walks all bindings for the action; chord match
requires modifier mask exactness.
- InputAction adds AcdreamRmbOrbitHold (Hold-activation, RMB held
drives chase-camera orbit) and AcdreamFlyDown (Ctrl held in fly
mode for descent).
- GameWindow OnInputAction subscriber replaces the entire KeyDown
switch + per-mouse-button handlers. Single dispatcher event drives:
- F1 AcdreamToggleDebugPanel
- F2 AcdreamToggleCollisionWires
- F3 AcdreamDumpNearby
- F7 AcdreamCycleTimeOfDay
- F8 AcdreamSensitivityDown
- F9 AcdreamSensitivityUp
- F10 AcdreamCycleWeather
- F AcdreamToggleFlyMode
- Tab AcdreamTogglePlayerMode (player/fly toggle - K.1c will
reassign this to ToggleChatEntry)
- Esc EscapeKey (cancel fly mode etc.)
- Mouse wheel ScrollUp/ScrollDown (camera zoom)
- RMB held (Hold) drives orbit; LMB drag still drives orbit
camera; mouse position handled by surviving MouseMove handler
which is gated on ImGui WantCaptureMouse.
- MovementInput per-frame: reads from _inputDispatcher.IsActionHeld.
MouseDeltaX hardcoded to 0f (mouse never drives character yaw).
_playerMouseDeltaX field stays defined for chase-camera RMB-orbit
but is never consumed by movement.
- WantCaptureMouse explicit gate at the top of every surviving mouse
handler in GameWindow (defense in depth - dispatcher already gates
via IMouseSource.WantCaptureMouse).
Movement-input boundary preserved: PlayerMovementController.Update
still takes the same MovementInput struct. Existing
PlayerMovementControllerTests continue green - no regression in
motion-command byte production.
Two deviations:
1. Scroll lost magnitude going through the dispatcher (fixed-step
zoom). Acceptable - discrete wheel-tick matches retail feel
anyway.
2. Movement chords are duplicated with both ModifierMask.None and
ModifierMask.Shift (covering "shift held to run while walking
forward" etc.) so the dispatcher's modifier-strict matching
preserves the modifier-blind feel of the old IsKeyPressed
polling. Will be reshaped cleanly in K.1c when retail's
walk-modifier semantics flip (default = run, shift held = walk).
15 new tests:
- InputDispatcherIsActionHeldTests: 7 cases covering chord-held +
release + modifier-mismatch + multi-binding-for-action.
- InputDispatcherTests: 3 scroll-action cases.
- DispatcherToMovementIntegrationTests (Core.Tests): 5 cases
proving FakeKeyboardSource.Press(W) -> dispatcher.IsActionHeld ->
MovementInput.Forward -> PlayerMovementController produces the
expected motion-command bytes. Includes the regression-prevention
test that mouse-X delta value (zero vs nonzero) doesn't affect
the motion bytes.
Solution total: 1133 green (243 Core.Net + 225 UI + 665 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the abstraction without changing user-visible behavior.
Existing keyboard/mouse handlers in GameWindow continue working
unchanged. The new InputDispatcher runs alongside, fires
InputAction events, and a diagnostic Console.WriteLine subscriber
proves the path is observable. K.1b cuts the existing handlers
over; K.1c flips bindings to retail.
New types in src/AcDream.UI.Abstractions/Input/:
- InputAction enum (~110 actions, doc-grouped by retail keymap
category: MovementCommands, ItemSelectionCommands, UICommands,
QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse
selection, plus Acdream-specific debug actions for the existing
F-key behaviors)
- KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device)
- ModifierMask [Flags] enum matching retail keymap bit values
(Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08)
- ActivationType enum (Press, Release, Hold, DoubleClick, Analog)
- Binding record (chord -> action -> activation)
- InputScope enum with stack semantics (Always at bottom, Game on
top during normal play; Chat / EditField / Dialog / MeleeCombat /
MissileCombat / MagicCombat / Camera push as transient overlays)
- KeyBindings collection class with Find / ForAction / Add / Remove.
AcdreamCurrentDefaults() factory matches today's hardcoded binds
(W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a
doesn't change behavior. RetailDefaults() is K.1c's job; for now
it returns the same map.
- IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping
Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard
flags so the dispatcher can gate per ImGui state.
- InputDispatcher: multicast event Fired<InputAction, ActivationType>;
scope stack with PushScope/PopScope/ActiveScope; per-frame Tick()
fires Hold-type bindings for currently-held chords; mouse buttons
encoded as KeyChord with Device=1.
New adapters in src/AcDream.App/Input/:
- SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state
- SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture
flags for both keyboard and mouse
GameWindow.cs:
- Constructs adapters + dispatcher in OnLoad
- Subscribes to dispatcher.Fired with diagnostic Console.WriteLine
("[input] {action} {activation}") so the path is observable in
launch.log without touching any actual game state
- Calls _inputDispatcher.Tick() per frame in OnUpdate
- Existing IsKeyPressed and event handlers unchanged
Memory crib at memory/project_input_pipeline.md describes the five
layers (Silk events -> Source interfaces -> Dispatcher -> Action
events -> Subscribers) with file paths + scope semantics + the K.1c
retail-defaults plan. Indexed in MEMORY.md.
Two deviations from plan, both documented:
1. InputDispatcher placed in UI.Abstractions/Input/ rather than
App/Input/ - it has no Silk dependencies (uses only the test-
fakeable interfaces) and the test fakes live in
UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk
adapters + GameWindow wiring stay in App.
2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse
(the dispatcher needs both at the same point).
34 new tests covering KeyChord equality, ModifierMask flags,
KeyBindings lookup, dispatcher chord matching with modifier
mismatch rejection, Hold-type Press/Release transitions, Tick()
firing held bindings, scope stack push/pop with mismatched-pop
throwing, WantCapture* gating.
Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-Phase K research artifact. Captures the AC retail default keymap
in two complementary forms so the upcoming InputAction enum + retail
preset (Phase K.1c) can be built byte-precise.
- docs/research/named-retail/retail-default.keymap.txt — verbatim
copy of the user's test.keymap from
~/Documents/Asheron's Call/. Human-readable text format with
every binding categorized: MovementCommands (W/X/A/D/Z/C/Q/Space/
LShift/S + Y/G/H/B postures), ItemSelectionCommands (F/T/P + 18
punctuation keys for compass/item/monster/player/fellow targeting),
UICommands (F1-F12 panel toggles, R=USE, E=Examine, Esc=close,
Shift+Esc=Logout), QuickslotCommands (1-9 + Ctrl/Alt variants for
hotbar pages), Combat / MeleeCombat / MissileCombat / MagicCombat
(mode-dependent Insert/PgUp/Delete/End/PgDn), Emotes
(U=Cry, I=Laugh, J=Wave, O=Cheer, K=Point), CameraControls (numpad
cluster), MouseCommands, ScrollableControls, EditControls,
CopyAndPasteControls, DialogBoxes. 346 lines.
- docs/research/named-retail/keymap-default.txt — binary dump of
the gmDefaultMap MasterInputMap from client_portal.dat at file id
0x14000000. Decoded via the new tools/dump-keymap utility:
scancodes + modifier flags + action IDs + activation phase per
context. Confirms the text file's bindings against the dat-shipped
default. Cross-referenced against
acclient_2013_pseudo_c.txt:405510 (ACCmdInterp::OnAction) for the
movement dispatch logic and :365889 (CPlayerSystem::OnAction) for
the targeting dispatch.
- tools/dump-keymap/ — dotnet console tool referencing
references/DatReaderWriter. Reads MasterInputMap entries from a
dat directory + emits human-readable per-context binding tables.
Reusable for future custom keymap analysis. Run with:
dotnet run --project tools/dump-keymap/dump-keymap.csproj -c Release
Default dat dir is %USERPROFILE%/Documents/Asheron's Call.
Foundation for Phase K — control system overhaul. Plan documented at
~/.claude/plans/ticklish-conjuring-cake.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.
Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
renders a local "[System] Unknown command: /foo. Type /help for
the list." line and is NEVER published to the bus. No SendChatCmd,
no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
/patron /vassals /monarch /covassals /fellowship /lookingforgroup
/trade /roleplay /society /olthoi /help /clear /framerate /loc
and friends) still flow through ChatInputParser.Parse → SendChatCmd
exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
server-side and replies via SystemChat ("Unknown command: foo")
per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
verbs (Phase J Tier 1) and the @-passthrough fallthrough for
unknown verbs both still apply.
ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
table. Used by ChatPanel to discriminate "unknown verb" from
"known verb with bad args".
- GetVerbToken(string command): public alias of the existing
ExtractVerb so callers can pull the first whitespace token without
reproducing the helper.
Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.
19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
covering /foo, /ls, /mp <path>, /genio, and bare /.
Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.
Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the chat input field disappearing when the chat
window was resized smaller — older entries pushed it past the
visible area. Standard ImGui chat-window pattern fixes it: scrollable
nested region for the chat tail, fixed footer for the
separator + input field below it.
IPanelRenderer extensions (Phase J Tier 3):
- BeginChild(string id, Vector2 size, bool border = false) — opens
a nested scrollable region. Size follows ImGui semantics:
0 = fill available, negative = fill available minus this much.
- EndChild() — closes the nested region.
- FrameHeightWithSpacing() — single-line widget height incl. frame
padding + item spacing. Lets panels compute footer reservations
without hardcoding pixel constants.
- SetScrollHereY(float ratio) — forces scroll within current region;
pass 1.0f to keep the latest line visible after new entries
arrive.
ImGuiPanelRenderer impls. ImGui.NET's BeginChild signature changed
across versions (third arg moved from `bool border` to
`ImGuiChildFlags`); we cast a numeric literal (0x01 = Border bit)
to sidestep the rename. FrameHeightWithSpacing maps to
ImGui.GetFrameHeightWithSpacing(); SetScrollHereY to ImGui.SetScrollHereY.
ChatPanel restructured:
- Reserves footer height = FrameHeightWithSpacing() + 6f (small pad
for the separator above the input).
- Wraps the chat tail in BeginChild("##chattail", (0, -footer))
so the inner region scrolls independently of the window.
- Tracks _lastRenderedCount across frames and calls SetScrollHereY(1f)
only when new entries appended — manual scroll-up isn't fought
against; new messages jump the view back down only when they
actually arrive.
- Header Separator removed (the BeginChild border is enough).
FakePanelRenderer extended with the four new methods + recording.
4 new tests in ChatPanelLayoutTests pin the layout invariants:
- Render order: Begin → BeginChild → ... → EndChild → Separator
→ InputTextSubmit → End.
- BeginChild size has X=0 + negative Y at least matching the
injected FrameHeightWithSpacingValue.
- SetScrollHereY fires when entries grow.
- SetScrollHereY does NOT fire when entries don't grow.
Solution total: 1067 green (243 Core.Net + 164 UI + 660 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-tier rollout per the 2026-04-25 retail @help dump showing the
full ACE command surface. Tier 1 + most of Tier 2 in one commit.
TIER 1 - @ as / equivalent
ACE accepts both / and @ as verb prefixes (per its own help text:
"Note: You may substitute a forward slash (/) for the at symbol
(@)."). ChatInputParser now normalises @ to / for the verb-match
phase and re-enters parsing. Critical: for verbs we don't recognise
(@acehelp, @tele, @die, @version, @loc-on-server, @nonsense, ...),
the original @ is kept in the message text so ACE's CommandManager
intercepts the message server-side. If we substituted / there too,
ACE would treat it as plain Talk and broadcast it.
Result: @a hi / @tell Bob hi / @help / @clear / @reply / @retell
all route exactly like their / counterparts. @acehelp / @tele /
@version / @die etc. pass through to the server intact.
TIER 2 - client-only commands
- /retell <msg> (also @retell): resend to the last person you
tell'd. Mirrors retail @retell. ChatVM tracks
LastOutgoingTellTarget on each OnSelfSent(Tell, ...) entry —
SenderGuid==0 distinguishes outgoing echo from inbound whispers,
same way LastIncomingTellSender already worked. ChatInputParser
takes a new optional lastOutgoingTellTarget param.
- /framerate (also @framerate): prints "Framerate: 144.2 FPS"
into chat. Wired via a new ChatVM.FpsProvider Func<float>
callback set by GameWindow at construction (closes over
_lastFps). Falls back to "(provider unavailable)" if no
callback is wired (tests / pre-live).
- /loc (also @loc): prints "Location: (123.4, 567.8, 60.0)" into
chat. Wired via ChatVM.PositionProvider Func<Vector3> closing
over GetDebugPlayerPosition() in GameWindow. ACE has a server-
side @loc too; client wins here (instantaneous + uses the local
interpolated position).
ChatPanel.TryHandleClientCommand grew @ aliases for /help /clear
/framerate /loc and the new EqAny helper for case-insensitive
multi-string matching. Help text rewritten to reference the
/ <-> @ equivalence and point at @acehelp / @acecommands for ACE's
full command list.
TIER 3 - automatic (no code)
Most retail @-commands (@allegiance motd, @afk, @die, @lifestone,
@corpse, @marketplace, @pkarena, @emote/@emotes, @fillcomps,
@permit, @consent, @squelch, @unsquelch, @messagetypes, @age,
@birth, @day, @endurance, @pklite, @version, @filter, @unfilter,
@loadfile, @log, @marketplace, ...) are server-side ACE commands.
Tier 1's passthrough takes care of them automatically — they
arrive via Talk, ACE recognises the @ and intercepts, replies via
SystemChat (which our 0xF7E0 wiring renders as [System] lines).
DEFERRED
- @saveui / @loadui / @lockui: ImGui layout save/load, ~1 hr
standalone task. Filed for follow-up.
- @title <text>: rename chat window. ImGui window-id complications.
- Toggle-style @framerate (FPS overlay on/off): print-once is
simpler and matches retail's most-common usage.
30 new tests:
- ChatInputParserAtPrefixTests: 11 covering @-prefix recognition,
unknown-@ passthrough, /retell and @retell.
- ChatVMRetellAndProvidersTests: 8 covering LastOutgoingTellTarget
tracking, FpsProvider/PositionProvider callbacks, no-provider
fallback.
- ChatPanelInputTests: +3 (/framerate, @loc, @acehelp passthrough).
Solution total: 1063 green (243 Core.Net + 160 UI + 660 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase J follow-up after a 2026-04-25 trace where typing /help
produced two identical "Unknown command: help" lines (ACE fires the
text via both GameMessageSystemChat 0xF7E0 and a paired
CommunicationTransientString 0x02EB), and the server's WeenieError
0x0026 trailer rendered cryptically as "WeenieError 0x0026".
Three small changes:
1. WeenieErrorMessages: add 0x0026 ThatIsNotAValidCommand ->
"That is not a valid command." Plus 0x0414 / 0x050F that Phase J
already added are now covered by tests too.
2. ChatLog.OnSystemMessage dedup. Track last system text + arrival
time; if a second identical text shows up within 1 second,
suppress. ACE's two-path send (gag warnings, command errors,
etc.) collapses to a single chat line. Long bursts of repeated
text still skip the duplicates without resetting the timer.
3. Client-side /help and /clear in ChatPanel. Intercepted BEFORE
the parser passes to the server bus:
- /help, /?, /h (case-insensitive) -> render local cheat-sheet
listing acdream's slash prefixes via ChatLog.OnSystemMessage.
Avoids the round-trip to ACE that produced the duplicate
"Unknown command: help" lines AND gives users discoverability.
- /clear, /cls -> drains the chat log so the panel starts empty.
New ChatVM.ShowSystemMessage() + ChatVM.Clear() expose the
minimum surface the panel needs to dispatch client-only feedback
without coupling the panel to ChatLog directly.
12 new tests:
- 3 WeenieErrorMessages template adds (0x0026 / 0x0414 / 0x050F).
- 4 ChatLog dedup cases (immediate dup, different text, triplet,
bookended-by-different-text).
- 5 ChatPanel client-command cases (/help, 3 alias variants,
/clear).
Solution total: 1033 green (243 Core.Net + 130 UI + 660 Core),
0 warnings.
Acceptance: type /help in chat -> local help banner appears, no
server round-trip, no "Unknown command: help" duplicates. Type
/clear -> chat tail empty. Welcome banner + WeenieError-templated
"You are not in an allegiance!" / "You do not belong to a
Fellowship." continue rendering once each.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six fixes from the 2026-04-25 live verify session.
1. ServerMessage (0xF7E0) wired to ChatLog. ACE's
GameMessageSystemChat - used for the login banner "Welcome to
Asheron's Call ... powered by ACEmulator ... type @acehelp" plus
any future server broadcast - rides opcode 0xF7E0. The parser
shipped in I.5 but the WorldSession.ServerMessageReceived event
was never subscribed by GameWindow, so the welcome line was
silently dropped. Subscribed now; same wave wires the missing
EmoteHeard / SoulEmoteHeard / PlayerKilledReceived events that
I.5 also left orphan.
2. Drop optimistic /say echo + plumb local-player-guid into ChatLog.
ACE's HandleActionTalk broadcasts a HearSpeech back to the sender
too, so we were double-printing every /say (own optimistic +
server echo). New ChatLog.SetLocalPlayerGuid() pushes the chosen
character guid in (mirrors VitalsVM pattern); OnLocalSpeech
detects own-guid match and substitutes Sender="" so the formatter
's IsOwnSpeaker path renders "You say, ..." instead of
"+Acdream says, ...". Single line per /say.
3. IsOwnSpeaker check now applies to ChatKind.Channel too. Empty/
"You" sender -> "[Allegiance] You say, \"text\"" instead of the
"[Allegiance] says, \"text\"" double-space hole that Phase I.6's
OnSelfSent left when echoing legacy ChatChannel sends.
4. Long-form slash aliases: /general /allegiance /patron /vassals
/monarch /covassals /fellowship /fellow /lookingforgroup
/roleplay /rp /tr /gen, plus /s as alias for /say. Retail muscle
memory expected these; the prior parser only recognized /g /a /p
/v /m /cv /lfg /role and friends, so "/patron hello" fell
through as /say with the literal "/patron" prefix.
5. WeenieError templates filled in for the codes the user hit:
- 0x0414 YouAreNotInAllegiance -> "You are not in an allegiance!"
- 0x050F YouDoNotBelongToAFellowship -> "You do not belong to a Fellowship."
Replaces the cryptic "WeenieError 0x0414" / "0x050F" lines.
6. @ command pass-through: ACE handles @help / @acehelp / @tele etc.
server-side by intercepting Talk text with @ prefix; the user's
message isn't broadcast and ACE replies via SystemChat. Drop the
optimistic /say echo so the chat shows only the server's response
(the SystemChat wiring from #1 surfaces it as [System] {help}).
Tests:
- 11 long-form-alias Theory cases on ChatInputParser.
- 3 own-guid-substitution cases on ChatLog (own match, different
guid, pre-login fallback).
- Existing PrefixSubstring test refactored to "/genio" since the
previous "/general" stub is now a real verb.
Solution total: 1021 green (243 Core.Net + 125 UI + 653 Core),
0 warnings, 0 errors. +14 tests.
Acceptance: at login, [System] Welcome to Asheron's Call appears.
Single "You say, \"hi\"" per /say. /allegiance with no allegiance
shows [Allegiance] You say, ... + [System] You are not in an
allegiance!. /patron / /vassals / /monarch route correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up fixes from the 2026-04-25 live verify session.
1. CRITICAL: BuildTell wire field order. Our outbound layout was
[target_name, message] but ACE's GameActionTell.Handle reads
[message, target_name] (verified against
references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
/tell since Phase I.3 has been failing with WeenieError 0x052B
(CharacterNotAvailable) because ACE was looking up the message
text as the recipient name. Swapped the field order in
ChatRequests.BuildTell so message is written first; updated the
pinned BuildTell test to expect the corrected layout. The
WorldSessionChatTests round-trip continues to pass since SendTell
delegates to BuildTell.
2. Retail-style FormatEntry. The user asked for the canonical retail
strings:
/say (own): You say, "text"
/say (incoming): Name says, "text"
/tell (own echo): You tell Caith, "text"
/tell (incoming): Caith tells you, "text"
channel: [Trade] +Acdream says, "text"
/shout (own): You shout, "text"
/shout (incoming):Name shouts, "text"
Discriminators: SenderGuid == 0 distinguishes our own outbound
echoes (set by OnSelfSent) from real incoming whispers (carry the
sender's player guid). Sender == "" or "You" distinguishes our own
/say echoes (OnLocalSpeech substitutes "You" when the wire sender
is empty per holtburger client/messages.rs:476-487).
ChatEntry gains a new ChannelName slot so Channel-kind entries
render with the friendly room name ("Trade") instead of "ch 3".
Falls back to "ch {ChannelId}" when ChannelName isn't populated
(legacy ChatChannel inbound or older callers).
3. Suppress optimistic Channel echo. The user saw duplicates per
/trade /lfg in the live trace:
[ch 0] Trade: hello <-- our optimistic
[ch 3] +Acdream: [Trade] hello <-- ACE's TurbineChat broadcast
ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
broadcasts EventSendToRoom to ALL recipients in the room including
the sender, so the canonical echo always arrives via 0xF7DE. Drop
the optimistic OnSelfSent for Turbine kinds in GameWindow's
SendChatCmd handler; trust the server. Legacy ChatChannel paths
(Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
keep the optimistic echo because the legacy 0x0147 broadcast may
not always come back to the sender.
Inbound TurbineChat also stops embedding "[Trade] " into the
message text — passes the friendly name out-of-band via the new
channelName parameter on ChatLog.OnChannelBroadcast.
11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.
Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three post-launch fixes from the 2026-04-25 live verify session.
1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
codes are *informational*, not error-level — the user saw cryptic
"WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
those decode as "You have entered the General channel." and
"Turbine Chat is enabled." per ACE WeenieError(WithString).cs
templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
~30 high-frequency codes to retail-faithful templates with `_`
placeholder substitution. ChatLog.OnWeenieError now routes through
Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
so nothing is silently lost. New codes can be added in 30 seconds
when the user reports one.
2. Tell target eats trailing punctuation. Retail muscle memory is
"/t Name, message" — comma is the separator. Our split-on-whitespace
pulled "Name," (with comma) as the target, server returned 0x052B
"That person is not available now." because no such character.
ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
the target token so "/t Caith, hi" and "/t Caith hi" both work.
Added 7 Theory cases covering each separator + the long-form alias.
3. TurbineChat routing diagnostics. The user's ACE login showed the
"TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
for General/Trade/LFG, confirming TurbineChat IS active server-side.
But outbound /g /trade /lfg might still fall back to legacy
ChatChannel (which the server then rejects). Added diagnostic
Console.WriteLines so the next launch shows:
- "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
(when ACE sends the 0x0295 channel-id table)
- "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
(when SendChatCmd routes a Turbine kind through 0xF7DE)
- "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
(when SendChatCmd uses the legacy 0x0147 path)
- "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
(when neither path can dispatch — usually means ACE didn't send
0x0295 yet and the kind is Turbine-only)
Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
a follow-up commit once the next live trace shows the actual flow.
18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.
Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps Phase I — UI consolidation + complete chat system. All 7 prior
commits (I.1 through I.7 + I.2) are now reflected in the canonical
sources of truth.
- docs/plans/2026-04-11-roadmap.md: new "Phase I — UI consolidation +
complete chat system" section between H and J. 8 sub-pieces all
marked SHIPPED 2026-04-25 with their actual commit SHAs:
I.1 b131514, I.2 56037a4, I.3 8e6e5a0, I.4 f14296c, I.5 ff5ed9e,
I.6 ca968fc, I.7 3d26c8e, I.8 (this commit).
Plus Phase H.1 entry annotated to credit I.4 + I.7 for chat input
+ combat translation. D.5 / D.6 entries cross-link to the new I
surface where relevant. Three Q&A rows added to "When will my
specific complaint be fixed?".
- docs/ISSUES.md: 7 issues filed and closed in the same session
(#14 IPanelRenderer widgets, #15 DebugPanel migration, #16
LiveCommandBus, #17 ChatPanel input, #18 holtburger inbound
parity, #19 TurbineChat, #20 CombatChatTranslator). All in
Recently closed with real commit SHAs.
- CLAUDE.md: surgical update to the UI strategy paragraph (~line 35).
ImGui now hosts ALL dev/debug UI (Vitals + Chat + Debug);
StbTrueTypeSharp DebugOverlay deleted in I.2; TextRenderer +
BitmapFont retained for the future HUD-in-world (D.6); custom
retail-look toolkit (D.2b) remains the long-term retail-look
path while ImGui is the pragmatic D.2a default.
- memory/project_chat_pipeline.md (auto-loaded; in user's claude
project memory tree): new evergreen crib documenting the
ChatLog -> ChatVM -> ChatPanel + LiveCommandBus -> WorldSession
pipeline with the slash-command set + opcode coverage.
- memory/MEMORY.md: indexed line for project_chat_pipeline.
Solution state at end of Phase I:
989 tests green (107 + 639 + 243), 0 warnings, 0 errors.
+124 tests across the phase.
Closes Phase I in roadmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>