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:
commit
5ac9d8c19c
53 changed files with 6691 additions and 439 deletions
101
src/AcDream.Core/Lighting/LightBake.cs
Normal file
101
src/AcDream.Core/Lighting/LightBake.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 < 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 > 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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->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->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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=<bool> ambient=(r,g,b) sun=<intensity>
|
||||
/// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id>
|
||||
/// </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=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m>
|
||||
/// </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";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface
|
|||
namespace AcDream.Core.World.Cells;
|
||||
|
||||
/// <summary>
|
||||
/// The unified cell graph: the authoritative id->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->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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue