merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)

main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 16:19:15 +02:00
commit 5ac9d8c19c
53 changed files with 6691 additions and 439 deletions

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Retail per-vertex static-light burn-in. Ported verbatim from
/// <c>calc_point_light</c> (acclient 0x0059c8b0), the function retail's
/// <c>D3DPolyRender::SetStaticLightingVertexColors</c> (0x0059cfe0) runs over
/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the
/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud-
/// interpolates it across each triangle and the texture stage modulates it).
///
/// <para>
/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our
/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and
/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL
/// reaching lights into the vertex once, keyed on light position not camera —
/// uniform, stable, and never blown out (each light is clamped to its own
/// colour, then the vertex sum is clamped to [0,1]).
/// </para>
///
/// <para>Constants (decomp-cited, not guessed):</para>
/// <list type="bullet">
/// <item><c>static_light_factor</c> = 1.3 (0x00820e24) — folded into
/// <see cref="LightSource.Range"/> by <c>LightInfoLoader</c>, so
/// <c>falloff_eff == light.Range</c> here.</item>
/// <item><c>LIGHT_POINT_RANGE</c> = 0.75 (0x007e5430) — the half-Lambert wrap
/// uses <c>2·LPR = 1.5</c> as the divisor and <c>(2·LPR 1) = 0.5</c> as the
/// distance bias, so even surfaces angled away from a torch receive some light.</item>
/// </list>
/// </summary>
public static class LightBake
{
// calc_point_light literals.
private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE
private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) 1.0
/// <summary>
/// Accumulate one static light's contribution into a per-vertex RGB sum,
/// exactly as <c>calc_point_light</c> does. Returns the contribution to ADD
/// (already per-channel clamped to the light's own colour); the caller sums
/// over all reaching lights and clamps the total to [0,1].
/// </summary>
public static Vector3 PointContribution(
Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light)
{
// D = light vertex (FROM vertex TO light), used un-normalised.
float dx = light.WorldPosition.X - vtxWorldPos.X;
float dy = light.WorldPosition.Y - vtxWorldPos.Y;
float dz = light.WorldPosition.Z - vtxWorldPos.Z;
float distsq = dx * dx + dy * dy + dz * dz;
float dist = MathF.Sqrt(distsq);
float falloffEff = light.Range; // = Falloff × static_light_factor(1.3)
if (dist >= falloffEff || falloffEff <= 1e-4f)
return Vector3.Zero;
// Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal.
float wrap = (1f / TwoLpr) *
(vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz
+ WrapBias * dist);
if (wrap <= 0f)
return Vector3.Zero;
// norm branch — ported EXACTLY (changes the near-vs-far falloff shape).
float norm = distsq > 1f ? distsq * dist : dist;
float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm);
// Per channel: contribution clamped to the light's own colour (a single
// light can never push a channel past its colour — the no-blowout ceiling).
return new Vector3(
MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X),
MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y),
MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z));
}
/// <summary>
/// Bake the full per-vertex colour by summing every reaching lit point/spot
/// light, then clamping to [0,1] (the <c>SetStaticLightingVertexColors</c>
/// final clamp). Directional lights are skipped — they are handled by the
/// sun path, not the static burn-in.
/// </summary>
public static Vector3 ComputeVertexColor(
Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList<LightSource> reaching)
{
float r = 0f, g = 0f, b = 0f;
for (int i = 0; i < reaching.Count; i++)
{
var light = reaching[i];
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
var c = PointContribution(vtxWorldPos, vtxWorldNormal, light);
r += c.X; g += c.Y; b += c.Z;
}
return new Vector3(
Math.Clamp(r, 0f, 1f),
Math.Clamp(g, 0f, 1f),
Math.Clamp(b, 0f, 1f));
}
}

View file

@ -79,7 +79,15 @@ public static class LightInfoLoader
(info.Color?.Green ?? 255) / 255f,
(info.Color?.Blue ?? 255) / 255f),
Intensity = info.Intensity,
Range = info.Falloff,
// falloff_eff for the per-vertex point-light burn-in (calc_point_light
// 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor
// is the fixed global 1.3 (0x00820e24). That is the path that lights
// STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is
// about — so we match it, not the D3D-dynamic config_hardware_light
// rangeAdjust (1.5, a different path for moving objects). The shader ramp
// (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard
// disc edge that read as a spotlight.
Range = info.Falloff * 1.3f,
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
IsLit = true,

View file

@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting;
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Recompute <c>DistSq</c> from viewer to every registered
/// point/spot light.
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </description></item>
/// <item><description>
/// Drop lights outside <c>Range² * 1.1</c> (10% slack prevents
/// pop as we walk across the boundary).
/// </description></item>
/// <item><description>
/// Rank remaining lights by <c>DistSq</c> ascending. Pick top 7.
/// </description></item>
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range).
/// For every registered lit point/spot light, recompute <c>DistSq</c>
/// from the viewer and keep the nearest <c>(MaxActiveLights sunSlot)</c>
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
/// </description></item>
/// </list>
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (<c>mesh_modern.frag</c>: <c>d &lt; range</c>), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
/// </para>
///
/// <para>
@ -37,7 +39,6 @@ namespace AcDream.Core.Lighting;
public sealed class LightManager
{
public const int MaxActiveLights = 8; // D3D parity
private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff
private readonly List<LightSource> _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
@ -94,45 +95,66 @@ public sealed class LightManager
/// </summary>
public void Tick(Vector3 viewerWorldPos)
{
// Pass 1: compute DistSq + filter out lights outside the slack radius.
var candidates = new List<LightSource>(_all.Count);
foreach (var light in _all)
{
if (!light.IsLit) continue;
if (light.Kind == LightKind.Directional)
{
// Directional lights don't participate in this ranking —
// the sun is always slot 0.
continue;
}
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack;
if (light.DistSq > rangeSq) continue;
candidates.Add(light);
}
// Pass 2: sort by DistSq ascending, take up to 7.
candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq));
// Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
// point lights (slot 0 is the sun) and applies each light's hard range cutoff
// PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
// NOT a viewer-range candidacy filter — a torch the viewer stands outside the
// range of must still light the wall it sits on.
//
// Allocation-free partial selection: the old path built `new List<>(N)` and
// ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
// so that allocated a large list per frame (GC pressure → FPS). Instead keep
// the nearest maxPoint directly in the _active window, maintained sorted by
// insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
Array.Clear(_active);
_activeCount = 0;
// Slot 0 = sun when present.
// Slot 0 = sun when present (directional; never ranked by distance).
int baseSlot = 0;
if (Sun is not null)
{
_active[0] = Sun;
_activeCount = 1;
baseSlot = 1;
}
int maxPoint = MaxActiveLights - _activeCount;
int pointCount = Math.Min(maxPoint, candidates.Count);
for (int i = 0; i < pointCount; i++)
int maxPoint = MaxActiveLights - baseSlot;
int filled = 0;
if (maxPoint > 0)
{
_active[_activeCount + i] = candidates[i];
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
// Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by
// DistSq. Insert if there's room or this light is nearer than the
// current farthest (then the farthest falls off the end).
if (filled < maxPoint)
{
int j = baseSlot + filled;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
filled++;
}
else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq)
{
int j = baseSlot + maxPoint - 1;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
}
}
}
_activeCount += pointCount;
_activeCount = baseSlot + filled;
}
}

View file

@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver
resolvedGfxObj = closeGfxObj;
return true;
}
/// <summary>
/// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based
/// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible
/// ONLY at distance 0 (<c>Degrades[0].MaxDist == 0</c>) and the table degrades to GfxObj
/// id 0 (= nothing) at real distance. Retail
/// (<c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030 → <c>Draw</c> 0x0050D7A0 picks
/// <c>gfxobj[deg_level]</c> by viewer distance) therefore never draws it in the live
/// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame
/// distance-LOD (the resolver above always returns slot 0), so without this check it
/// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39
/// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0,
/// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at
/// distance &gt; 0) skip such GfxObjs.
/// </summary>
public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId)
=> IsRuntimeHiddenMarker(
id => dats.Get<GfxObj>(id),
id => dats.Get<GfxObjDegradeInfo>(id),
gfxObjId);
/// <summary>Loader-callback overload of <see cref="IsRuntimeHiddenMarker(DatCollection, uint)"/>.</summary>
public static bool IsRuntimeHiddenMarker(
Func<uint, GfxObj?> getGfxObj,
Func<uint, GfxObjDegradeInfo?> getDegradeInfo,
uint gfxObjId)
{
var gfxObj = getGfxObj(gfxObjId);
if (gfxObj is null
|| !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
|| gfxObj.DIDDegrade == 0)
return false;
var info = getDegradeInfo(gfxObj.DIDDegrade);
if (info is null || info.Degrades.Count == 0)
return false;
// Closest slot visible only at distance exactly 0 = editor-only placement marker.
bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f;
if (!firstSlotEditorOnly)
return false;
// ...and the table degrades to NOTHING (id 0) at real distance — confirms it
// becomes invisible at runtime rather than LOD-swapping to a real mesh.
foreach (var d in info.Degrades)
if ((uint)d.Id == 0u)
return true;
return false;
}
}

View file

@ -935,52 +935,47 @@ public sealed class MotionInterpreter
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
/// <summary>
/// Return the run rate. Mirrors retail
/// <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
/// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// Mirrors retail <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
///
/// <para>
/// <b>Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):</b>
/// <code>
/// void get_max_speed(this) {
/// weenie_obj = this->weenie_obj;
/// this_1 = nullptr;
/// if (weenie_obj == 0) return;
/// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return;
/// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack
/// }
/// </code>
/// Binary Ninja shows the return type as <c>void</c> because the float
/// return rides the x87 FPU stack rather than EAX. Both branches
/// emit an <c>fld</c> of either <c>this_1</c> (the InqRunRate
/// out-param value) or <c>my_run_rate</c>, leaving the run rate on
/// ST0 as the return value.
/// <b>The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).</b>
/// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// renders this function as <c>void</c> with a bare <c>this->my_run_rate;</c>
/// statement because it drops x87 instructions — a known BN artifact class.
/// Disassembling the PDB-matched v11.4186 binary at VA <c>0x00527cb0</c>
/// shows all THREE return paths end with
/// <c>fmul dword ptr [0x007C8918]</c>, and the .rdata dword at
/// <c>0x007C8918</c> is <c>0x40800000</c> = 4.0f (the sibling
/// <c>get_adjusted_max_speed</c> 0x00527d00 carries the same trailing
/// fmul). Re-derive with <c>py tools/verify_un2_fmul.py</c>. The three
/// retail paths: weenie_obj == null → 1.0×4; InqRunRate success →
/// queried×4; InqRunRate failure → my_run_rate×4. ACE's
/// MotionInterp.cs:665-676 ports it identically (RunAnimSpeed = 4.0f).
/// </para>
///
/// <para>
/// <b>Critical:</b> this returns the BARE run rate (typically 1.0 to
/// ~3.0), NOT a velocity in m/s. We previously multiplied by
/// <c>RunAnimSpeed</c> to get a m/s value, reasoning that
/// <c>2 × bare_rate</c> would be too slow a catch-up speed for the
/// caller (<c>InterpolationManager::adjust_offset</c>). That was a
/// misread of the decomp — retail's catch-up IS that slow on purpose.
/// The multi-second 1-Hz blip the user reported when observing retail
/// remotes from acdream traced to body racing at the wrong (overshot)
/// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s
/// for a run-skill-200 char).
/// Consequence: the dead-reckoning catch-up speed
/// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// is <c>2 × get_max_speed()</c> ≈ 23.5 m/s for a run-rate-2.94
/// (run-skill-200) character — that IS retail's value. An earlier
/// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was
/// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on
/// observed retail remotes; that reading trusted the BN x87 dropout
/// and is refuted by the binary. If the blip recurs, its root cause is
/// elsewhere (node-fail handling / progress-quantum abandonment /
/// position-queue feed — the #41 family), NOT this multiply.
/// </para>
/// </summary>
public float GetMaxSpeed()
{
// Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate.
// Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678
// which is verified against retail (the ACE MotionInterp file is a
// line-by-line port). Returns the maximum world-space velocity in m/s
// — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by
// InterpolationManager.AdjustOffset to compute the catch-up speed
// (= 2 × maxSpeed).
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
// Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried;
// InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0,
// .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0
// (.rdata 0x007928B0), not my_run_rate.
float rate = 1.0f;
if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
rate = MyRunRate;
return RunAnimSpeed * rate;
}

View file

@ -26,8 +26,16 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches.
/// Consumed by nobody this stage (zero behavior change).
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> — BEFORE the
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
/// universal id-&gt;cell lookup (<c>GetVisible</c>), the 3rd-person camera cell
/// (<c>FindVisibleChildCell</c>), and the block-local terrain origin
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert.
/// </summary>
public UcgCellGraph CellGraph { get; } = new();

View file

@ -638,9 +638,23 @@ public sealed class PhysicsEngine
{
Console.WriteLine(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain
// the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE
// (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon
// landblock fails the localY>=0 bounds test, so the loop matches a
// neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping
// the validated claim 0x00070143 -> 0xA9B30143. The client then
// mis-resolves the player into the wrong landblock and spams ACE with
// rejected moves. The validated claim's prefix is AUTHORITATIVE; a
// position falling in a neighbouring resident landblock must not
// re-stamp it. Byte-identical for the login case (the position lies in
// the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000);
// diverges only — and correctly — in the far-teleport dungeon case.
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
lbPrefix | (cellId & 0xFFFFu),
cellId,
IsOnGround: true);
}
}

View file

@ -3095,14 +3095,26 @@ public sealed class Transition
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
float dirLenSq = direction.LengthSquared();
if (dirLenSq >= PhysicsGlobals.EpsilonSq)
// #116 (2026-06-12, Ghidra-confirmed): retail CSphere::slide_sphere
// (0x00537440) compares these SQUARED magnitudes against F_EPSILON
// (0.000199999995 ≈ 0.0002 = PhysicsGlobals.EPSILON), NOT against the
// squared epsilon. Ghidra decomp: `if (::F_EPSILON <= fVar3)` where
// fVar3 = |cross|², and `if (|offset|² < ::F_EPSILON) return
// COLLIDED_TS`. Our port used EpsilonSq (0.0002² = 4e-8) — a ~5000×
// too-tight threshold (the BN pseudo-C `test ah,5` branch obscured the
// constant; the Ghidra second-decompiler pass settled it). Effect:
// crease-exists now needs ≥0.81° between the normals (was 0.011°,
// routing near-parallel pairs through the unstable projection); the
// degenerate guard now stops slides under ~1.41 cm like retail (was
// 0.2 mm). Register: AP-? (divergence retired). See ISSUES.md #116.
if (dirLenSq >= PhysicsGlobals.EPSILON)
{
// Crease exists: project displacement onto it.
float diff = Vector3.Dot(direction, gDelta);
float invDirLenSq = 1f / dirLenSq;
Vector3 offset = direction * diff * invDirLenSq;
if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq)
if (offset.LengthSquared() < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
// Subtract current displacement to get the correction vector.

View file

@ -109,6 +109,20 @@ public static class RenderingDiagnostics
public static bool ProbeViewerEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1";
/// <summary>
/// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer
/// emits one <c>[outstage]</c> line per CHANGE of the outside-stage
/// routing + per-slice cone verdict set under an interior root (which
/// outdoor dynamics were routed to the landscape slice, which survived the
/// slice viewcone), and GameWindow emits one <c>[outstage-pt]</c> line per
/// change of the slice Scene-particle id set + matched-emitter count.
/// Built for the portal-swirl-missing-through-doorway capture. Light:
/// silent while the set is stable. Initial state from
/// <c>ACDREAM_PROBE_OUTSTAGE=1</c>.
/// </summary>
public static bool ProbeOutStageEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1";
/// <summary>
/// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal
/// visibility pass emits, EVERY frame the camera root is an indoor cell, a
@ -229,6 +243,34 @@ public static class RenderingDiagnostics
public static bool ProbePhantomEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1";
/// <summary>
/// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true,
/// the per-frame scene-lighting build emits ONE <c>[light]</c> line
/// roughly every second (wall-clock rate-limited like WB-DIAG) via
/// <see cref="EmitLight"/>:
/// <code>
/// [light] insideCell=&lt;bool&gt; ambient=(r,g,b) sun=&lt;intensity&gt;
/// registeredLights=&lt;N&gt; activeLights=&lt;uCellAmbient.w&gt; playerCell=0x&lt;id&gt;
/// </code>
/// This is the self-verification signal for the dungeon-dim question:
/// <list type="bullet">
/// <item><description><c>insideCell=true ambient=(0.20,0.20,0.20) sun=0</c>
/// confirms the indoor branch fired (retail flat ambient, sun killed).</description></item>
/// <item><description><c>registeredLights</c> is the count of dat-baked
/// point/spot lights (<c>Setup.Lights</c>) registered with the
/// <c>LightManager</c> — if this is 0 in a dungeon, the cell's static
/// objects carry no baked torches (so the only illumination IS the
/// 0.2 ambient → dim).</description></item>
/// <item><description><c>activeLights</c> is <c>uCellAmbient.w</c> — the
/// shader's active-slot count, which INCLUDES the (zeroed) sun slot
/// indoors. So <c>activeLights=1 registeredLights=0</c> = "only the dead
/// sun slot, no torches in range".</description></item>
/// </list>
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_LIGHT=1</c>.
/// </summary>
public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
// so launch.log stays readable under motion (the per-frame call is a no-op
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
@ -322,6 +364,93 @@ public static class RenderingDiagnostics
/// </summary>
internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0;
// Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty —
// we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0
// = "never emitted" so the first call always fires.
private static long _lastLightEmitTicks;
private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks
/// <summary>
/// #133 A7 — emit ONE rate-limited <c>[light]</c> line describing the
/// current scene-lighting state, followed (when <paramref name="lights"/>
/// is supplied) by up to three <c>[light-detail]</c> lines for the nearest
/// ACTIVE point/spot lights. Cheap no-op when
/// <see cref="ProbeLightEnabled"/> is false; otherwise fires at most
/// once per second. Pull the values from the spot where
/// <c>GameWindow.UpdateSunFromSky</c> set <c>Lighting.CurrentAmbient</c>
/// / <c>Lighting.Sun</c> and where <c>SceneLightingUbo.Build</c> computed
/// the active-slot count.
/// <para>
/// The <c>[light-detail]</c> lines are the answer to the "candle-spotlight"
/// question — they expose each torch's REAL dat-derived runtime values
/// (<c>range=</c> Falloff metres, <c>intensity=</c>, <c>cone=</c> radians,
/// <c>color=</c>, <c>distToViewer=</c>) so it is visible in launch.log
/// whether dungeon torches are tiny-range points or wide cones and at what
/// intensity — without a screenshot:
/// <code>
/// [light-detail] kind=Point range=&lt;Falloff m&gt; intensity=&lt;I&gt; cone=&lt;rad&gt; color=(r,g,b) distToViewer=&lt;m&gt;
/// </code>
/// </para>
/// </summary>
/// <param name="insideCell">The <c>playerInsideCell</c> value driving the indoor branch.</param>
/// <param name="ambientR">Cell ambient red (xyz of <c>uCellAmbient</c>).</param>
/// <param name="ambientG">Cell ambient green.</param>
/// <param name="ambientB">Cell ambient blue.</param>
/// <param name="sunIntensity">The sun <c>LightSource.Intensity</c> (0 indoors).</param>
/// <param name="registeredLights">Total point/spot lights registered with the LightManager.</param>
/// <param name="activeLights"><c>uCellAmbient.w</c> — shader active-slot count (includes the zeroed sun slot indoors).</param>
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
/// <param name="lights">The ticked <c>LightManager</c> (its <c>Active</c> list, sorted nearest-first by the
/// just-completed Tick). When non-null, drives the <c>[light-detail]</c> lines. Optional so existing call
/// sites / tests that only want the aggregate line keep compiling.</param>
public static void EmitLight(bool insideCell,
float ambientR, float ambientG, float ambientB,
float sunIntensity,
int registeredLights,
int activeLights,
uint playerCellId,
AcDream.Core.Lighting.LightManager? lights = null)
{
if (!ProbeLightEnabled) return;
long now = DateTime.UtcNow.Ticks;
if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks)
return;
_lastLightEmitTicks = now;
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}",
insideCell, ambientR, ambientG, ambientB, sunIntensity,
registeredLights, activeLights, playerCellId));
// #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble"
// question. Dump the actual runtime dat-derived values of the nearest
// ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle
// are visible in launch.log (are torch ranges 1m or 10m? points or
// spots? what intensity?). The sun (Directional, slot 0) is skipped —
// it carries no Range/cone meaning. DistSq is already cached by
// LightManager.Tick this frame, so the active list is sorted nearest-
// first; we just take the first few non-directional entries.
if (lights is null) return;
var active = lights.Active;
int shown = 0;
const int MaxDetail = 3;
for (int i = 0; i < active.Length && shown < MaxDetail; i++)
{
var ls = active[i];
if (ls is null) continue;
if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue;
float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f;
Console.WriteLine(string.Format(ci,
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}",
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist));
shown++;
}
}
private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";

View file

@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface
namespace AcDream.Core.World.Cells;
/// <summary>
/// The unified cell graph: the authoritative id-&gt;cell resolver and registry.
/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed
/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209).
/// Worker-thread populated; reads are concurrency-safe.
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (before its
/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local
/// terrain origin for the LandDefs lcoord math. Retail anchor: CObjCell::GetVisible
/// (pseudo_c:308209). Worker-thread populated; reads are concurrency-safe.
/// </summary>
public sealed class CellGraph
{
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new();
/// <summary>Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer).</summary>
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
/// (NPCs never touch it — a per-entity writer was the cottage-doorway "blue-hole"
/// cause); read by the renderer for the player root (GameWindow). Left unchanged when
/// the id isn't yet resolvable in the graph (stale beats null).</summary>
public ObjCell? CurrCell { get; internal set; }
public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);