Opus dual-lens review of the three VM6 commits (0930c35d, 39e8408c,
6cc5e183) found two blockers and two should-fix issues; all landed here
along with the review's nits and documentation corrections.
Blockers:
- A1: the procedural-scenery classifier tested bit 31 alone instead of
the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched
LandblockStaticEntityIdAllocator's 0xC... namespace (fences/gates/
building shells with a cutout subset), the 0xDA11_D0xx paperdoll id,
and the 0xFFFF_FF01 portal-tunnel id as procedural scenery — all
three would have swayed. ProceduralSceneryIdAllocator.IsInNamespace
now does the exact top-nibble test; FoliageWindClassification
delegates to it.
- A2: GroupKey (the receiver's instance-batching key) did not carry
FoliageFlags while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing a mesh subset coalesced
into one receiver InstanceGroup whose flags were last-writer-wins —
disagreeing with the correctly-keyed caster. GroupKey now carries
FoliageFlags, computed before key construction and set exactly once
at group creation; the imperative re-stamp is gone, and CachedBatch's
now-redundant FoliageFlags field is removed.
Should-fix:
- A3: the world receiver pass bound UniformAtmosphericFrame only by
accident (leftover from the caster pass, which runs first each
frame, since Vulkan binding state isn't reset between passes).
DirectionalShadowFrameBinding now carries the caster's exact
AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds
it explicitly.
- A4: a Setup-composed tree's opaque trunk part never got the trunk
flag because HasCutoutSubset is cached per GfxObj part, not per
entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now
ORs HasCutoutSubset across an entity's resolved sibling parts once
per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches
via a new optional override parameter.
Nits: A5 hashes the per-vertex flutter seed relative to the instance
origin instead of absolute world XY (fp32 sin() precision loss at far
landblock corners), mirrored in both foliage_wind.glsl and
FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as
a deliberate pseudocode divergence; A8 switches FoliageWindExclusions'
construction to ToFrozenSet() and softens the "never stale" doc
comment to "no slower than one frame behind."
Tests added: top-nibble classification (0xFFFFFFFFu now correctly
false), GroupKey inequality across entity-driven scenery/landblock-
static classification, a caster-batch test proving the same pairing
never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end
two-part-Setup tests, the caster→receiver AtmosphericFrame binding
carry-through, flutter-hash translation invariance relative to
instance origin, and a Storm-wind mid-height displacement floor
guarding against a "no motion" regression.
Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits
1 and 2", "all four" caster shaders, and top-nibble wording throughout;
the owner gate checklist's Rain/Storm step; the stale v1-only shader-
interface compatibility entry; semantic-bindings-v1.md's v2 members
folded into the main 192-byte block; the IA-25 register row's top-
nibble wording; AtmosphericFrameInputs.cs's ABI size reference.
foliage_wind.glsl's A5 change recompiled exactly the five shaders that
include it (mesh_atmospheric.vert, the four directional_shadow_world_*
casters) plus the manifest; no other .spv changed.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,041/0 failed (no environment-specific failures this run).
RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0
failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FoliageWindByDayGroup / FoliageWindDayGroupPoint(int ActiveDayGroup, ...)
becomes FoliageWindByWeather / FoliageWindWeatherPoint(string WeatherKind,
...) in AtmospherePolicyDeclaration (Plugin.Abstractions is BCL-only, so
the key is the exact member name of AcDream.Core.World.WeatherKind rather
than the enum itself). The raw activeDayGroup index carries no weather
meaning by itself; WeatherState.cs already classifies each day group's
authored DAT name into one of five real weather kinds, and that fact was
already threaded through AtmosphericFrameInputs.Weather / uAtmosphereWeather.x
— this reuses it instead of guessing an index-to-category mapping.
Built-in table (BuiltInAtmosphericRenderPack.AtmospherePolicy()): Clear
0.25/0.15, Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm
1.00/0.75 — all five WeatherKind members declared, the invented "Cloudy"
row dropped. RenderPackAtmospherePolicyEvaluation.FoliageWind now takes a
WeatherKind and matches by weather.ToString() (ordinal) against each
declared point's name; a kind absent from the table falls back to the
declared Clear row, then to (0,0) if Clear itself is undeclared. The
delta-seconds EMA interpolation (EaseTowardTarget) is unchanged.
AtmosphericPostProcessGraph.ResolveFoliageWind and its two callers
(RenderPostProcess via inputs.Weather; RenderDirectionalShadows via
foundation.Atmosphere.Kind) now pass WeatherKind instead of the day-group
int.
RenderPackValidation.ValidateAtmosphere (runs for every pack declaring an
AtmospherePolicy, not gated to Tier2/shadow packs) now rejects an unknown
or non-exact-case weather-kind name and a repeated kind, mirroring the
existing ActiveDayGroupMultiplier duplicate-key check.
Tests: RenderPackAtmospherePolicyEvaluationTests rewritten for the
kind-keyed API (all five kinds resolve to their declared row, an unlisted
kind falls back to Clear, ordinal exact-case matching, null-table
handling); RenderPackSpirvValidatorTests gains four descriptor-validation
cases (unknown name, wrong case, duplicate kind, the five-kind table
accepted); AtmosphericPostProcessGraphTests' three foliage-wind cases now
select WeatherKind.Storm via `with` instead of an assumed day-group index.
Spot-check (per the coordinator's ask, not changed here): yes —
ActiveDayGroupMultiplier / EvaluateDayGroupPolicy (pre-existing, Campaign
AR/VM3-era — BuiltInAtmosphericRenderPack.AtmospherePolicy()'s three rows
`new ActiveDayGroupMultiplier(0, 1.0), (1, 0.35), (2, 0.20)`) key the
sun-ray/shadow/volumetric day-group strength multiplier by the same raw
activeDayGroup index with an undocumented assumed meaning (0=brightest ...
2=dimmest), the identical class of issue this commit fixes for foliage
wind. Left unchanged per instruction; flagging for the coordinator to file.
Full solution Debug and Release builds green. App hermetic filter
6024/6026 — the same 2 pre-existing failures as VM6a/VM6b. Both were
re-run in isolation per the verification ask: both still fail alone (not
a load-flake in this environment) — confirmed via git stash earlier this
session that both already fail on the unmodified pre-VM6 baseline, so
they are pre-existing and unrelated to this change. Core.Tests hermetic
4697/4697. RenderPackValidator.Tests 30/30. No shader/spv changes in this
commit (pure C#/docs fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.
Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.
Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.
New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.
Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.
Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).
App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes:
two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude,
carry the foliage-wind clock/weather and amplitude inputs VM6b's shader
displacement will read. RenderPackShaderAbi renames the old constant to
AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192),
keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds
ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame
accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte)
shape and rejects anything else naming both — this is why the frozen
external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are
not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer
still reads correctly, since a bound range only needs to be >= the block's
own declared size.
DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too
(both the multiview and per-cascade sites), through a new
AtmosphericFrameBufferBinding the graph owns and supplies via
DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows
builds its own 192-byte ring allocation for this, separate from the world
receiver's frame block, because the caster pass runs before RenderPostProcess
constructs that block within the same frame. The four world caster pipeline
variants (opaque/cutout, base/multiview) are now allowed to declare binding
5 in the validator; terrain casters are untouched.
This commit is plumbing only: the two new members are always written but
never read by any shader yet (zero placeholders), so pack-on and pack-off
output are both pixel-identical to before. VM6b wires the real weather-driven
values and the shader-side displacement.
App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this
change, confirmed against the unmodified baseline). Core.Tests hermetic:
4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests
(retail oracle set): 7/7, byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus narrow re-review of 51178f7c: APPROVE. Residuals closed: the AR plan
no longer says '<=1 LSB' unqualified (99.99% of pixels; 95 foliage-
silhouette pixels up to 73 LSB, 58 isolated); the campaign doc says the
same; AtmosphericColorPipelineTests now read the SHIPPED exposure/vignette
defaults from BuiltInAtmosphericRenderPack.Descriptor and the graph's named
DefaultVignetteStrengthFallback instead of literals.
Measured for the gate (offline Holtburg hillside, High defaults vs pack
off): mean luminance -17% noon, -44% dusk, p95 unchanged, clip 0.06% both;
neutral High vs pack off: 110,561 px at |d|=1, 95 at >=5 (foliage edges).
Evidence images under docs/research/evidence/vm3/.
#422 filed: one High-default offline capture exited with
STATUS_HEAP_CORRUPTION after a clean managed shutdown; 1 in 8 runs, never
under validation layers. VM7 gate item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix round from Opus review of 87677f9c (APPROVE WITH FIXES):
1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_
downsample.frag shader-source pinning test: asserts the exact decode
call count (6 - three in lowFusedScene, three in main's non-fused
branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in
atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline
.DisplayGamma (so shader literal and CPU-tested value cannot drift),
that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted
from AtmosphericColorPipeline.LinearMidGrey, and pins
AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as
exact literals (0.73f / 1f). Also removed a stray trailing "()" from
an existing comment in atmospheric_filmic.frag that was inflating the
decode-call count to 7.
2. (B1) Re-derived the "vignette-strength" default for the linear-light
post stack. The vignette multiply now happens on linear colour before
the final encode, so a corner factor of (1 - strength) displays as
(1 - strength)^(1/2.2), not (1 - strength) directly. The accepted
look was strength 0.12 under the OLD gamma-space pipeline: a 12%
on-screen corner darkening. Under strength 0.12 in the new linear
pipeline that same 0.88 corner multiplier would only display as
0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving
(1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245,
which reproduces the accepted 12% corner darkening. Since
RenderPackSettingValueCodec requires every declared default to be
step-aligned from the minimum and 0.245 is not a multiple of the old
0.01 step, the step also moves to 0.005 (a finer slider, not
coarser) so the exact derived default is a valid grid point -
verified by running the ExternalTierTwoPackCanRenameEveryOwnedId
AndShaderAsset validation test, which failed with "invalid default
value" before this correction. Also updated the matching fallback in
AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for
consistency, and added
AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted
TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88.
3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor ->
AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references,
both in that file - safe). Left LightSource.ColorLinear unrenamed:
grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo,
LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and
several Core tests) across the shared retail default-path lighting
UBO pipeline - renaming it is out of VM3's pack-only scope and would
touch the mandatory-unchanged default path. Added a pointer comment
on the field in LightSource.cs (and a one-line note at its
WorldRenderFrameBuilder.cs call site) documenting the same
display-space-not-linear fact and explaining why the rename is
deferred to its own default-path colour-space pass.
4. (B4) Added a citation beside acesFitted in both atmospheric_filmic
.frag and its C# mirror (AtmosphericColorPipeline.AcesFitted):
Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit
takes linear scene light in and returns linear display light in
[0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 *
decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen.
5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster-
campaign.md with the shipped truth in place of the pre-implementation
guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps
gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too
bright), bloom threshold stays 1.0 (a fixed point of both exponents),
knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added
the old-vs-new curve table at exposure 0.80 across ten gamma inputs.
Replaced the acceptance criteria's "new automated test on the
recording RHI" with the CPU mirror + shader-source pins actually
used, and recorded that the real-frame masked capture WAS run
(retail vs High-with-every-effect-neutral, artifacts/vm3):
independently re-verified by re-running the pixel diff against the
checked-in screenshots - 110,561 px at |delta|=1 and exactly 95
pixels at |delta|>=5, confined to foliage-canopy silhouette edges
against sky with nothing on any ground/building/water surface. Noted
the Stage-1 luminance table re-capture is still owed at the owner
gate.
6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3
summary sentence: the bloom intermediate is already linear after
extraction (no separate "bloom read" decode), and the neutral-preset
claim is now phrased as a measured numerical identity (<=1 LSB on a
real frame) rather than an unqualified "is" statement.
7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used
the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in
both this round and the original VM3 commit - a Vulkan SDK glslc was
detected and its path recorded, but the managed compiler is what
actually ran. Regenerating this round only changed the atmospheric_
filmic frag stage's manifest hash (comment-only edits); the compiled
.spv bytes are unchanged, and every retail-oracle shader
(mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched.
8. Replaced an invented motive in the atmospheric_filmic.frag contrast-
pivot comment ("rounded up for a stronger gamma-space contrast
feel") with the actual reason: the previous 0.5 was simply the [0,1]
midpoint of the standard contrast formula, not a deliberately chosen
value; in linear the perceptual mid-grey is 0.18.
Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests:
5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass
(retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag
manifest hash changed, no .spv bytes changed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.
- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
(pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
display-gamma assumption, deliberately not the sRGB piecewise curve,
which would claim a precision retail's authoring pipeline never had.
uAtmosphereSunColor's comment is corrected from "authored linear rgb"
to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
fused-Low and non-fused paths) decode every world/ray/volumetric read
before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
(it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
and atmospheric_volumetric.frag are documented as writing display-space
colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
(linear mid-grey, the standard 18%-grey-card exposure convention).
The final filmic output is clamped in linear, then re-encoded before
the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
[0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
gives decode(1.0) = 1.0 (threshold unchanged) and
decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
Replaced the inline 0.45f literals with named constants
BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
untouched.
- Exposure stays at its accepted 0.80 default: in linear,
encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
accepted midtone the old gamma-space pipeline produced as 0.51 for the
same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
decode/encode). Highlights now retain more (gamma 0.9 input moves from
~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
(gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
ACES/grade/filmic math (line-for-line, with a header comment requiring
it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
identity within half an 8-bit step for a 0..255 grey sweep (proving the
neutral preset is numerically the pack-off image), decode/encode
round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
drop the colour-space conversions: atmospheric_filmic.frag must
contain exactly one acdreamEncodeDisplay( call in main()'s output,
atmospheric_bloom_downsample.frag must contain at least three
acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
atmospheric_filmic.frag.spv changed in bytes; every other pack shader
that includes atmospheric_common.glsl recompiled to a byte-identical
binary (the new decode/encode helpers are unreferenced dead code for
them). VulkanShaderManifestTests' retail-oracle SHA-256 set
(mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
and added a "Colour space" section to the render-pack ABI doc
(docs/render-packs/semantic-bindings-v1.md) naming which inputs are
display-space and pointing at atmospheric_common.glsl as the
reference implementation. No ABI version bump — the binding layout
is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus narrow re-review of ae651312: APPROVE. Closes its three residuals:
- AP-232 filed: retail's single-pass stage-1 OUTPUT alpha
(MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a
translucent subset; acdream's two-draw model is exact for opaque
subsets (fog identity pinned) and a bounded weight difference on
translucent ones. Distinct from AP-34 (queue order). Owed since
05970306.
- TerrainAtlas.DetailSamplerDescription names the production sampler
(WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280);
the test now asserts that constant's properties instead of a
test-local copy.
- Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED
with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted
+2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:
1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
texture-stage pipeline, so the detail contribution must be fogged, not
just the base. mesh_modern.frag already fogs the base colour
(applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
mesh_detail.frag previously emitted raw detail.rgb, understating fog by
f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
UBO and applyFog function (copied verbatim, same binding/std140/math) and
fogs detail.rgb before emitting it. This collapses algebraically to
retail's fog-after-combine order:
(1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
fogFactor); RetailDetailTextureContractTests pins the identity across 200
random samples within 1e-6.
2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
the 10-50 m positive-view-depth fade" — a stale claim from before VM1
removed the fade. Replaced with the mip-chain attenuation statement that
mesh_detail.vert's header comment already carries.
3. Added the test the VM1 contract required but never had: TerrainAtlas
.TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
registers with the repeat/linear world sampler, not single-level or
clamped. Drives the private method directly (reflection) against a
synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).
4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
(MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
delayed-alpha subset composites with — is not modelled; acdream instead
draws a second pass weighted by detail.a*diffuseAlpha. Identical for
opaque subsets, a bounded difference on translucent building/EnvCell
subsets already covered by the existing AP-34 shared-alpha-queue
divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
citation to name its exact branch (burnedInStaticLights < 0 &&
*(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
but the opaque->1 / fading->opacity mapping still holds either way.
Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.
Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EnvCellRenderer.Rhi's SubmitRhi bound StorageInstances/StorageBatches/
StorageClipSlots/StorageGlobalLights/StorageInstanceLightSets every frame
but never GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO
mesh_modern.vert reads as instanceAlpha[instanceIndex] (vOpacityMultiplier,
#188) and, as of Campaign VM VM1 (05970306), mesh_detail.vert now reads the
same way (vDetailOpacity). Without a bind of its own, both the interior
shell pass and the interior detail replay read whatever section
WbDrawDispatcher's own SubmitRhi last bound in the same pass — an unrelated
object's opacity array, indexed by these EnvCell instance ids.
This predates VM1 (6c79d35c has the same omission on the mesh_modern side);
VM1 must not widen a latent defect by adding a second unconditional reader
of the same unbound slot.
Fix, root cause, no guard: EnvCellRenderer now owns _instanceAlphaData, a
grow-only float[] parallel to _gpuInstanceTransforms (same pattern as
_clipSlotData/_lightSetData), filled with the constant 1.0f every frame —
EnvCell shells have no #188 TransparentPartHook translucency fade (that
mechanism fades object PARTS, never cells) — and bound at
GpuBindingModel.StorageInstanceAlpha alongside the renderer's other
per-frame ring sections, before any draw in the pass.
Test: EnvCellRendererTests.SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass
drives SubmitRhi directly (reflection, mirroring the file's existing
private-method test pattern) with N seeded cell instances and one real
draw command, then asserts against RecordingGpuDevice that
StorageInstanceAlpha is bound with exactly N floats all equal to 1.0f, and
that the bind precedes the pass's first MultiDrawIndexedIndirect call.
Verified failing (StorageInstanceAlpha was never bound) with the fix
temporarily reverted, then passing restored.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green,
5960/5960 (5959 baseline + 1 new test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):
result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)
RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.
Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.
Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
the measured darkening on the live category texture, and full-alpha
replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
retired TS-52 divergence-register row corrected from the two-pass
DESTCOLOR description to the single-pass path and the darkening
expectation, each citing the VM2 cdb note.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Imported from the codex worktree's uncommitted work
(C:/Users/erikn/.codex/worktrees/bd98/acdream on
codex/atmospheric-rendering-campaign), on top of its 8b7b601b.
The remote-player shadow gate ran two clients on fixed sleeps, so the
observer could move before the primary had taken its 'before' screenshot,
or the primary could take its 'after' shot before the observer had moved.
Now the harness publishes named signal files into each client's artifact
directory and the routes block on them:
- IRetailUiAutomationRuntime.TryIsAutomationSignalPublished, implemented
by WorldLifecycleAutomationController over <artifactDir>/signals/
<name>.signal (names validated by AutomationArtifactName, so no path
escape).
- 'wait signal <name> [timeoutMs]' in RetailUiAutomationScriptRunner.
- run-connected-render-pack-remote-player-gate.ps1 publishes
'primary-before' to the observer after the primary's before-shot
completes, then 'observer-moved' to the primary after the observer's
remote-observer-moved checkpoint.
- Both routes teleport with an explicit heading and wait 12 s to settle.
Build green; the three touched App test classes pass 58/58 including the
two new signal tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "1d 2h 51sh m" — the running countdown drawing straight through
the d/h/m labels. The readout is authored across the same strip as the three
number boxes, so the strip has to be one thing or the other; I hid the boxes
and left their labels behind.
Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three:
m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus
the readout inverse. Reading the swap as "hide the inputs" instead of "hide the
input ROWS" is what produced the overlap.
Also settles the Record question the same round raised. Nothing was broken:
indoors, retail's own gid_to_lcoord fails and nothing is recorded, and
UpdateLocation @0x004958F0 only ever formats coordinates already stored — there
is no "you are indoors" message in that function to port. The silence is
faithful, and it is now commented as such rather than left looking like a gap.
JournalPanelLiveBindTests is new and is the test that should have existed
first: it builds the panel from the real DATs, constructs the controllers, and
asserts every button actually receives an OnClick. Every other test so far
checked either the layout or the logic — none of them proved the controller
finds its elements in the real tree, which is where an id typo or a subtree
assumption produces a panel where nothing responds and nothing fails.
The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it
was added for is answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes from the first connected round.
The location readout is authored EDITABLE (0x16), so it builds as a UiField —
not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved
it as text, got null, and threw every write away in silence: Record reached the
model and reached the FILE, and never reached the screen. That is exactly what
was reported, and it is a whole class of bug, so the sweep that found it is now
a test over every element all three controllers bind.
The handlers mutated the model and left redrawing to the next frame's Tick.
Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update()
instead — at the moment of the click. The deferred version happened to work in
the client and made the behaviour untestable and a frame late; the notes-page
tests I had not written until now fail against it.
Abandon is wired. "Retail's abandon path is a contract-registry command we have
not ported" was wrong — it is game action 0x0316 with a single contract id, and
ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed
locally, so a refusal leaves the quest visibly intact rather than vanishing it
optimistically and having it reappear on the next full table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Built from the REAL authored layout, because that is the only place property
0x0D exists — the hermetic UiButton tests construct their own ElementInfo and
so could not have caught this class of bug at all.
Asserts the two things the player actually experiences, separately: the button
builds enabled, and a click at its centre reaches IT rather than falling
through. Enabled alone would not have been enough to call the fix proven.
Verified to have teeth rather than assumed: restoring the previous UiButton
and re-running fails both tests, naming all ten buttons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.
UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.
The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.
It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.
The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.
Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:
Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.
The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.
Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.
DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.
The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.
Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Journal tab is not a quest feature: it is a per-character notebook with no
wire, no server and no dat content. The player writes it, and it persists to a
tagged text file recovered whole from LoadPages/SavePages.
Retail refuses a journal file that does not OPEN with <NEWP>, with its own
message. That strictness is ported rather than softened — accepting such a file
would scatter the first page's text into no page at all. An ABSENT or empty
file is the opposite case and must not error: that is simply a character who
has never written a page.
Three things the format does not say out loud, each with a test:
<PNUM> is written but page order IS file order, so a reader that trusted the
number would reshuffle a hand-edited file. A recorded location of (0, 0) is a
real place, so the location tags are written on a HasLocation flag rather than
on the numbers being non-zero. And the notes box is multi-line while the file
is line-oriented — an embedded newline would read back as a tagless line and
silently truncate the notes, so they are folded to spaces at the write.
The countdown belongs to the page it was started on, and what belongs in the
file is what is LEFT rather than what it started at — saving the start value
would resurrect the full duration on every reload.
Deleting the last remaining page empties the journal instead of leaving a blank
one behind; inventing a replacement would make the journal impossible to empty.
An out-of-range page is refused rather than clamped, because clamping moves the
player somewhere they did not ask to go.
Campaign QJ slices 1 and 2 of 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last piece of QT6's own scope: r10-quest-dialogs.md §11.6's contract half.
IGameState.Contracts exposes what the client structurally knows about quests,
which — per that same research — is the tracker and nothing else. The rest of
§11.6 (chat stream, tells, give, use, confirmations) is other features and
stays out of this campaign.
A pull-through source rather than a pushed mirror. Contracts change rarely and
are already owned canonically, so a second copy would only be a thing to keep
in step; reading through means a plugin cannot observe a stale list.
Both hosts implement it. The headless one carries contract id, stage and
progress but no names — a bot has no dat access — because losing the TEXT is
expected while losing the QUEST would leave a bot silently unable to see what
it is on. Same rule covers a contract the installed dat has never heard of: it
still projects, with empty text and a correct status, rather than vanishing.
The interface member is defaulted so a host predating this campaign still
satisfies IGameState.
Two lazy catalog loads exist (the panel's and this one) rather than one shared
instance. That is deliberate: threading a shared ContractCatalog through three
composition records to avoid reading a 322-row immutable table at most twice
per session would be plumbing for no correctness or performance gain, and the
comment at the call site says so.
Campaign QT is complete; the connected user gate is owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in the panel as committed, both found by checking the code against
the authored data rather than by a test.
The authored row template is a Type-3 generic container, which resolves through
DatWidgetFactory's fallback arm to UiDatElement — whose constructor sets
ClickThrough = true ("generic decoration; behavioral widgets opt back in").
Binding OnClick without clearing that compiles, reads correctly, and produces a
list in which nothing can be selected: every click sails past the row. The
skills page had already met this and left the precedent; I did not follow it.
And there was no selection highlight at all, so even once clicking worked the
player could not tell which row the detail pane was describing. UiTemplateListBox
has no selection mechanism of its own, so the page opts in directly and
re-PAINTS the highlight after a rebuild — a rebuild discards the row objects, so
remembering the selection is not enough to keep it visible.
The tests for both initially passed while the bugs were live, because the
fixture's row root was a UiPanel and its text started white. A UiPanel is not
click-through, so the first test was vacuous; white-on-white made the highlight
unobservable. The fixture now builds the same UiDatElement production does and
authors a non-white colour. This is the third time this campaign a fixture that
did not match the real widget hid a real defect.
Live mount confirmed against the installed dats in this session's client run:
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot
0x10000559)" with no bind failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The quest log is on screen. Rows come from the live tracker joined to the
authored catalog, the Status column runs QT4's port of FillProgressString, and
the detail pane shows contact, locations, description and the other timer.
Two things measured rather than assumed, each now pinned by an installed-DAT
test rather than left to the commit message:
The tab pairing is read from the authored 0x2E table, not inferred from
x-order — the FA campaign had to correct exactly that mistake, and Contracts
turns out to be the authored DEFAULT tab (0x32 = True), so opening on the
wrong one would have looked like an empty panel.
The open path needed no keybind at all. Toolbar button 0x1000055A authors
0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds
since the toolbar was ported — it just had no panel behind it, so clicking it
did nothing. Registering slot 25 finished a wiring that was already
three-quarters present.
The list rebuild is revision-gated while the repeat countdown is not: nothing
on the wire changes as a cooldown runs down, so a rebuild-gated timer would
freeze on screen, and a per-frame rebuild would reset the player's scroll under
them. Both directions have a test.
Deliberately inert: the Abandon button (retail's abandon path is a
contract-registry command this campaign did not port — authored and visible,
but wiring a no-op handler would look responsive and lie), and the Journal
notes and Page List tabs, which are their own feature.
Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wire carries an id, a stage and two timers. Every word the player reads
lives in portal.dat's ContractTable, which nothing in the tree had ever
opened — the only reference counted its entries in a CLI diagnostic. Chorizite
does decode it (322 contracts installed), which was a real question given it
declares TabooTable without decoding it.
FillProgressString @0x00498DE0 is the one real algorithm in this panel, and it
is now ported whole. Its x87 compares are the usual fcom/sahf pattern, so the
(status & 0x41) tests decode as "<= 0" rather than "< 0" — the difference
between a cooldown that expires and one that never does.
Three readings recorded as tests because each looks like a mistake:
TimeWhenDone is on the wire and is never read; an EMPTY QuestflagRepeatTime is
the entire difference between "Done" and "Available"; and DescriptionProgress
is a printf format taking stage-4, not a literal — rendering it verbatim shows
the player "%d/20 Tuskers".
DeltaTimeToString @0x00565E10 emits every part with a trailing space and then
overwrites the last one. That truncation is invisible in the decompiler output
(the instruction reads as pointer noise), so it was settled by decoding the
bytes: mov byte ptr [esp+eax+0x1b], cl with cl == 0 and eax == strlen writes
the terminator over buffer[len-1]. Guessing either way was a coin flip that
decides whether every repeat timer reads "Done (1h 30s to Repeat)".
The single-%d substitution is a MEASUREMENT, not a convenience: 89 of the 322
installed contracts author a progress format and every one uses exactly one
specifier. An installed-DAT test asserts that, so a future dat that ships two
fails there rather than silently rendering a raw specifier.
LayoutDump gained --contracts, which is how all of the above was measured.
Campaign QT slices 2 and 4 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth sibling J-owner, built to the shape the other three established. It
borrows nothing, because the retail client stores no quest state of its own —
everything here is a projection of what the server pushed.
Clearing at generation reset is safe for the same reason: a fresh session opens
with a full 0x0314 replacement, so the reset cannot lose anything the next
login will not immediately restate, while NOT clearing would show a previous
character's quests.
Three readings of the wire that would each lose contracts silently, one test
apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the
server dropped); an empty 0x0314 clears rather than being ignored (it is how
the server says "you have none", and ignoring it strands the last quest on
screen); and a delete carries a full tracker struct, so it looks exactly like
an add apart from one flag.
Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the
drain loop while GameRuntimeTeardownStage.Complete defines what the ledger
demands, and nothing tied them together. Leave the constant behind and the new
owner is never disposed at all, while the ledger goes on waiting for its flag —
the runtime hangs in teardown rather than failing anywhere near the edit. The
stage-ledger test now reads the constant by reflection and asserts it against
the flag list, so the next owner fails at the edit instead.
Campaign QT slice 3 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both opcodes have been named in GameEventType since the wire-catalog work with
nothing behind them, so every contract the server has ever sent us arrived and
was discarded.
Three details that a reimplementation from the enum alone would get wrong, and
each has a test:
The two trailing flags on 0x0315 are widened bools, not bytes, and they sit
OUTSIDE the struct writer — ACE's ContractTracker.Write has them commented out
precisely because the event appends them itself. Reading them as bytes decodes
the delete flag from the wrong four bytes and silently drops contracts.
The stage is not a dense enum. Retail encodes N completed steps as
ProgressCounter + N, so a switch over the four named values sees stage 9 as
unknown and shows nothing. Progress/HasProgressCounter do that arithmetic once
here rather than leaving every caller to remember it.
The countdown anchor is not on the wire. FillProgressString @0x00498DE0 counts
down from CContractTracker::_time_of_server_update, which the server never
sends — so arrival has to be stamped at parse time or the repeat timer has
nothing to tick against.
An empty table is a valid answer rather than a decode failure: it is how the
server says "you have no contracts", and confusing the two would leave stale
quests on screen permanently. A truncated one is rejected outright instead of
decoding to its prefix, which would drop quests just as silently.
Campaign QT slice 1 of 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.
The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.
We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.
The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.
Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.
All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").
The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.
acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.
Register: CT-5, CT-6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blink is not code. It is data, and we were throwing it away.
A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.
Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.
So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.
Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.
The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.
Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.
Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.
Register: CT-3, CT-4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression from cbab79d7, which I introduced: the indicator stopped showing at
all. Switching it from Visible to state-driven was half a correction — right
about retail's mechanism, wrong about what makes this element appear.
Measured, rather than reasoned about (LayoutDump gained --props for it):
0x1000048C state 13 Ghosted 0x3B = True -> hidden
state 1 Normal 0x3B = False -> shown
state 3 pressed 0x3B = False
Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this
element on screen. UiDatElement applies 0x3B on a state change; UiButton does
not, and this element builds as a button — so driving the state alone left it
hidden forever. The original Visible toggle was, by coincidence, exactly what
the authored data prescribes.
So the property is applied here rather than left unhonoured. That is the
authored data, not a visibility hack layered over the state machinery.
The state is still set, for the media it selects, but only on the way IN:
TrySetRetailState(Ghosted) means Enabled = false, and disabling the button
would also refuse the click that scrolls to the newest text — a second bug
waiting behind the first.
The test now pins VISIBILITY across the transitions instead of ActiveState.
The previous test passed while the feature was broken because the fixture
element carried no 0x3B, so the assertion could never see the property that
actually decides this. It fails now if the state is driven without the
visibility.
Proper fix noted for later: UiButton should honour per-state 0x3B the way
UiDatElement already does. That is a wider change than this regression wants.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT Group D, closing the campaign.
D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since
Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the
audit's word, then removed it with its three panel-only test files. Those tests
passed, which is exactly the problem: they made the real input surface look
better covered than it is.
ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM
coverage and only one exercised ChatPanel, so just that method went. Deleting
the file would have quietly dropped real coverage along with the dead kind.
Three doc comments referencing the deleted type were rewritten rather than left
as dangling crefs.
D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there, contrary to the audit's summary. What was stale was the
chat DIGEST's "Open" section, which still named four closed issues and claimed
Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open
are #359, #360, #361 and #366.
The digest also gained a Campaign CT section (the tag mechanism, the MEASURED
tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session:
- Do not model authored state media with one image per state — the unseen
indicator's Normal state carries SIX frames and that IS retail's blink.
- Do not read an element's role from a Binary Ninja field NAME — the names in
ChatInterface's binder are shifted badly enough to assign a UIElement* into
a float field.
- Do not assume our side has a gap because retail has a mechanism. That cost
this campaign twice in one session: the transcript was claimed unbounded
when ChatLog has always capped at 500 entries, and C1's auto-scroll was
planned as a port when UiScrollable already did it.
CT-C4 is deferred and marked so: pure test coverage over behaviour the audit
confirmed already works, changing nothing a user can see.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User report: "the Unseen indicator shows, but not blinking. I thought it was
blinking in retail." They were right, and CT-C1 had the mechanism wrong.
The dat settles it. Element 0x1000048C authors:
1:Normal media=13/6 <- SIX image frames: the flash
3:Normal_pressed media=2/1
13:Ghosted media=0/0 <- the authored DEFAULT, draws nothing
and retail's own click handler ends in SetState(0xD) — Ghosted. So the
indicator is driven by authored STATE, never by visibility, and the blinking is
a multi-frame media list in the DATA rather than anything in code.
CT-C1 toggled Visible instead. That looks almost right — the thing appears and
disappears at the correct moments — and can never blink, because visibility has
no frames. Now switched to Normal/Ghosted, which is both the retail mechanism
and the thing the animation hangs off.
STILL NOT BLINKING, and honestly so: our importer keeps ONE image per state
(ElementInfo.StateMedia is a single file), so multi-frame media is not modelled
anywhere in the UI layer. That is a capability rather than a tweak — the same
shape as the tagged-runs work in Group A — and the state machinery here is
correct either way, so it gains the animation for free once that lands. Recorded
in the method's own doc rather than left as a mystery.
The test fixture gained the element: it was absent, so the whole binding path
had never been exercised by any test — which is why a visibility-based
implementation passed everything. The test now asserts the state TRANSITIONS
(Ghosted at rest, Normal when a line arrives while scrolled up, Ghosted again on
returning to the bottom), not merely that something was bound.
Two notes on reading the decomp here, since both nearly misled me. Binary
Ninja's field names in this function are demonstrably shifted — it assigns a
UIElement* into m_fCurrentOpacity, a float — so the element's ROLE was
confirmed from its id and its click handler, not from a name. And the blink was
found by measuring the dat, not by reading code, because there is no blink code
to read.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User report: "ESC is hardwired to Freefly which it should not be ... the
freefly should really be discarded. Should not be in the client."
Two separate things were true.
Escape ran a priority chain — cancel target mode, else EXIT FLY MODE, else
leave player mode, else close a window — so in a session that had reached the
free-fly camera, Escape spent itself on that rung instead of doing what the
player expected. The rung is gone; a session somehow in fly mode now falls
through to the next one.
And free-fly was still bound: Ctrl+Shift+F in RetailDefaults (the table
production actually loads) and plain F in AcdreamCurrentDefaults (dead since
K.1c, removed anyway so it cannot be revived by accident). The comment on the
live binding advertised two other ways in — the ImGui View menu and the Debug
panel's "Toggle Free-Fly Mode" button — but BOTH went away with
AcDream.UI.ImGui at Campaign V, so the shortcut was the last route in. It is
now unbound, and a test pins that across both default tables.
This makes free-fly unreachable rather than deleted. The implementation still
spans 25 files (CameraController, FlyCamera, the dispatcher capture, pointer
controller, composition, and a streaming observer source), and ripping that out
at the end of a long session is how a regression lands in the camera. Scoped as
its own follow-up; unbinding is what fixes the reported behaviour today.
The Escape priority test was updated rather than deleted: its middle row now
asserts the fall-through, so the removed rung is documented by a passing test
instead of by its absence.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice C1, completing Group C.
The authored element was already in the layout and simply never bound:
0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when
a line arrives while the transcript is scrolled up, and clicking it jumps to
the newest text.
Half of this slice turned out to be done already, and checking rather than
assuming is what kept it that way. The plan called for porting retail's rule
that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading
back is not yanked to the bottom. UiScrollable.SetExtents already does exactly
that via preserveEnd, and chat gets it by default — so the scroll behaviour was
untouched and only the indicator was missing. Rewriting it would have been
churn on correct code.
The flag clears on reaching the bottom by ANY means, not only by clicking the
indicator. Clearing only on the click would leave it lit over text the player
had already scrolled down and read, which is worse than not having it.
Detection samples the scroll position before the rebuild, at the one moment we
know new content arrived (the revision advancing). The first build after bind
is deliberately excluded — a fresh window has not "missed" anything.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slices C2 and C3.
**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.
Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".
**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.
Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.
The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.
One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.
Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice B2, and the autocomplete the user asked about directly.
Typing "/r " now rewrites the chat entry to "@tell {LastTeller}, " the moment
the space lands, matching ChatInterface::HandleTextReplacements @0x004F50D0 ->
SetReplyTextInChatBox @0x004F4760.
This is display sugar rather than routing: "/r hello" already SENT correctly
through ChatInputParser's reply aliases. What was missing is that the player
could not SEE who they were about to reply to before pressing enter.
The trigger strings came out of the constant pool, not the decompiled listing —
Binary Ninja renders them as bare data_* references with no preview:
data_7C4C70 = "r " data_7C4C68 = "rp " data_7C4C58 = "reply "
Retail stores them WITHOUT the leading prefix and tests the first character
separately against '/' (0x2F) or '@' (0x40), which is why both prefixes work.
The research summary for this area listed the triggers as "/t ", "/tell " and
"reply " — reading the pool corrected that.
Three boundaries, each pinned by test because each is a way to get this subtly
wrong:
- The trailing space is PART of the trigger. "/r" alone must be left alone —
the player may still be typing "/roleplay", and expanding early would
hijack a different command mid-word.
- Only on space. Running the replacer per keystroke would rewrite text out
from under someone mid-word; retail keys on 0x20 specifically.
- Only with the caret at the end. Otherwise the player is editing existing
text, and expanding would corrupt a sentence they are part way through
fixing.
With nobody to reply to, nothing is rewritten — retail leaves the text alone
rather than producing a tell addressed to nobody, and the ordinary submit path
still reports "Someone must @tell you first!".
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice B1.
CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.
The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.
So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.
Two deliberate simplifications, both registered as CT-1 rather than left
implicit:
- ONE threshold, not retail's two. The hysteresis exists to stop retail
re-trimming an accumulating buffer on every append; we rebuild the visible
list each time, so there is nothing to damp, and a second threshold would
only make the oldest visible line jump around as messages arrive.
- Whole-line cutting rather than a newline search near an offset — our unit
already IS the line, which is what retail's newline preference is for.
Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a name filled the entry with "@tell Name, " correctly but parked the
caret at column 0, so the player had to click the chat bar to get behind their
own prefix before typing — which defeats most of the point of the affordance.
Self-inflicted in d32ef388. SetText already places the caret at the end, and I
stacked an explicit "move to the end" on top of it. MoveCaret takes a DELTA, so
int.MaxValue overflowed _caret + delta to negative and the clamp landed at
column 0. The redundant call was not merely redundant; it was the bug.
Removing it is the whole fix. The test now pins CaretPos as well as the text,
and reintroducing the call reproduces the reported symptom exactly (expected
11, actual 0).
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A5, closing Group A. Retail's
gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10 ->
ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " into the chat
entry and takes keyboard focus; clicking a green name here now does the same.
The trailing space is deliberate — without it the first character the player
types joins the comma.
Three seams, each narrow on purpose:
- UiText.OnCharClick is offered the character under a left click before the
element-wide OnClick, and consuming it suppresses that. Kept separate
because a tag click is POSITIONAL and an element click is not; folding
them together would make every text element with an OnClick swallow tag
clicks.
- TaggedRangesForFragment returns tagged column ranges relative to the
FRAGMENT, because that is what a click resolves to — UiText.HitChar gives
a line index into the WRAPPED list plus a column within it. Line-relative
ranges would land every click on a wrapped line at the wrong characters.
- The controller caches those ranges alongside the runs it already caches,
so the per-click lookup reads the same cache the draw does.
The hit test is half-open: a caret slot sits BETWEEN glyphs, so clicking just
past a name's last letter belongs to the space after it, not the name. Pinned
by theory rather than left to chance, since off-by-one here means clicking a
name sometimes does nothing.
StartTell uses the tag's NAME, not its object id — retail carries the id but
this handler never reads it, so the tell still addresses correctly for someone
who has since moved out of range.
Group A is complete: names are green (A4) and clickable (A5). Ready for the
user's visual gate.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A4, and the first slice of Group A that shows on screen: a
player's name in a chat line now draws green while the rest of the line keeps
its message colour.
The colour is AUTHORED, not computed. Retail keeps two parallel index-selected
colour arrays on the text element and refreshes both from the same caller index
on every append (UIElement_Text::AppendStringInfoWithFont @0x00469DE0):
property 0x1B for ordinary glyphs, 0x1D for glyphs under an open tag. Property
0x1D is read exactly the way 0x1B already was, carried on ElementInfo, and
seeded onto UiText beside DefaultColor. Measured on the chat transcript
(0x2100006F / 0x10000011) as RGB(0,178,0).
It deliberately does NOT go into RetailChatColorTable. That table is the
runtime-built per-LogTextType mapping; the tag colour is per-element authored
data, and filing it there would put it somewhere it would look right in tests
and be wrong in principle.
RunsForFragment is the load-bearing piece and is pure. Wrapping can drop the
space it broke on, so a fragment is NOT simply the next N characters of the
line — BuildLines locates each fragment in the source text to keep the span
offsets honest, and the mapper clips spans to the fragment window. A tag
straddling a wrap break is therefore split across both fragments and stays
green on both, instead of changing colour mid-word.
Two guards worth naming. A fragment containing no tag returns NULL rather than
a single-run list, so the overwhelming majority of lines keep the existing flat
draw path untouched. And an element authoring no 0x1D falls back to the line
colour, so a name never renders in a colour nobody chose.
The run/fragment contract is property-tested across every substring of a tell
line, because CT-A1's RunsMatchLine refuses mismatched runs by silently falling
back to flat text — a mapping bug here would degrade quietly rather than fail.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A3. Still nothing visible; this removes the blocker.
ChatEntry carried Sender and SenderGuid the whole way through ChatLog, and
RecentLinesDetailed dropped both one step before the transcript. That is why a
speaker's name could never be coloured or clicked apart from the sentence
around it — by the time anything could draw it, there was no name left, only
prose.
FormattedLine now carries a Spans sidecar: the line split into stretches, one
of which may hold a retail text tag. Text remains the VISIBLE line with markup
consumed, so wrapping, selection, hit-testing and the caret are untouched, and
Spans is null for the ordinary single-colour line, which is most of them.
FormatEntryTagged shares its format strings with FormatEntry through a sender
decorator rather than duplicating retail's wording. Two copies would be two
things to keep in step, and these two renderings MUST show the same characters:
the transcript selects against the flat text, so any drift would mean clicking
one character and selecting another.
Tagging is gated on AC1's player id range (0x50000001..0x6FFFFFFF), read off
the guard in Handle_Communication__HearSpeech @0x005712A0 — so monsters and
NPCs never become clickable, which a "has a name" test would get wrong.
One real bug came out of a defensive test rather than a report. A sender name
containing '<' re-parsed as a marker and SWALLOWED characters from the visible
line ("Od<d says" rendered as "Od says"). The markup has no escape mechanism,
and retail's has none either because AC name validation makes the case
unreachable there — but sender names are server data. Such a name is now simply
not tagged, so the line renders plain like any other untaggable sender instead
of rendering corrupted.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A2. Pure parser, no UI, nothing wired yet.
Retail's client composes chat lines with the markup already embedded —
Handle_Communication__HearSpeech @0x005712A0 sprintf's it — and the text
element recognises it while appending (UIElement_Text::InqGlyphs @0x00468EA0),
calling TextTagFactory::MakeTag @0x00478480 per marker. A tagged speaker name
arrives as:
<Tell:IIDString:1342177290:Dww>Dww<\Tell> tells you, "hello"
ChatTagMarkup.Parse splits that into spans, consuming the markers: the name
under a tag, the remainder untagged.
The rule that decides where a tag ENDS is the one worth being careful about.
It is the absence of a colon, not the backslash: MakeTag requires a ':' to
succeed, so ANY bracketed text it cannot parse closes the open tag, and the
backslash in retail's own closer (TextTag::BuildEndTag @0x00479190) is
incidental to that. Porting "a closer starts with a backslash" would look
correct on every retail line and then diverge on everything else, so the test
pins all three of <\Tell>, <Tell> and <anything> as closers.
Two details taken from the decomp rather than guessed: only the FIRST colon of
an IIDString payload separates the id from the name, so a name containing a
colon survives intact (ParseStartTag @0x00478910); and an unterminated '<' is
ordinary text, so a player typing "is 3 < 4 really" does not lose the rest of
their sentence.
The parse also upholds the contract CT-A1's draw side enforces — the
concatenated span text always reproduces the visible line, because selection
and hit-testing index into that flat string.
Solution builds clean; full hermetic gate green.
Note for the record: PreparedAssetVerificationCacheTests.BackupRecoveryHashes-
TheBackupEvenWhenTheLiveCacheIsValid failed once during this slice's gate and
then passed isolated, as a class, and on a full-gate rerun. This branch touches
no launcher code, so it is load-sensitive rather than caused here — flagging it
rather than silently re-running, since a test that only fails under parallel
load is worth someone classifying.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice A1. No behaviour change: this is the capability the rest of
Group A needs.
UiText already drew several differently-coloured runs on one line
(TextRun/RunsProvider, used by the character stat panel) but the path was gated
to OneLine == true, and the chat transcript is multi-line — so a chat line
could only ever be one colour. Retail's is not: a tagged glyph run takes the
element's TAG colour (property 0x1D) while the rest of the line takes the
ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0.
LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line.
Roughly fifty files construct Line, and widening its shape would put every one
of them in the blast radius of a chat feature; a line with no runs draws
exactly as before.
The runs fold into the existing datLines list as extra entries at advancing
pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line
still submits its whole outline pass before any fill, and cannot notch the
descender of the line above.
Two things are deliberately load-bearing:
- RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT
line text, so a run list that disagrees with it would draw one thing and
select another. The draw path verifies the runs say exactly the same
characters and falls back to the flat line if not, rather than trusting the
caller.
- LayoutRuns is pure. The pen-advance is the part that silently mis-renders
if it drifts, so it is testable without a font atlas or a GPU — which also
keeps its tests in the ordinary gate rather than the SystemFont lane.
Solution builds clean; full hermetic gate green, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both entries were deliberate no-ops — the code said so — and both showed a
static label where retail shows the selected player's NAME.
Retail builds them in gmMainChatUI::InitTalkFocusMenu @0x004CDC50 and rebuilds
their labels every time the menu opens, substituting the selection through
StringInfo::AddVariable_String (@0x004CD91C / @0x004CD982). So they now read
"Tell to Dww" / "Squelch (ignore) Dww", rebuilt on open from a live selection
provider, and grey out with nothing selected — retail arms the tell slot only
for a talkable target (SetTalkFocusEnabled(2, 1) @0x004CD9B0).
Picking "Tell to X" aims the chat bar at X. That needed one piece of plumbing:
the parser's plain-speech fallthrough returned a null target, so a line typed
under a Tell focus was dropped by the router for having no one to send to.
Parse/Submit now carry an optional default tell target for exactly that case.
"Squelch X" publishes the ALREADY-REGISTERED /squelch verb rather than
reimplementing the request — the ModifyCharacterSquelch wire builder
(CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0) has been there all
along; only the menu path to it was missing.
UiMenu gains an OnOpen seam, because a menu whose Items are fixed at Bind can
only ever say "Tell to Selected". It fires before _open flips so the rebuilt
rows are measured and drawn in the same opening.
Solution builds clean; 14,480 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>