feat(rendering): port retail building degrade walk

Add exact retail building degrade selection, shared FPS/degrade ownership, complete-body gating, selected-shell submission, ordinary ladder mesh residency, frame-scoped retry rearm, Config controls, installed-DAT census, and lifecycle/allocation proofs.

Reviews: OpenAI retail pass 3 PASS; OpenAI production pass 5 PASS. Gates: Release 0W/0E; focused 285/285; hermetic 16811/16811; InstalledDat 469 pass, 10 documented fail, 1 documented skip; both manifests 30/30.
This commit is contained in:
Erik 2026-09-04 22:29:13 +02:00
parent d0f45f6a99
commit c673f767e9
41 changed files with 1829 additions and 167 deletions

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,7 @@ internal sealed record FrameRootDependencies(
EntityEffectPoseRegistry EffectPoses,
WorldRenderRangeState RenderRange,
RuntimeSettingsController Settings,
BuildingDegradeController BuildingDegrades,
DisplayFramePacingController DisplayFramePacing,
WorldSceneDebugState WorldSceneDebugState,
RetailAlphaQueue RetailAlphaQueue,
@ -506,7 +507,8 @@ internal sealed class FrameRootCompositionPhase
?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."),
d.CellVisibility,
d.PhysicsEngine.ShadowObjects),
d.PhysicsEngine.ShadowObjects,
d.BuildingDegrades),
retailPViewPassExecutor),
retailPViewCells,
worldScenePasses,
@ -798,7 +800,8 @@ internal sealed class FrameRootCompositionPhase
privatePresentation,
live.FrameDiagnostics,
postDiagnostics,
NullRenderFrameFailureRecovery.Instance);
NullRenderFrameFailureRecovery.Instance,
d.BuildingDegrades);
Fault(FrameRootCompositionPoint.RenderRootCreated);
var liveFrameCoordinator = new RetailLiveFrameCoordinator(

View file

@ -66,6 +66,7 @@ internal sealed record InteractionRetainedUiDependencies(
// Save button writes here, same file GameWindow's startup load reads.
string KeyBindingsFilePath,
RuntimeSettingsController Settings,
BuildingDegradeController BuildingDegrades,
GameRuntime Runtime,
IRuntimeCombatAttackOperations CombatAttackOperations,
RuntimeCombatTargetOperationsSlot CombatTargetOperations,
@ -310,6 +311,18 @@ internal interface IInteractionRetainedUiCompositionFactory
internal sealed class RetailInteractionRetainedUiCompositionFactory
: IInteractionRetainedUiCompositionFactory
{
internal static FpsRuntimeBindings CreateFpsBindings(
BuildingDegradeController buildingDegrades,
Func<bool> isVisible)
{
ArgumentNullException.ThrowIfNull(buildingDegrades);
ArgumentNullException.ThrowIfNull(isVisible);
return new FpsRuntimeBindings(
() => buildingDegrades.Fps,
() => buildingDegrades.ActiveMultiplier,
isVisible);
}
public IDisposable BindCombatTarget(
InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) =>
@ -787,9 +800,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.ClientTime),
JumpPowerbar: new JumpPowerbarRuntimeBindings(
() => d.PlayerController.Controller?.JumpCharge ?? default),
Fps: new FpsRuntimeBindings(
() => d.FrameDiagnostics.Snapshot.Fps,
() => 1.0,
Fps: CreateFpsBindings(
d.BuildingDegrades,
() => d.Settings.DisplayPreview.ShowFps),
VividTarget: new VividTargetRuntimeBindings(
d.Actions.Selection,

View file

@ -0,0 +1,173 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering;
/// <summary>
/// Renderer-lifetime owner of retail's FPS history and automatic degrade
/// multiplier. Scene/world replacement never recreates this owner.
/// </summary>
internal sealed class BuildingDegradeController : IBuildingDegradeFrameTick
{
internal const int FpsHistoryLength = 20;
internal const int CandidateHistoryLength = 30;
internal const float MinimumFps = 8f;
internal const float IdealFps = 10f;
internal const float MaximumFps = 20f;
private readonly Func<DisplaySettings> _settings;
private readonly float[] _frameSeconds = new float[FpsHistoryLength];
private readonly float[] _candidateHistory = new float[CandidateHistoryLength];
private float _automaticMultiplier;
private float _fps;
internal BuildingDegradeController(Func<DisplaySettings> settings)
=> _settings = settings ?? throw new ArgumentNullException(nameof(settings));
internal float Fps => _fps;
internal float AutomaticMultiplier => _automaticMultiplier;
internal float DegradeDistance => _settings().DegradeDistance;
internal float ActiveMultiplier
{
get
{
DisplaySettings settings = _settings();
return settings.AutomaticDegrades
? _automaticMultiplier
: settings.GraphicsPerformance;
}
}
/// <summary>SceneTool::UpdateFPSCounter followed by CalcDegLevel. The
/// FPS sum observes the prior 20 samples; the just-finished frame is
/// shifted in only after that calculation.</summary>
internal void Tick(double elapsedSeconds)
{
double total = 0d;
for (int i = 0; i < _frameSeconds.Length; i++)
total += _frameSeconds[i];
_fps = total > 0.000199999995f
? (float)(FpsHistoryLength / total)
: 0f;
AdvanceFrameHistory(_frameSeconds, (float)elapsedSeconds);
_automaticMultiplier = AdvanceAutomaticMultiplier(
_candidateHistory,
_settings().AutomaticDegrades,
_fps,
_automaticMultiplier);
}
void IBuildingDegradeFrameTick.Tick(double elapsedSeconds) => Tick(elapsedSeconds);
/// <summary>Retail's physical history direction: old slots 0..18 move
/// to 1..19 and the just-finished binary32 duration is stored at slot 0.</summary>
internal static void AdvanceFrameHistory(Span<float> history, float frameSeconds)
{
if (history.Length != FpsHistoryLength)
throw new ArgumentException(
$"Retail FPS history must contain exactly {FpsHistoryLength} slots.",
nameof(history));
history[..^1].CopyTo(history[1..]);
history[0] = frameSeconds;
}
/// <summary>Literal state transition of <c>Render::CalcDegLevel</c>:
/// shift first, compare the candidate against all thirty prior values,
/// conditionally commit, then store the resulting current multiplier.</summary>
internal static float AdvanceAutomaticMultiplier(
Span<float> history,
bool automatic,
float fps,
float current)
{
if (history.Length != CandidateHistoryLength)
throw new ArgumentException(
$"Retail degrade history must contain exactly {CandidateHistoryLength} slots.",
nameof(history));
history[1..].CopyTo(history);
if (!automatic)
{
history[^1] = current;
return current;
}
float candidate = CalculateCandidate(fps, current);
bool stable = IsCandidateStable(history, candidate);
if (stable)
current = candidate;
history[^1] = current;
return current;
}
/// <summary>The x87 comparison promotes both binary32 stores and tests
/// their wide difference against retail's qword 0.01 constant.</summary>
internal static bool IsCandidateStable(ReadOnlySpan<float> history, float candidate)
{
if (history.Length != CandidateHistoryLength)
throw new ArgumentException(
$"Retail degrade history must contain exactly {CandidateHistoryLength} slots.",
nameof(history));
for (int i = 0; i < CandidateHistoryLength; i++)
{
if (!(Math.Abs((double)history[i] - candidate) < 0.01))
return false;
}
return true;
}
/// <summary>The exact five-weight candidate calculation from
/// <c>Render::CalcDegLevel @0x0054CAF0</c>, separated so fixed-vector
/// tests do not need to infer the math through controller history.</summary>
internal static float CalculateCandidate(float fps, float current)
{
double fpsWide = fps;
double minimumFps = MinimumFps;
double idealFps = IdealFps;
double maximumFps = MaximumFps;
double w0 = LowShoulder(fpsWide, minimumFps * 0.75,
(minimumFps + idealFps) * 0.5);
double w1 = Triangle(fpsWide, minimumFps,
minimumFps * 0.25 + idealFps * 0.75);
double w2 = Triangle(fpsWide,
(minimumFps + idealFps) * 0.5,
(idealFps + maximumFps) * 0.5);
double w3 = Triangle(fpsWide,
idealFps * 0.75 + maximumFps * 0.25,
maximumFps);
double w4 = HighShoulder(fpsWide,
(idealFps + maximumFps) * 0.5,
maximumFps * 1.25);
double weight = w0 + w1 + w2 + w3 + w4;
float numeratorAfterW0 = (float)((double)-0.150000006f * w0);
float numeratorAfterW1 = (float)(
(double)numeratorAfterW0 - (double)0.02f * w1);
float numeratorAfterW2 = (float)(
(double)numeratorAfterW1 + (double)0f * w2);
float numeratorAfterW3 = (float)(
(double)numeratorAfterW2 + (double)0.01f * w3);
double numerator = (double)numeratorAfterW3 + (double)0.1f * w4;
double adjustment = weight > 0d
? numerator / weight
: 0d;
double candidate = (double)current + adjustment;
if (candidate > 1d)
candidate = 1d;
else if (candidate < -1d)
candidate = -1d;
return (float)candidate;
}
private static double Triangle(double value, double low, double high)
=> Math.Max(0d, 1d - Math.Abs(2d * value - (high + low)) / (high - low));
private static double LowShoulder(double value, double peak, double zero)
=> value < peak ? 1d : Math.Max(0d, 1d - Math.Abs(value - peak) / (zero - peak));
private static double HighShoulder(double value, double zero, double peak)
=> value > peak ? 1d : Math.Max(0d, 1d - Math.Abs(value - peak) / (peak - zero));
}

View file

@ -193,6 +193,7 @@ public sealed class GameWindow :
private Exception? _runFailure;
private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
private readonly BuildingDegradeController _buildingDegrades;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
@ -784,6 +785,8 @@ public sealed class GameWindow :
_applicationPaths.SettingsFile),
log: Console.WriteLine,
characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit);
_buildingDegrades = new BuildingDegradeController(
() => _runtimeSettings.DisplayPreview);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_renderPackRegistry = renderPackRegistry;
@ -1552,6 +1555,7 @@ public sealed class GameWindow :
_localPlayerTeleportSink,
_applicationPaths.KeyBindingsFile,
_runtimeSettings,
_buildingDegrades,
_runtime,
_combatAttackOperations,
_combatTargetOperations,
@ -1746,6 +1750,7 @@ public sealed class GameWindow :
_effectPoses,
_renderRange,
_runtimeSettings,
_buildingDegrades,
_displayFramePacing,
_worldSceneDebugState,
_retailAlphaQueue,

View file

@ -52,6 +52,11 @@ internal interface IRenderFrameLifetime
void EndFrame();
}
internal interface IBuildingDegradeFrameTick
{
void Tick(double elapsedSeconds);
}
internal interface IRenderFrameResourcePhase
{
void Prepare(RenderFrameInput input);
@ -180,6 +185,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
private readonly IRenderFrameDiagnosticsPhase _diagnostics;
private readonly IRenderFramePostDiagnosticsPhase _postDiagnostics;
private readonly IRenderFrameFailureRecovery _recovery;
private readonly IBuildingDegradeFrameTick? _buildingDegrades;
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
@ -189,7 +195,8 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
IPrivatePresentationFramePhase presentation,
IRenderFrameDiagnosticsPhase diagnostics,
IRenderFramePostDiagnosticsPhase postDiagnostics,
IRenderFrameFailureRecovery recovery)
IRenderFrameFailureRecovery recovery,
IBuildingDegradeFrameTick? buildingDegrades = null)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_gpuMeasurement = gpuMeasurement
@ -201,6 +208,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
_postDiagnostics = postDiagnostics
?? throw new ArgumentNullException(nameof(postDiagnostics));
_recovery = recovery ?? throw new ArgumentNullException(nameof(recovery));
_buildingDegrades = buildingDegrades;
}
public RenderFrameOutcome Render(RenderFrameInput input)
@ -212,6 +220,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
if (input.ViewportWidth <= 0 || input.ViewportHeight <= 0)
return RenderFrameOutcome.ZeroArea;
_buildingDegrades?.Tick(input.DeltaSeconds);
_lifetime.BeginFrame();
Exception? renderFailure = null;
try

View file

@ -72,7 +72,8 @@ internal sealed class RetailPViewRenderer
Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows)
ShadowObjectRegistry shadows,
BuildingDegradeController? buildingDegrades = null)
{
_renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow));
@ -85,6 +86,9 @@ internal sealed class RetailPViewRenderer
_walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings,
shadows ?? throw new ArgumentNullException(nameof(shadows)));
_frameWalk = buildingDegrades is null
? new Walk.RetailFrameWalk()
: new Walk.RetailFrameWalk(buildingDegrades);
_walkFlushLandscapeAction = FlushWalkLandscape;
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsFunc = DrawWalkExitSeals;
@ -518,7 +522,7 @@ internal sealed class RetailPViewRenderer
}
}
private readonly Walk.RetailFrameWalk _frameWalk = new();
private readonly Walk.RetailFrameWalk _frameWalk;
/// <summary>S3 chunk 2 (§8.2 B3): the flush half of the former combined
/// <c>ClearWalkInteriorDepth</c> — retail's <c>D3DPolyRender::FlushAlphaList(0f)</c>

View file

@ -68,6 +68,9 @@ public interface IRetailFrameWalkContext : IWalkBuildingFrameContext
/// </summary>
public sealed class RetailFrameWalk
{
private readonly BuildingDegradeController? _degradation;
private readonly float? _fixedDegradeDistance;
private readonly float? _fixedDegradeMultiplier;
// RenderDeviceD3D::Init @0x0059efb0: indoor_pview = PView(…, 1) — the
// interior pview DRAWS the landscape through its surviving exit views.
private readonly WalkPView _interiorPView = new() { DrawLandscape = true };
@ -86,14 +89,19 @@ public sealed class RetailFrameWalk
/// regardless of per-cell visibility.</summary>
public bool AlwaysDrawObjects = true;
/// <summary>Retail global <c>Render::deg_mul</c> — DYNAMIC: the
/// auto-tuner (<c>auto_update_deg_mul</c>) swings it with frame load
/// (positive ⇒ degrade thresholds slide toward each level's max;
/// negative ⇒ toward its min). The oracle captures pin ≈+0.99 for every
/// fixture except doorway-still, whose capture ran under cdb load with
/// the multiplier depressed (the recon session's live dump read 0.99
/// under the same conditions).</summary>
public float DegradeMultiplier = WalkBuilding.DefaultDegradeMultiplier;
public RetailFrameWalk() { }
internal RetailFrameWalk(BuildingDegradeController degradation)
=> _degradation = degradation ?? throw new ArgumentNullException(nameof(degradation));
/// <summary>Fixture-only capture-state constructor. Production always
/// supplies the renderer-lifetime controller; historical retail captures
/// retain their recorded preference/multiplier without becoming defaults.</summary>
internal RetailFrameWalk(float degradeDistance, float degradeMultiplier)
{
_fixedDegradeDistance = degradeDistance;
_fixedDegradeMultiplier = degradeMultiplier;
}
/// <summary>FW4 slice 1: the interior root's <c>outside_view</c> — the
/// exit-view polygons THIS walk's own <c>ConstructView</c> installed
@ -272,8 +280,8 @@ public sealed class RetailFrameWalk
/// portal pass, and the shell draw — sits inside retail's
/// <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c> @0x0059f2d3; a
/// degraded-out slot draws NOTHING beyond the BLD event.
/// <c>HasGeometry</c> + a non-null <c>SelectDrawingBsp</c> together model
/// that one gate. Inside the gate, retail's own order
/// The selected GfxObj id alone models that gate; its drawing BSP is an
/// optional portal-walk input. Inside the gate, retail's own order
/// (@0x0059f30b0x0059f345) is <c>D3DPolyRender::FlushAlphaList(0f)</c> →
/// <c>CPhysicsPart::Draw(parts, 1)</c> (the PORTAL flavor — the two-pass
/// punch/look-in walk below) → <c>CPhysicsPart::Draw(parts, 0)</c> (the
@ -285,47 +293,47 @@ public sealed class RetailFrameWalk
IRetailFrameWalkContext ctx, IWalkEventSink sink)
{
sink.Emit(WalkEvent.Building(building.PositionCellId));
if (!building.HasGeometry) return;
// Retail walks the CURRENT degrade level's drawing BSP
// (part->gfxobj[deg_level]); a degraded-out slot skips everything
// after publishing the portal list.
WalkBspNode? bsp = building.SelectDrawingBsp(
WalkBuildingSelection selection = building.Select(
ctx.ViewerDistanceTo(building),
degradeMultiplier: DegradeMultiplier);
if (bsp is null) return;
_degradation?.DegradeDistance ?? _fixedDegradeDistance ?? 50f,
_degradation?.ActiveMultiplier ?? _fixedDegradeMultiplier ?? 0f);
if (selection.GfxObjId == 0)
return;
// Additive (Campaign FW3.2b-1): the alpha barrier
// (D3DPolyRender::FlushAlphaList(0f) @0x0059f30b) — gated by the
// SAME part->gfxobj[deg_level]!=0 check as everything below it, so
// this fires only now that both HasGeometry and the bsp lookup have
// passed.
// this fires after the selected-id gate, independently of whether
// that selected object's drawing BSP exists.
sink.OnBuildingTurn(building);
int viewCount = Math.Max(activeViews.ViewCount, 0);
var passSink = new PortalPassSink(building, sink);
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
for (int v = 0; v < viewCount; v++)
if (selection.DrawingBsp is WalkBspNode bsp)
{
// Retail pins building_view = the CURRENT view for the whole
// two-pass walk (DrawMeshInternal @0x0059f3bf) — the punch fans
// it emits clip against THAT view, so the sink carries the index.
passSink.ActiveViewIndex = v;
ctx.SetActiveView(activeViews, v);
WalkBuildingPortals.BuildDrawPortalsOnly(
bsp, 1, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
WalkBuildingPortals.BuildDrawPortalsOnly(
bsp, 2, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
int viewCount = Math.Max(activeViews.ViewCount, 0);
var passSink = new PortalPassSink(building, sink);
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
for (int v = 0; v < viewCount; v++)
{
// Retail pins building_view = the CURRENT view for the whole
// two-pass walk (DrawMeshInternal @0x0059f3bf).
passSink.ActiveViewIndex = v;
ctx.SetActiveView(activeViews, v);
WalkBuildingPortals.BuildDrawPortalsOnly(
bsp, 1, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
WalkBuildingPortals.BuildDrawPortalsOnly(
bsp, 2, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
}
}
// Additive (Campaign FW3.2b-1): CPhysicsPart::Draw(parts, 0)
// @0x0059f331 — the building's own shell mesh — runs AFTER the
// portal walk completes (CPhysicsPart::Draw(parts, 1) just above),
// not before it.
sink.OnBuildingShellTurn(building);
sink.OnBuildingShellTurn(building, selection);
}
/// <summary>Returns the extracted cell-id array so <see cref="DrawInside"/>

View file

@ -40,7 +40,21 @@ public sealed class WalkBspNode
/// <summary>One degrade-ladder level: the drawing BSP of that level's
/// GfxObj (portal-only view) and the level's authored distance bands.</summary>
public readonly record struct WalkBuildingDegradeLevel(
float MinDist, float IdealDist, float MaxDist, WalkBspNode? DrawingBsp);
uint GfxObjId,
uint Mode,
float MinDist,
float IdealDist,
float MaxDist,
WalkBspNode? DrawingBsp);
/// <summary>The complete result of retail's part-zero degrade selection.
/// A nonzero GfxObj id is drawable even when its drawing BSP is null; the
/// BSP is only the optional portal-walk input.</summary>
public readonly record struct WalkBuildingSelection(
uint GfxObjId,
WalkBspNode? DrawingBsp,
int Level,
uint Mode);
/// <summary>The walk's building model (retail <c>CBuildingObj</c> +
/// <c>BuildInfo</c> as the frame walk consumes them).</summary>
@ -56,6 +70,11 @@ public sealed class WalkBuilding
/// Used directly when the model has no degrade ladder.</summary>
public WalkBspNode? DrawingBsp;
/// <summary>Part zero's direct/base GfxObj. Zero is retail's authored
/// complete-body sentinel and suppresses the alpha, portal and shell
/// turns after the unconditional BLD publication.</summary>
public uint GfxObjId;
/// <summary>The degrade ladder (near→far). Retail walks the CURRENT
/// level's drawing BSP (<c>part->gfxobj[deg_level]</c>) — and building
/// degrade models beyond the first band carry NO portal nodes, which is
@ -63,27 +82,18 @@ public sealed class WalkBuilding
/// every Holtburg building has ports at level 0 only).</summary>
public WalkBuildingDegradeLevel[] DegradeLevels = [];
/// <summary><c>part->gfxobj[deg_level] != 0</c> — a degraded-out slot
/// skips the whole building AFTER publishing the portal list.</summary>
public bool HasGeometry = true;
/// <summary>The base GfxObj's sort center — retail measures the viewer
/// distance to the part's SCALED sort center, not the position origin
/// (<c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030).</summary>
public Vector3 SortCenter;
/// <summary>Retail <c>Render::s_rDegradeDistance</c> — a subtractive
/// slack before the ladder applies (live-dumped 100 on the capture
/// client; registry-configurable).</summary>
public const float DefaultDegradeDistance = 100f;
/// <summary>Part-zero Setup resting/default-scale transform. Direct Gfx
/// buildings use identity.</summary>
public Matrix4x4 PartZeroTransform = Matrix4x4.Identity;
/// <summary>The live capture client's <c>Render::deg_mul</c> arm:
/// magnitude 0.99 pinned by the walkout-F2 fixture (building a9b4001e,
/// effective 30.3, level 0 ideal/max 24/48 — retail floods, so its
/// threshold ≈ max ⇒ the POSITIVE arm; the recon note's "0.99" sign was
/// a misread — the negative arm's threshold ≈ min contradicts the
/// fixture from both directions). Re-dump at the next retail session.</summary>
public const float DefaultDegradeMultiplier = 0.99f;
/// <summary>The selected part scale's Z component used by retail's
/// UpdateViewerDistance division.</summary>
public float PartZeroScaleZ = 1f;
/// <summary>
/// <c>GfxObjDegradeInfo::get_degrade</c> @0x0051e4b0 (BN flag-mush on
@ -96,22 +106,41 @@ public sealed class WalkBuilding
/// mul is <c>Render::deg_mul</c> when <c>auto_update_deg_mul</c> is on,
/// else <c>s_rUserSuppliedDegradeBias</c>.
/// </summary>
public WalkBspNode? SelectDrawingBsp(
public WalkBuildingSelection Select(
float viewerDistance,
float degradeDistance = DefaultDegradeDistance,
float degradeMultiplier = DefaultDegradeMultiplier)
float degradeDistance,
float degradeMultiplier,
bool degradesDisabled = false,
int forcedLevel = -1)
{
if (DegradeLevels.Length == 0) return DrawingBsp;
if (DegradeLevels.Length == 0)
return new WalkBuildingSelection(GfxObjId, DrawingBsp, 0, 1u);
if (degradesDisabled)
return SelectionAt(0);
if (forcedLevel >= 0)
return SelectionAt(Math.Min(forcedLevel, DegradeLevels.Length - 1));
float effective = MathF.Max(0f, MathF.Abs(viewerDistance) - degradeDistance);
foreach (WalkBuildingDegradeLevel level in DegradeLevels)
for (int i = 0; i < DegradeLevels.Length; i++)
{
float threshold = degradeMultiplier >= 0f
? level.IdealDist - (level.IdealDist - level.MaxDist) * degradeMultiplier
: level.IdealDist + (level.IdealDist - level.MinDist) * degradeMultiplier;
WalkBuildingDegradeLevel level = DegradeLevels[i];
double threshold = degradeMultiplier >= 0f
? (double)level.IdealDist
- ((double)level.IdealDist - level.MaxDist) * degradeMultiplier
: (double)level.IdealDist
+ ((double)level.IdealDist - level.MinDist) * degradeMultiplier;
if (effective < threshold)
return level.DrawingBsp;
return new WalkBuildingSelection(
level.GfxObjId, level.DrawingBsp, i, level.Mode);
}
return SelectionAt(DegradeLevels.Length - 1);
WalkBuildingSelection SelectionAt(int index)
{
WalkBuildingDegradeLevel level = DegradeLevels[index];
return new WalkBuildingSelection(
level.GfxObjId, level.DrawingBsp, index, level.Mode);
}
return DegradeLevels[^1].DrawingBsp;
}
}

View file

@ -1,5 +1,7 @@
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -37,7 +39,21 @@ public static class WalkBuildingFactory
/// placement transform (<paramref name="WorldTransform"/> already has the
/// caller's block offset baked in — see <see cref="Build"/>).</summary>
public sealed record Entry(
WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform);
WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform)
{
public Matrix4x4 PartZeroWorldTransform { get; } =
Building.PartZeroTransform * WorldTransform;
public Matrix4x4 InversePartZeroWorldTransform { get; } =
Invert(Building.PartZeroTransform * WorldTransform);
private static Matrix4x4 Invert(Matrix4x4 value)
{
if (!Matrix4x4.Invert(value, out Matrix4x4 inverse))
throw new InvalidOperationException("A building part-zero transform is not invertible.");
return inverse;
}
}
/// <summary>Builds every building of one landblock. <paramref name="lbOffset"/>
/// is the SAME landblock-local world offset every other production
@ -84,8 +100,32 @@ public static class WalkBuildingFactory
WalkBspNode? bsp = null;
Vector3 sortCenter = Vector3.Zero;
uint partZeroGfxObjId = 0;
Matrix4x4 partZeroTransform = Matrix4x4.Identity;
float partZeroScaleZ = 1f;
var degradeLevels = new List<WalkBuildingDegradeLevel>();
if (dats.Get<GfxObj>(buildingInfo.ModelId) is GfxObj gfxObj)
if (dats.Get<GfxObj>(buildingInfo.ModelId) is GfxObj directGfxObj)
{
partZeroGfxObjId = buildingInfo.ModelId;
PopulateGfx(directGfxObj);
}
else if (dats.Get<Setup>(buildingInfo.ModelId) is Setup setup)
{
IReadOnlyList<MeshRef> parts = SetupMesh.Flatten(setup);
if (parts.Count > 0)
{
MeshRef partZero = parts[0];
partZeroGfxObjId = partZero.GfxObjId;
partZeroTransform = partZero.PartTransform;
partZeroScaleZ = setup.DefaultScale.Count > 0
? setup.DefaultScale[0].Z
: 1f;
if (dats.Get<GfxObj>(partZero.GfxObjId) is GfxObj setupGfxObj)
PopulateGfx(setupGfxObj);
}
}
void PopulateGfx(GfxObj gfxObj)
{
bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
sortCenter = new Vector3(gfxObj.SortCenter.X, gfxObj.SortCenter.Y, gfxObj.SortCenter.Z);
@ -102,7 +142,13 @@ public static class WalkBuildingFactory
levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
}
degradeLevels.Add(
new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp));
new WalkBuildingDegradeLevel(
(uint)level.Id,
level.DegradeMode,
level.MinDist,
level.IdealDist,
level.MaxDist,
levelBsp));
}
}
}
@ -116,9 +162,12 @@ public static class WalkBuildingFactory
{
PositionCellId = positionCellId,
Portals = portals,
GfxObjId = partZeroGfxObjId,
DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter,
PartZeroTransform = partZeroTransform,
PartZeroScaleZ = partZeroScaleZ,
},
worldTransform,
inverse));

View file

@ -176,9 +176,8 @@ public interface IWalkEventSink
/// draw) in <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c>
/// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the
/// unconditional <see cref="WalkEventKind.Building"/> <see cref="Emit"/>
/// call. <see cref="WalkBuilding.HasGeometry"/> and a non-null
/// <see cref="WalkBuilding.SelectDrawingBsp"/> result together model that
/// one gate, so this hook fires only after both have passed. This is the
/// call. The selected GfxObj id alone models that gate; a null drawing
/// BSP only suppresses the portal sub-walk. This is the
/// ALPHA BARRIER turn (<c>D3DPolyRender::FlushAlphaList(0f)</c>
/// @0x0059f30b) — retail's own order runs it BEFORE the portal pass
/// (<c>CPhysicsPart::Draw(parts, 1)</c>), which in turn runs BEFORE the
@ -198,7 +197,9 @@ public interface IWalkEventSink
/// fires when <see cref="OnBuildingTurn"/> also fired (same gate; see its
/// doc comment) — a degraded-out slot reaches neither. Default no-op.
/// </summary>
void OnBuildingShellTurn(WalkBuilding building) { }
void OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection) { }
/// <summary>
/// Fires when the two-pass portal machinery

View file

@ -81,7 +81,8 @@ internal interface IWalkFrameWorldData
/// <summary>Building-local → world, for transforming a punch polygon
/// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the
/// production implementation is <see cref="WalkBuildingRegistry.TryGetEntry"/>'s
/// <c>WorldTransform</c> (FW3.2b-2 wiring).</summary>
/// <c>PartZeroWorldTransform</c> (the Setup part-zero frame already composed
/// with the building root).</summary>
Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
}
@ -1590,7 +1591,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_currentDcStage = WalkDrawStage.LookInStatic;
}
void IWalkEventSink.OnBuildingShellTurn(WalkBuilding building)
void IWalkEventSink.OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection)
{
ArgumentNullException.ThrowIfNull(building);
RequireOpenFrame();
@ -1602,10 +1605,27 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// look-in flood appended (keeps every range single-stage).
MarkIfGrown();
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
_populator.PopulateCell(
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
alphaSubmissions: _alphaSubmissions);
if (shell.Records.Count > 1)
{
throw new InvalidOperationException(
$"Building 0x{building.PositionCellId:X8} has "
+ $"{shell.Records.Count} retained shell records; expected at most one.");
}
if (shell.Records.Count == 1)
{
ref readonly RenderProjectionRecord record =
ref shell.Records.AsSpan()[0];
_populator.PopulateBuildingShell(
_stream,
building.PositionCellId,
in record,
shell.TupleLandblockId,
_cameraWorldPosition,
_viewProjection,
in selection,
building.PartZeroTransform,
_alphaSubmissions);
}
if (_alphaSubmissions.Count != _alphaSubmitMark)
{
MarkIfGrown();

View file

@ -173,21 +173,24 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame
}
public Vector3 ViewpointInBuilding(WalkBuilding building)
=> Vector3.Transform(WorldViewpoint, GetEntry(building).InverseWorldTransform);
=> Vector3.Transform(WorldViewpoint, GetEntry(building).InversePartZeroWorldTransform);
/// <summary><c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030: the
/// distance to the part's SCALED sort center, not the position origin.</summary>
public float ViewerDistanceTo(WalkBuilding building)
{
WalkBuildingFactory.Entry entry = GetEntry(building);
return Vector3.Distance(
WorldViewpoint, Vector3.Transform(building.SortCenter, entry.WorldTransform));
float distance = Vector3.Distance(
WorldViewpoint,
Vector3.Transform(building.SortCenter, entry.PartZeroWorldTransform));
return distance / building.PartZeroScaleZ;
}
public int ClipBuildingPolygon(
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
{
Matrix4x4 objectToClip = GetEntry(building).WorldTransform * _viewProjection;
Matrix4x4 objectToClip =
GetEntry(building).PartZeroWorldTransform * _viewProjection;
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
for (int i = 0; i < polygon.Vertices.Length; i++)
{

View file

@ -543,6 +543,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
throw new InvalidOperationException(
$"walk building 0x{building.PositionCellId:X8} has no committed registry entry");
}
return entry.WorldTransform;
return entry.PartZeroWorldTransform;
}
}

View file

@ -82,6 +82,34 @@ internal sealed class WalkStaticStreamPopulator
}
}
/// <summary>Classifies exactly the selected part-zero GfxObj against the
/// one retained building-shell record. Its retained identity/material/
/// palette/effect/picking payload is borrowed unchanged; only the mesh
/// id and precomputed part-zero transform come from degrade selection.</summary>
internal void PopulateBuildingShell(
OrderedDrawStream stream,
uint cellId,
in RenderProjectionRecord record,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection,
in WalkBuildingSelection selection,
Matrix4x4 partZeroTransform,
List<WbDrawDispatcher.WalkClassifiedBatch> alphaSubmissions)
{
ClassifyAndAppend(
stream,
WalkDrawStage.BuildingShell,
cellId,
in record,
tupleLandblockId,
cameraWorldPosition,
viewProjection,
alphaSubmissions: alphaSubmissions,
buildingSelection: selection,
buildingPartTransform: partZeroTransform);
}
/// <summary>
/// The landscape entry point: outdoor static content
/// (<c>RenderProjectionClass.OutdoorStatic</c> —
@ -275,7 +303,9 @@ internal sealed class WalkStaticStreamPopulator
bool liveDynamic = false,
IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null)
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null,
WalkBuildingSelection? buildingSelection = null,
Matrix4x4 buildingPartTransform = default)
{
_batchScratch.Clear();
_selectionScratch.Clear();
@ -288,7 +318,9 @@ internal sealed class WalkStaticStreamPopulator
lookInViews,
lookInRouteIndex,
cellId,
diagnosticViewProjection: viewProjection);
diagnosticViewProjection: viewProjection,
buildingSelection: buildingSelection,
buildingPartTransform: buildingPartTransform);
for (int i = 0; i < _batchScratch.Count; i++)
{

View file

@ -44,6 +44,7 @@ public sealed class EnvCellLandblockBuild
VisibilityCells = visibilityCells.ToImmutableArray();
Shells = shells.ToImmutableArray();
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
WalkBuildingMeshDependencies = CollectWalkBuildingMeshDependencies(WalkBuildings);
WalkMaxZ = walkMaxZ;
WalkMinZ = walkMinZ;
@ -67,6 +68,14 @@ public sealed class EnvCellLandblockBuild
/// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary>
public ImmutableArray<WalkBuildingFactory.Entry> WalkBuildings { get; }
/// <summary>
/// Unique nonzero GfxObj ids referenced by the buildings' degrade ladders.
/// This worker-built immutable carrier is acquired as ordinary mesh
/// ownership with the containing near-tier landblock; EnvCell shell ids
/// remain on the separate prepared-geometry path.
/// </summary>
public ImmutableArray<ulong> WalkBuildingMeshDependencies { get; }
/// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block
/// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>).
@ -77,6 +86,24 @@ public sealed class EnvCellLandblockBuild
/// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[minByte] 1</c>). See <see cref="WalkMaxZ"/>.</summary>
public float WalkMinZ { get; }
private static ImmutableArray<ulong> CollectWalkBuildingMeshDependencies(
ImmutableArray<WalkBuildingFactory.Entry> buildings)
{
var seen = new HashSet<uint>();
var dependencies = ImmutableArray.CreateBuilder<ulong>();
for (int buildingIndex = 0; buildingIndex < buildings.Length; buildingIndex++)
{
WalkBuildingDegradeLevel[] levels = buildings[buildingIndex].Building.DegradeLevels;
for (int levelIndex = 0; levelIndex < levels.Length; levelIndex++)
{
uint id = levels[levelIndex].GfxObjId;
if (id != 0 && seen.Add(id))
dependencies.Add(id);
}
}
return dependencies.ToImmutable();
}
}
/// <summary>

View file

@ -76,7 +76,9 @@ public sealed class LandblockSpawnAdapter
/// </summary>
public void OnLandblockLoaded(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalReadinessIds = null)
IEnumerable<ulong>? additionalReadinessIds = null,
IEnumerable<ulong>? additionalOrdinaryIds = null,
bool replaceExisting = false)
{
System.ArgumentNullException.ThrowIfNull(landblock);
@ -90,6 +92,8 @@ public sealed class LandblockSpawnAdapter
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
}
if (additionalOrdinaryIds is not null)
unique.UnionWith(additionalOrdinaryIds.Where(static id => id != 0));
HashSet<ulong>? preparedIds = additionalReadinessIds is null
? null
@ -110,6 +114,11 @@ public sealed class LandblockSpawnAdapter
MarkAllUndesired(registration.Prepared);
registration.WantsLoaded = true;
}
else if (replaceExisting)
{
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
}
MarkDesired(registration.Ordinary, unique);
if (preparedIds is not null)

View file

@ -47,11 +47,9 @@ namespace AcDream.App.Rendering.Wb;
/// (<c>TranslucencyFadeManager</c>/<c>EntityOpacity</c>) is NOT threaded
/// through: that mechanic is <c>TransparentPartHook</c>, a retail LIVE-entity
/// behavior keyed by ServerGuid, not something world statics undergo — every
/// classified batch here carries <c>Alpha = 1f</c>. Likewise the async
/// mesh-miss self-heal request (<c>_missRequested</c>/<c>EnsureLoaded</c>,
/// frame-scoped state cleared by <c>BeginEntityDispatch</c>) is not fired
/// here — this seam has no production frame to be scoped to yet; a missing
/// mesh is simply skipped, matching every other unwired FW2/FW3.2a path.</para>
/// classified batch here carries <c>Alpha = 1f</c>. A mesh miss uses the
/// existing frame-scoped <c>_missRequested</c>/<c>EnsureLoaded</c> seam and
/// skips this frame; the caller retries naturally on the following frame.</para>
/// </summary>
public sealed partial class WbDrawDispatcher
{
@ -93,6 +91,7 @@ public sealed partial class WbDrawDispatcher
"A walk part frame was opened before the previous scope closed.");
}
_missRequested.Clear();
_walkDrawnParts.Clear();
BeginFacilityStairSubmissionProbeFrame();
_walkPartFrameActive = true;
@ -274,7 +273,9 @@ public sealed partial class WbDrawDispatcher
IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1,
uint lookInCellId = 0,
Matrix4x4 diagnosticViewProjection = default)
Matrix4x4 diagnosticViewProjection = default,
WalkBuildingSelection? buildingSelection = null,
Matrix4x4 buildingPartTransform = default)
{
ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts);
@ -316,12 +317,22 @@ public sealed partial class WbDrawDispatcher
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
IReadOnlyList<MeshRef>? meshRefs = projection.EntityPayload.MeshRefs;
if (meshRefs is null)
if (buildingSelection is null && meshRefs is null)
return;
for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++)
IReadOnlyDictionary<uint, uint>? buildingSurfaceOverrides =
buildingSelection is not null && meshRefs is { Count: > 0 }
? meshRefs[0].SurfaceOverrides
: null;
int meshRefCount = buildingSelection is null ? meshRefs!.Count : 1;
for (int partIndex = 0; partIndex < meshRefCount; partIndex++)
{
MeshRef meshRef = meshRefs[partIndex];
MeshRef meshRef = buildingSelection is WalkBuildingSelection selected
? new MeshRef(selected.GfxObjId, buildingPartTransform)
{
SurfaceOverrides = buildingSurfaceOverrides,
}
: meshRefs![partIndex];
if (_meshAdapter.IsRuntimeHiddenMarker(meshRef.GfxObjId))
continue;
@ -333,7 +344,9 @@ public sealed partial class WbDrawDispatcher
continue;
}
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
if (buildingSelection is null
&& renderData.IsSetup
&& renderData.SetupParts.Count > 0)
{
// Same entity-scoped OR the classic/packed classifiers compute
// — a Setup composite's parts are separate GfxObjs with their

View file

@ -22,7 +22,9 @@ internal sealed record GpuLandblockSpatialPublication(
IReadOnlyList<ulong> AdditionalRenderIds,
IReadOnlyList<WorldEntity> StaticEntities,
bool RequiresActivation = true,
uint RenderTraversalOrder = 0);
uint RenderTraversalOrder = 0,
IReadOnlyList<ulong>? AdditionalOrdinaryRenderIds = null,
bool ReplacesMeshRegistration = false);
/// <summary>
/// Render-thread-owned registry of currently-loaded landblocks and their
@ -975,13 +977,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds,
LandblockStreamTier tier) =>
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
LandblockStreamTier tier,
IEnumerable<ulong>? additionalOrdinaryRenderIds = null) =>
CommitLandblockSpatialCore(
landblock,
additionalRenderIds,
tier,
additionalOrdinaryRenderIds);
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds,
LandblockStreamTier tier)
LandblockStreamTier tier,
IEnumerable<ulong>? additionalOrdinaryRenderIds = null)
{
ArgumentNullException.ThrowIfNull(landblock);
@ -1036,8 +1044,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
tier = LandblockStreamTier.Near;
}
bool replacesMeshRegistration = false;
if (RemoveLoadedLandblock(landblock.LandblockId, out displaced))
{
replacesMeshRegistration = true;
RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId);
}
@ -1048,7 +1058,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
return CreateSpatialPublication(
_loaded[landblock.LandblockId],
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>(),
GetRenderTraversalOrder(landblock.LandblockId));
GetRenderTraversalOrder(landblock.LandblockId),
additionalOrdinaryRenderIds,
replacesMeshRegistration);
}
/// <summary>
@ -1099,7 +1111,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_wbSpawnAdapter?.OnLandblockLoaded(
publication.Landblock,
publication.AdditionalRenderIds);
publication.AdditionalRenderIds,
publication.AdditionalOrdinaryRenderIds,
publication.ReplacesMeshRegistration);
// C.1.5b: fire DefaultScript for dat-hydrated entities only.
// EntityScriptActivator owns a retryable per-static acquisition ledger,
@ -1115,10 +1129,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private static GpuLandblockSpatialPublication CreateSpatialPublication(
LoadedLandblock landblock,
IEnumerable<ulong> additionalRenderIds,
uint renderTraversalOrder)
uint renderTraversalOrder,
IEnumerable<ulong>? additionalOrdinaryRenderIds = null,
bool replacesMeshRegistration = false)
{
ulong[] renderIds = additionalRenderIds as ulong[]
?? additionalRenderIds.ToArray();
ulong[] ordinaryRenderIds = additionalOrdinaryRenderIds as ulong[]
?? additionalOrdinaryRenderIds?.ToArray()
?? Array.Empty<ulong>();
WorldEntity[] staticEntities = landblock.Entities
.Where(static entity => entity.ServerGuid == 0)
.ToArray();
@ -1127,7 +1146,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblock,
renderIds,
staticEntities,
RenderTraversalOrder: renderTraversalOrder);
RenderTraversalOrder: renderTraversalOrder,
AdditionalOrdinaryRenderIds: ordinaryRenderIds,
ReplacesMeshRegistration: replacesMeshRegistration);
}
/// <summary>
@ -2014,6 +2035,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblockId,
entities,
additionalRenderIds,
additionalOrdinaryRenderIds: null,
parkIfMissing: true);
if (publication is null)
return false;
@ -2025,13 +2047,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial(
uint landblockId,
IReadOnlyList<WorldEntity> entities,
IEnumerable<ulong>? additionalRenderIds)
IEnumerable<ulong>? additionalRenderIds,
IEnumerable<ulong>? additionalOrdinaryRenderIds = null)
{
GpuLandblockSpatialPublication? publication =
CommitEntitiesToExistingLandblockSpatialCore(
landblockId,
entities,
additionalRenderIds,
additionalOrdinaryRenderIds,
parkIfMissing: false);
return publication
?? throw new InvalidOperationException(
@ -2042,6 +2066,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
uint landblockId,
IReadOnlyList<WorldEntity> entities,
IEnumerable<ulong>? additionalRenderIds,
IEnumerable<ulong>? additionalOrdinaryRenderIds,
bool parkIfMissing)
{
ArgumentNullException.ThrowIfNull(entities);
@ -2113,7 +2138,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loaded[canonical],
effectiveRenderIds,
staticEntities,
RenderTraversalOrder: GetRenderTraversalOrder(canonical));
RenderTraversalOrder: GetRenderTraversalOrder(canonical),
AdditionalOrdinaryRenderIds:
additionalOrdinaryRenderIds?.ToArray() ?? Array.Empty<ulong>());
}
private void DrainVisibilityTransitions()

View file

@ -957,6 +957,8 @@ public sealed class LandblockPresentationPipeline
IEnumerable<ulong>? renderIds =
transaction.Build.EnvCells?.Shells.Select(
static shell => shell.GeometryId);
IEnumerable<ulong>? ordinaryRenderIds =
transaction.Build.EnvCells?.WalkBuildingMeshDependencies;
transaction.SpatialPublication = transaction.Kind switch
{
PublicationKind.Loaded
@ -965,12 +967,14 @@ public sealed class LandblockPresentationPipeline
_state.CommitLandblockSpatial(
transaction.Build.Landblock,
renderIds,
transaction.Tier),
transaction.Tier,
ordinaryRenderIds),
PublicationKind.PromoteExisting =>
_state.CommitEntitiesToExistingLandblockSpatial(
transaction.LandblockId,
transaction.Build.Landblock.Entities,
renderIds),
renderIds,
ordinaryRenderIds),
_ => throw new InvalidOperationException(
$"Unknown landblock publication kind {transaction.Kind}."),
};

View file

@ -118,11 +118,14 @@ namespace AcDream.App.UI.Layout;
/// Brightness (OP6 rework, review S2: its OWN <c>DisplaySettings.ScreenBrightness</c>
/// field, range [-1,1] default 0 — NOT the pre-existing <c>Gamma</c>
/// multiplier, a different unit system with its own live legacy-panel
/// consumer; no gamma-correction render pass exists for either); Automatic
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail-
/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate
/// <c>QualityPreset</c>, no per-feature knobs). Building Detail Textures is
/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass;
/// consumer; no gamma-correction render pass exists for either); Landscape
/// Texture Detail/Environment Texture Detail/Texture Filtering/Multi-Pass
/// Alpha (the renderer is Vulkan + one aggregate <c>QualityPreset</c>, no
/// per-feature knobs). Automatic Degrades, Graphics Performance, and Degrade
/// Distance are LIVE through S5-c3's shared building-degrade owner. Landscape
/// Draw Distance is LIVE through <c>ApplyLandscapeDrawDistance</c>. Building
/// Detail Textures is LIVE: #226 consumes it directly in the retail
/// building/EnvCell detail pass;
/// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to
@ -138,8 +141,8 @@ namespace AcDream.App.UI.Layout;
///
/// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> Every
/// STORE-ONLY row above (the 20 rows named in the paragraph before this one
/// -- AP-198's nine, AP-199's three, and TS-74's six Camera/Input rows plus
/// STORE-ONLY row above (the 16 rows named in the paragraph before this one
/// -- AP-198's five, AP-199's three, and TS-74's six Camera/Input rows plus
/// AP-200's two Chat-font rows) renders its caption in
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> instead of the normal
/// white/DAT-authored color. The row stays fully interactive -- it still
@ -1231,14 +1234,14 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().AutomaticDegrades,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }),
storeOnly: true); // AP-198
storeOnly: false);
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().GraphicsPerformance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }),
storeOnly: true, // AP-198
storeOnly: false,
rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail");
BuildSliderRow(
@ -1246,7 +1249,7 @@ public static class ConfigOptionsPageController
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
read: () => bindings.LoadDisplay().DegradeDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }),
storeOnly: true, // AP-198
storeOnly: false,
rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far");
display = bindings.LoadDisplay();

View file

@ -156,11 +156,10 @@ public sealed record DisplaySettings(
bool ShowFps,
QualityPreset Quality,
ParticleRange ParticleRange,
// Campaign OP slice OP6: the Config tab's "Graphics Options" +
// "Rendering Quality Options" rows with no acdream renderer consumer —
// the world renderer is Vulkan + one aggregate QualityPreset, not
// per-feature knobs (register row, OP6). Persisted faithfully; every
// default below is retail's own byte-verified
// Campaign OP slice OP6: Config's graphics preferences. S5-c3 makes
// AutomaticDegrades, GraphicsPerformance and DegradeDistance live for
// building part-zero selection; the remaining per-feature knobs stay
// store-only under AP-198. Every default below is retail's byte-verified
// gmClient::InitUIPreferences / gmConfigUI::InitOptions literal.
//
// OP6 rework (2026-08-11, review S2): Screen Brightness gets its OWN

View file

@ -17,6 +17,7 @@ using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Composition;
@ -36,6 +37,29 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
];
[Fact]
public void FpsPanelBorrowsTheExactRendererLifetimeDegradeOwner()
{
DisplaySettings settings = DisplaySettings.Default with
{
AutomaticDegrades = false,
GraphicsPerformance = -0.35f,
};
var owner = new BuildingDegradeController(() => settings);
for (int i = 0; i < 21; i++)
owner.Tick(0.05);
FpsRuntimeBindings bindings =
RetailInteractionRetainedUiCompositionFactory.CreateFpsBindings(
owner, () => true);
Assert.Equal(owner.Fps, bindings.FramesPerSecond());
Assert.Equal(-0.35, bindings.DegradeMultiplier(), 6);
settings = settings with { GraphicsPerformance = 0.65f };
Assert.Equal(0.65, bindings.DegradeMultiplier(), 6);
Assert.True(bindings.IsVisible());
}
[Fact]
public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter()
{
@ -278,6 +302,8 @@ public sealed class InteractionRetainedUiCompositionTests
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
KeyBindingsFilePath: "keybinds.json",
Settings: null!,
BuildingDegrades: new BuildingDegradeController(
() => DisplaySettings.Default),
Runtime: runtime,
CombatAttackOperations: new NoopCombatOperations(),
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),

View file

@ -0,0 +1,228 @@
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
[Collection(ThreadSchedulingCollection.Name)]
public sealed class BuildingDegradeControllerTests
{
[Fact]
public void FpsUsesPriorTwentyFramesThenInsertsJustFinishedFrame()
{
DisplaySettings settings = DisplaySettings.Default;
var controller = new BuildingDegradeController(() => settings);
for (int i = 0; i < 20; i++)
controller.Tick(0.05);
Assert.Equal(20f / 0.95f, controller.Fps, 4);
controller.Tick(0.05);
Assert.Equal(20f, controller.Fps, 4);
}
[Fact]
public void FpsWideSumNarrowsOnceAtStoreAndMatchesRetailBits()
{
DisplaySettings settings = DisplaySettings.Default;
var controller = new BuildingDegradeController(() => settings);
for (int i = 0; i < 10; i++)
{
controller.Tick(0.001d);
controller.Tick(0.005d);
}
controller.Tick(0d);
Assert.Equal(0x43A6AAABu, BitConverter.SingleToUInt32Bits(controller.Fps));
}
[Fact]
public void FpsHistoryMovesOldSlotsUpAndStoresCurrentAtSlotZero()
{
float[] history = Enumerable.Range(1, 20).Select(static value => (float)value).ToArray();
BuildingDegradeController.AdvanceFrameHistory(history, 99f);
Assert.Equal(99f, history[0]);
for (int i = 1; i < history.Length; i++)
Assert.Equal((float)i, history[i]);
}
[Fact]
public void ManualAndAutomaticModesUseTheSamePersistedSettingsWithoutResettingAutoHistory()
{
DisplaySettings settings = DisplaySettings.Default with
{
AutomaticDegrades = true,
GraphicsPerformance = -0.4f,
DegradeDistance = 73f,
};
var controller = new BuildingDegradeController(() => settings);
for (int i = 0; i < 51; i++)
controller.Tick(1d / 14d);
float automatic = controller.AutomaticMultiplier;
Assert.True(automatic > 0f);
Assert.Equal(automatic, controller.ActiveMultiplier);
Assert.Equal(73f, controller.DegradeDistance);
settings = settings with { AutomaticDegrades = false };
controller.Tick(0.05);
Assert.Equal(-0.4f, controller.ActiveMultiplier);
Assert.Equal(automatic, controller.AutomaticMultiplier);
settings = settings with { AutomaticDegrades = true };
Assert.Equal(automatic, controller.ActiveMultiplier);
}
[Fact]
public void WarmedTickIsAllocationFreeAndExceptionalDeltasStayDefined()
{
DisplaySettings settings = DisplaySettings.Default with
{
AutomaticDegrades = true,
};
var controller = new BuildingDegradeController(() => settings);
for (int i = 0; i < 100; i++)
controller.Tick(1d / 60d);
controller.Tick(0d);
controller.Tick(double.NaN);
controller.Tick(double.PositiveInfinity);
controller.Tick(double.NegativeInfinity);
long allocated = ZeroAllocationProbe.MeasureWarmed(
() => controller.Tick(1d / 60d),
batchSize: 10_000,
warmupBatches: 2,
samples: 5);
Assert.Equal(0, allocated);
Assert.InRange(controller.AutomaticMultiplier, -1f, 1f);
}
[Theory]
[InlineData(0d, 0x41A86BCAu)]
[InlineData(0.00001d, 0x41A86B56u)]
[InlineData(double.NaN, 0u)]
[InlineData(double.PositiveInfinity, 0u)]
[InlineData(double.NegativeInfinity, 0u)]
public void ZeroTinyAndExceptionalDeltasKeepRetailFloatHistoryWithoutSanitizing(
double exceptionalDelta,
uint expectedFollowingFpsBits)
{
DisplaySettings settings = DisplaySettings.Default with
{
AutomaticDegrades = true,
};
var controller = new BuildingDegradeController(() => settings);
for (int i = 0; i < 20; i++)
controller.Tick(0.05d);
controller.Tick(exceptionalDelta);
Assert.Equal(0x41A00000u, BitConverter.SingleToUInt32Bits(controller.Fps));
Assert.Equal(0u, BitConverter.SingleToUInt32Bits(controller.AutomaticMultiplier));
controller.Tick(0.05d);
Assert.Equal(expectedFollowingFpsBits, BitConverter.SingleToUInt32Bits(controller.Fps));
Assert.Equal(0u, BitConverter.SingleToUInt32Bits(controller.AutomaticMultiplier));
}
[Fact]
public void NewControllerStartsAtRetailZeroWithoutSharingHistory()
{
DisplaySettings settings = DisplaySettings.Default with
{
AutomaticDegrades = true,
};
var first = new BuildingDegradeController(() => settings);
for (int i = 0; i < 51; i++)
first.Tick(1d / 14d);
var restarted = new BuildingDegradeController(() => settings);
Assert.NotEqual(0f, first.AutomaticMultiplier);
Assert.Equal(0f, restarted.AutomaticMultiplier);
Assert.Equal(0f, restarted.Fps);
}
[Theory]
[InlineData(0f, -0.150000006f)]
[InlineData(10f, 0f)]
[InlineData(14f, 0.0054545454f)]
[InlineData(20f, 0.1f)]
[InlineData(25f, 0.1f)]
public void ExactFiveWeightFormulaMatchesFixedRetailVectors(
float fps,
float expected)
{
Assert.Equal(
expected,
BuildingDegradeController.CalculateCandidate(fps, 0f),
7);
}
[Fact]
public void FiveWeightFormulaKeepsX87IntermediatesUntilCandidateStore()
{
float candidate = BuildingDegradeController.CalculateCandidate(14f, 0f);
Assert.Equal(0x3BB2BC0Au, BitConverter.SingleToUInt32Bits(candidate));
}
[Fact]
public void FiveWeightNumeratorPreservesAllFourRetailFloatStores()
{
float candidate = BuildingDegradeController.CalculateCandidate(16.25f, 0f);
Assert.Equal(0x3CA3D70Au, BitConverter.SingleToUInt32Bits(candidate));
}
[Fact]
public void StabilityUsesQwordPointZeroOneRatherThanPromotedFloatConstant()
{
float candidate = BitConverter.UInt32BitsToSingle(0x3C13D70Au);
float prior = -BitConverter.UInt32BitsToSingle(0x3A800001u);
float[] history = Enumerable.Repeat(prior, 30).ToArray();
double difference = Math.Abs((double)prior - candidate);
Assert.True(difference > (double)0.01f);
Assert.True(difference < 0.01);
Assert.True(BuildingDegradeController.IsCandidateStable(history, candidate));
}
[Fact]
public void AutomaticCommitRequiresAllThirtyPriorSlotsWithinStrictBand()
{
float[] history = Enumerable.Repeat(0.1f, 30).ToArray();
history[15] = 0f;
float rejected = BuildingDegradeController.AdvanceAutomaticMultiplier(
history, automatic: true, fps: 20f, current: 0f);
Assert.Equal(0f, rejected);
Assert.Equal(0f, history[^1]);
Array.Fill(history, 0.1f);
float committed = BuildingDegradeController.AdvanceAutomaticMultiplier(
history, automatic: true, fps: 20f, current: 0f);
Assert.Equal(0.1f, committed, 7);
Assert.All(history, value => Assert.Equal(0.1f, value, 7));
}
[Fact]
public void AutomaticClampAndManualArmMatchFixedExpectedTransitions()
{
float[] history = Enumerable.Repeat(1f, 30).ToArray();
float clamped = BuildingDegradeController.AdvanceAutomaticMultiplier(
history, automatic: true, fps: 25f, current: 0.95f);
Assert.Equal(1f, clamped);
history[10] = -1f;
float manual = BuildingDegradeController.AdvanceAutomaticMultiplier(
history, automatic: false, fps: 0f, current: 0.35f);
Assert.Equal(0.35f, manual);
Assert.Equal(0.35f, history[^1]);
}
}

View file

@ -1,5 +1,6 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Rendering;
@ -84,6 +85,43 @@ public sealed class RenderFrameOrchestratorTests
Assert.Empty(phases.ObservedInputs);
}
[Fact]
public void AcceptedRenderTicksSharedDegradeOwnerExactlyOnceAndZeroAreaDoesNot()
{
var calls = new List<string>();
var phases = new RecordingPhases(calls);
var degradation = new RecordingDegradeTick(calls);
var orchestrator = new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, phases, phases, phases,
degradation);
double[] durations =
[
1d / 1024d, 2d / 1024d, 3d / 1024d, 4d / 1024d,
5d / 1024d, 6d / 1024d, 7d / 1024d, 8d / 1024d,
9d / 1024d, 10d / 1024d, 11d / 1024d, 12d / 1024d,
13d / 1024d, 14d / 1024d, 15d / 1024d, 16d / 1024d,
17d / 1024d, 18d / 1024d, 19d / 1024d, 20d / 1024d,
31d / 1024d,
];
for (int i = 0; i < durations.Length - 1; i++)
orchestrator.Render(Input with { DeltaSeconds = durations[i] });
orchestrator.Render(Input with { ViewportWidth = 0 });
orchestrator.Render(Input with { DeltaSeconds = durations[^1] });
Assert.Equal(durations, degradation.Durations);
Assert.Equal(0x42C30C31u, BitConverter.SingleToUInt32Bits(degradation.Fps));
Assert.Equal(durations.Length, calls.Count(static call => call == "degrade-tick"));
for (int i = 0; i < calls.Count; i++)
{
if (calls[i] == "gpu-begin")
{
Assert.True(i > 0, "degrade tick must precede BeginFrame");
Assert.Equal("degrade-tick", calls[i - 1]);
}
}
}
[Fact]
public void BeginFailure_DoesNotAttemptAnyPhaseOrClose()
{
@ -278,6 +316,7 @@ public sealed class RenderFrameOrchestratorTests
typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFramePostDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery),
typeof(IBuildingDegradeFrameTick),
];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
@ -293,7 +332,7 @@ public sealed class RenderFrameOrchestratorTests
Assert.All(
expectedFieldTypes,
contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow))));
foreach (Type contract in expectedFieldTypes)
foreach (Type contract in expectedFieldTypes.Where(type => type.IsInterface))
{
foreach (MethodInfo method in contract.GetMethods())
{
@ -395,6 +434,25 @@ public sealed class RenderFrameOrchestratorTests
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases, phases, phases, phases);
private sealed class RecordingDegradeTick : IBuildingDegradeFrameTick
{
private readonly List<string> _calls;
private readonly BuildingDegradeController _inner = new(
() => DisplaySettings.Default);
public RecordingDegradeTick(List<string> calls) => _calls = calls;
public List<double> Durations { get; } = [];
public float Fps => _inner.Fps;
public void Tick(double elapsedSeconds)
{
_calls.Add("degrade-tick");
Durations.Add(elapsedSeconds);
_inner.Tick(elapsedSeconds);
}
}
private sealed class RecordingPhases :
IRenderFrameLifetime,
IRenderFrameGpuMeasurement,

View file

@ -15,6 +15,14 @@ public sealed class RetailFrameWalkTests
/// them — proves the terrain/building/object-turn ORDER, not just
/// each vocabulary in isolation.</summary>
public readonly List<string> Combined = new();
public int BuildingAlphaTurns;
public readonly List<WalkBuildingSelection> ShellSelections = new();
public void OnBuildingTurn(WalkBuilding building) => BuildingAlphaTurns++;
public void OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection) => ShellSelections.Add(selection);
public void Emit(in WalkEvent walkEvent)
{
@ -66,7 +74,7 @@ public sealed class RetailFrameWalkTests
=> new(screenX, screenY, 100f);
}
private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext
private class TestContext : IWalkFrameContext, IRetailFrameWalkContext
{
public readonly Dictionary<uint, WalkCell> Cells = new();
private readonly Matrix4x4 _viewProj;
@ -88,7 +96,7 @@ public sealed class RetailFrameWalkTests
public float ViewportHeight => 480f;
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
public float ViewerDistanceTo(WalkBuilding building) => 0f;
public virtual float ViewerDistanceTo(WalkBuilding building) => 0f;
public IWalkFrameContext CellContext => this;
// Permissive near plane: every column wholly inside.
// S3 chunk 3 (T2): settable, default unchanged — a permissive
@ -165,11 +173,154 @@ public sealed class RetailFrameWalkTests
var ctx = new TestContext();
var walk = new RetailFrameWalk();
var recorder = new Recorder();
var building = new WalkBuilding { PositionCellId = 0xF518002E, HasGeometry = false };
var building = new WalkBuilding { PositionCellId = 0xF518002E, GfxObjId = 0 };
walk.DrawBuilding(building, new WalkPortalView(), ctx, recorder);
Assert.Equal("BLD:f518002e", recorder.Signature());
Assert.Equal(0, recorder.BuildingAlphaTurns);
Assert.Empty(recorder.ShellSelections);
}
[Fact]
public void NullBspSelectedGfxStillRunsAlphaAndShellWithoutPortalWalk()
{
var building = new WalkBuilding
{
PositionCellId = 0xF518002Eu,
GfxObjId = 0x01000001u,
DrawingBsp = null,
};
var recorder = new Recorder();
new RetailFrameWalk().DrawBuilding(
building, new WalkPortalView(), new TestContext(), recorder);
Assert.Equal("BLD:f518002e", recorder.Signature());
Assert.Equal(1, recorder.BuildingAlphaTurns);
WalkBuildingSelection selected = Assert.Single(recorder.ShellSelections);
Assert.Equal(0x01000001u, selected.GfxObjId);
Assert.Null(selected.DrawingBsp);
}
[Fact]
public void SelectionUsesStrictThresholdAndFinalZeroIsCompleteBodyFailure()
{
var firstBsp = new WalkBspNode();
var building = new WalkBuilding
{
GfxObjId = 0x0100FFFFu,
DegradeLevels =
[
new(0x01000001u, 1u, 10f, 20f, 30f, firstBsp),
new(0u, 1u, 30f, 40f, 50f, null),
],
};
Assert.Equal(0x01000001u, building.Select(29.999f, 0f, 1f).GfxObjId);
WalkBuildingSelection equality = building.Select(30f, 0f, 1f);
Assert.Equal(0u, equality.GfxObjId);
Assert.Equal(1, equality.Level);
var recorder = new Recorder();
var context = new DistanceContext(80f);
new RetailFrameWalk().DrawBuilding(
building, new WalkPortalView(), context, recorder);
Assert.Equal("BLD:00000000", recorder.Signature());
Assert.Equal(0, recorder.BuildingAlphaTurns);
Assert.Empty(recorder.ShellSelections);
}
[Fact]
public void SelectionComparesStoredFloatEffectiveDistanceToWideThreshold()
{
const float multiplier = 0.0020020019728690386f;
const float effective = 24.04804801940918f;
Assert.Equal(0x41C06267u, BitConverter.SingleToUInt32Bits(effective));
var building = new WalkBuilding
{
DegradeLevels =
[
new(0x01000001u, 1u, 0f, 24f, 48f, null),
new(0x01000002u, 1u, 48f, 64f, 80f, null),
],
};
WalkBuildingSelection selected = building.Select(
effective, degradeDistance: 0f, degradeMultiplier: multiplier);
Assert.Equal(0, selected.Level);
Assert.Equal(0x01000001u, selected.GfxObjId);
}
[Fact]
public void DirectAndLadderSelectionsCarryExactIdBspLevelAndMode()
{
var directBsp = new WalkBspNode();
var direct = new WalkBuilding
{
GfxObjId = 0x01000010u,
DrawingBsp = directBsp,
};
Assert.Equal(
new WalkBuildingSelection(0x01000010u, directBsp, 0, 1u),
direct.Select(float.NaN, 50f, float.NaN));
var nearBsp = new WalkBspNode();
var middleBsp = new WalkBspNode();
var ladder = new WalkBuilding
{
GfxObjId = 0x0100FFFFu,
DegradeLevels =
[
new(0x01000011u, 3u, 10f, 20f, 30f, nearBsp),
new(0x01000012u, 5u, 30f, 40f, 50f, middleBsp),
new(0u, 7u, 50f, 60f, 70f, null),
],
};
Assert.Equal(
new WalkBuildingSelection(0x01000011u, nearBsp, 0, 3u),
ladder.Select(29f, 5f, 0.5f)); // effective 24 < positive threshold 25
Assert.Equal(
new WalkBuildingSelection(0x01000011u, nearBsp, 0, 3u),
ladder.Select(-19f, 5f, -0.5f)); // effective 14 < negative threshold 15
Assert.Equal(
new WalkBuildingSelection(0x01000012u, middleBsp, 1, 5u),
ladder.Select(30f, 5f, 0.5f)); // equality advances
Assert.Equal(2, ladder.Select(float.PositiveInfinity, 5f, 0f).Level);
Assert.Equal(2, ladder.Select(float.NegativeInfinity, 5f, 0f).Level);
Assert.Equal(2, ladder.Select(float.NaN, 5f, 0f).Level);
}
[Fact]
public void DisableAndForcedLevelBranchesPreserveRetailPrecedenceAndClamp()
{
var firstBsp = new WalkBspNode();
var building = new WalkBuilding
{
DegradeLevels =
[
new(0x01000021u, 11u, 1f, 2f, 3f, firstBsp),
new(0x01000022u, 12u, 3f, 4f, 5f, null),
new(0u, 13u, 5f, 6f, 7f, null),
],
};
Assert.Equal(
new WalkBuildingSelection(0x01000021u, firstBsp, 0, 11u),
building.Select(float.PositiveInfinity, 50f, 1f,
degradesDisabled: true, forcedLevel: 2));
Assert.Equal(1, building.Select(0f, 50f, 0f, forcedLevel: 1).Level);
WalkBuildingSelection clamped = building.Select(0f, 50f, 0f, forcedLevel: 99);
Assert.Equal(2, clamped.Level);
Assert.Equal(13u, clamped.Mode);
Assert.Equal(0u, clamped.GfxObjId);
}
private sealed class DistanceContext(float distance) : TestContext
{
public override float ViewerDistanceTo(WalkBuilding building) => distance;
}
// ── T2 (S3 chunk 3 §9.3): the per-cell interleave — DrawLandCell (LC)
@ -224,7 +375,7 @@ public sealed class RetailFrameWalkTests
nearBlock.EnsureCellArrays();
// A building at an ordinary in-view cell (cx=3, cy=0), away from
// both the excluded column and the closest/farthest cells.
var building = new WalkBuilding { PositionCellId = 0x11110019u, HasGeometry = false };
var building = new WalkBuilding { PositionCellId = 0x11110019u, GfxObjId = 0 };
nearBlock.CellBuildings[3 * 8 + 0] = building;
// The far block (grid slot 3 = (1,1), the diagonal corner — drawn

View file

@ -0,0 +1,104 @@
using AcDream.App.Tests.Rendering;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Rendering.Walk;
[Trait("Lane", "InstalledDat")]
public sealed class WalkBuildingInstalledDatCensusTests
{
[Fact]
public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
using var dats = new DatCollection(datDir!, DatAccessType.Read);
int landblocks = 0;
int instances = 0;
int ladderInstances = 0;
int ladderSlots = 0;
int nonzeroSlots = 0;
int zeroFinalSlots = 0;
var models = new HashSet<uint>();
var ladderModels = new HashSet<uint>();
var ladderHistogram = new Dictionary<int, int>();
for (uint blockX = 0; blockX <= 0xFF; blockX++)
for (uint blockY = 0; blockY <= 0xFF; blockY++)
{
uint infoId = (blockX << 24) | (blockY << 16) | 0xFFFEu;
LandBlockInfo? info = dats.Get<LandBlockInfo>(infoId);
if (info?.Buildings is not { Count: > 0 } buildings)
continue;
landblocks++;
var anchors = new HashSet<uint>();
uint landblock = infoId & 0xFFFF0000u;
foreach (BuildingInfo building in buildings)
{
instances++;
models.Add(building.ModelId);
GfxObj? baseGfx = dats.Get<GfxObj>(building.ModelId);
Assert.NotNull(baseGfx);
BuildingPortal? firstInteriorPortal = building.Portals.FirstOrDefault(
static portal => portal.OtherCellId != 0xFFFF);
int cellX = (int)MathF.Floor(building.Frame.Origin.X / 24f);
int cellY = (int)MathF.Floor(building.Frame.Origin.Y / 24f);
uint placementCell = landblock | (uint)(cellX * 8 + cellY + 1);
uint anchor = firstInteriorPortal is null
? placementCell
: landblock | firstInteriorPortal.OtherCellId;
Assert.True(anchors.Add(anchor),
$"duplicate building anchor 0x{anchor:X8}");
if (baseGfx!.DIDDegrade == 0)
continue;
GfxObjDegradeInfo? ladder =
dats.Get<GfxObjDegradeInfo>(baseGfx.DIDDegrade);
Assert.NotNull(ladder);
ladderInstances++;
ladderModels.Add(building.ModelId);
int count = ladder!.Degrades.Count;
ladderSlots += count;
ladderHistogram[count] = ladderHistogram.GetValueOrDefault(count) + 1;
Assert.NotEmpty(ladder.Degrades);
Assert.Equal(0u, (uint)ladder.Degrades[^1].Id);
zeroFinalSlots++;
for (int i = 0; i < ladder.Degrades.Count - 1; i++)
{
GfxObjInfo slot = ladder.Degrades[i];
Assert.NotEqual(0u, (uint)slot.Id);
GfxObj? selected = dats.Get<GfxObj>((uint)slot.Id);
Assert.NotNull(selected);
Assert.NotNull(selected!.DrawingBSP?.Root);
nonzeroSlots++;
}
}
}
Assert.Equal(1_639, landblocks);
Assert.Equal(6_979, instances);
Assert.Equal(398, models.Count);
Assert.Equal(6_760, ladderInstances);
Assert.Equal(27_859, ladderSlots);
Assert.Equal(350, ladderModels.Count);
Assert.Equal(6_760, zeroFinalSlots);
Assert.Equal(21_099, nonzeroSlots);
Assert.Equal(
new Dictionary<int, int>
{
[2] = 196,
[3] = 216,
[4] = 4_964,
[5] = 1_341,
[6] = 43,
},
ladderHistogram);
}
}

View file

@ -1036,6 +1036,7 @@ public sealed partial class WalkFrameDriverTests
var building = new WalkBuilding
{
PositionCellId = 0xA9B4000Fu,
GfxObjId = (uint)shellGfxObj,
Portals =
[
new WalkBldPortal
@ -1139,6 +1140,36 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount));
}
[Fact]
public void BuildingShellTurnAllowsEmptyRecordButFailsLoudOnDuplicateAnchorRecords()
{
using var fx = new DispatcherFixture();
var building = new WalkBuilding
{
PositionCellId = 0xA9B40001u,
GfxObjId = 0x01000001u,
};
var selected = new WalkBuildingSelection(
building.GfxObjId, null, 0, 1u);
var world = new FakeWorldData();
var driver = new WalkFrameDriver(
fx.Dispatcher, new RecordingLeafRenderer([]), world);
driver.BeginFrame(new TestContext(), Matrix4x4.Identity, Vector3.Zero);
((IWalkEventSink)driver).OnBuildingShellTurn(building, selected);
RenderProjectionRecord record = MakeRecord(
301, 0, Vector3.Zero,
[new MeshRef(building.GfxObjId, Matrix4x4.Identity)],
isBuildingShell: true);
world.ShellByBuilding[building] = new WalkFrameStaticRecords(
new[] { record, record with { Id = RenderProjectionId.FromRaw(302) } },
0xA9B4u);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => ((IWalkEventSink)driver).OnBuildingShellTurn(building, selected));
Assert.Contains("expected at most one", error.Message, StringComparison.Ordinal);
}
// ── S4-c1 C1/T2 (quantifier corrected at fix round 1 F1): DrawPortalPoly
// Internal's degenerate-input guard, ported at the punch-fan producer
// (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL

View file

@ -52,9 +52,9 @@ public sealed class WalkPortalGateDumpTests
float dist = ctx.ViewerDistanceTo(building);
Vector3 eyeInBuilding = ctx.ViewpointInBuilding(building);
dump.AppendLine(
$"building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 100f):f1} "
$"building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 50f):f1} "
+ $"eyeLocal={eyeInBuilding} portals={building.Portals.Length}");
WalkBspNode? bsp = building.SelectDrawingBsp(dist);
WalkBspNode? bsp = building.Select(dist, 50f, 0f).DrawingBsp;
var refs = new List<WalkPortalRef>();
Collect(bsp, refs);
dump.AppendLine($" level BSP portal refs: {refs.Count}");
@ -161,9 +161,9 @@ public sealed class WalkPortalGateDumpTests
world.Buildings.Keys.Single(b => b.PositionCellId == id);
float dist = ctx.ViewerDistanceTo(building);
Vector3 eyeInBuilding = ctx.ViewpointInBuilding(building);
WalkBspNode? bsp = building.SelectDrawingBsp(dist);
WalkBspNode? bsp = building.Select(dist, 50f, 0f).DrawingBsp;
dump.AppendLine(
$" building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 100f):f1} "
$" building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 50f):f1} "
+ $"bsp={(bsp is null ? "NULL" : "selected")} ports={CountPorts(bsp)}");
for (int li = 0; li < building.DegradeLevels.Length; li++)
{
@ -249,7 +249,8 @@ public sealed class WalkPortalGateDumpTests
WalkBuilding building =
world.Buildings.Keys.Single(b => b.PositionCellId == 0xA9B40036u);
Vector3 eye = ctx.ViewpointInBuilding(building);
WalkBspNode? bsp = building.SelectDrawingBsp(ctx.ViewerDistanceTo(building));
WalkBspNode? bsp = building.Select(
ctx.ViewerDistanceTo(building), 50f, 0f).DrawingBsp;
var dump = new StringBuilder();
dump.AppendLine($"camera cell={pose.CellId:x8} origin={pose.Origin}");
dump.AppendLine($"eyeInBuilding={eye} dist={ctx.ViewerDistanceTo(building):f2}");

View file

@ -108,6 +108,32 @@ public sealed class WalkProductionFrameContextTests
Assert.Equal(13f, ctx.ViewerDistanceTo(building));
}
[Fact]
public void SetupPartZeroTransformIsSharedByViewpointDistanceAndWorldPublication()
{
var registry = new WalkBuildingRegistry();
var building = new WalkBuilding
{
PositionCellId = 1,
SortCenter = new Vector3(0f, 1f, 0f),
PartZeroTransform = Matrix4x4.CreateScale(2f)
* Matrix4x4.CreateTranslation(0f, 3f, 0f),
PartZeroScaleZ = 2f,
};
Matrix4x4 root = Matrix4x4.CreateTranslation(0f, 10f, 0f);
Matrix4x4.Invert(root, out Matrix4x4 inverseRoot);
var entry = new WalkBuildingFactory.Entry(building, root, inverseRoot);
registry.Publish(0xA9B4FFFFu, [entry]);
var ctx = new WalkProductionFrameContext(
new CellVisibility(), registry, new Vector3(0f, 19f, 0f), Vector3.UnitY,
SimpleViewProjection(), 1024f, 768f);
Assert.Equal(entry.PartZeroWorldTransform,
building.PartZeroTransform * entry.WorldTransform);
Assert.Equal(new Vector3(0f, 3f, 0f), ctx.ViewpointInBuilding(building));
Assert.Equal(2f, ctx.ViewerDistanceTo(building));
}
[Fact]
public void CyPlane_MatchesTheRetailNearPlaneFormula()
{

View file

@ -161,7 +161,7 @@ public sealed class WalkProductionWorldConformanceTests
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, null, assembler.Landscape, ctx, recorder);
@ -190,7 +190,7 @@ public sealed class WalkProductionWorldConformanceTests
// Matches WalkTraceConformanceTests.Doorway_still_first_frame_diff:
// this capture ran under cdb load with Render::deg_mul depressed to
// the portless-arm threshold. Same environment pin, same reason.
var walk = new RetailFrameWalk { DegradeMultiplier = 0f };
var walk = new RetailFrameWalk(100f, 0f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
@ -221,7 +221,7 @@ public sealed class WalkProductionWorldConformanceTests
WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
? Assert.Contains(frame.Pose.CellId, cells)
: null;
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
@ -302,7 +302,7 @@ public sealed class WalkProductionWorldConformanceTests
},
};
new RetailFrameWalk().WalkFrame(
new RetailFrameWalk(100f, 0.99f).WalkFrame(
cameraCellId,
camera,
assembler.Landscape,
@ -348,7 +348,7 @@ public sealed class WalkProductionWorldConformanceTests
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
BuildProductionWorld(adapter, anchor.CellId, anchor.Origin);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
for (int n = 1; n < frames.Count - 1; n++)
{
@ -402,7 +402,7 @@ public sealed class WalkProductionWorldConformanceTests
Assert.NotNull(frame.Pose);
WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
var ctx = new WalkTraceReplayContext(frame.Pose, cells);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder);

View file

@ -542,6 +542,260 @@ public sealed class WalkStaticStreamPopulatorTests
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
}
[Fact]
public void SelectedBuildingShellUsesExactlySelectedGfxAndPrecomputedPartZeroTransform()
{
using var fx = new DispatcherFixture();
const uint baseGfx = 0x0100_0031u;
const uint selectedGfx = 0x0100_0032u;
InjectRenderData(fx.Manager, baseGfx, MakeFlatMesh(
MakeBatch(0x08000031u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
InjectRenderData(fx.Manager, selectedGfx, MakeFlatMesh(
MakeBatch(0x08000032u, TranslucencyKind.Opaque, 3, 4, 6, 2)));
Matrix4x4 root = Matrix4x4.CreateTranslation(8, 9, 10);
Matrix4x4 partZero = Matrix4x4.CreateScale(2f)
* Matrix4x4.CreateTranslation(1, 2, 3);
var surfaceOverrides = new Dictionary<uint, uint>
{
[0x08000032u] = 0x05000032u,
};
RenderProjectionRecord record = MakeRecord(
231, 0x7000_0231u, new Vector3(8, 9, 10),
[new MeshRef(baseGfx, Matrix4x4.Identity)
{
SurfaceOverrides = surfaceOverrides,
}],
isBuildingShell: true);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selections = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
var selected = new WalkBuildingSelection(selectedGfx, null, 2, 1u);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: partZero);
WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches);
Assert.Equal(3u, batch.Key.FirstIndex);
Assert.True(batch.Key.TextureSlot.IsAssigned);
Assert.NotEqual(new GpuTextureSlot(2), batch.Key.TextureSlot);
Assert.Equal(partZero * root, batch.Transform);
var selection = Assert.Single(selections);
Assert.Equal(selectedGfx, selection.GfxObjId);
Assert.Equal(partZero * root, selection.LocalToWorld);
}
[Fact]
public void SelectedBuildingShellMissDoesNotFallBackToResidentBaseGfx()
{
using var fx = new DispatcherFixture();
const uint baseGfx = 0x0100_0041u;
const uint missingSelectedGfx = 0x0100_0042u;
InjectRenderData(fx.Manager, baseGfx, MakeFlatMesh(
MakeBatch(0x08000041u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
RenderProjectionRecord record = MakeRecord(
241, 0, Vector3.Zero,
[new MeshRef(baseGfx, Matrix4x4.Identity)], isBuildingShell: true);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selections = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
var selected = new WalkBuildingSelection(missingSelectedGfx, null, 1, 1u);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
Assert.Empty(batches);
Assert.Empty(selections);
InjectRenderData(fx.Manager, missingSelectedGfx, MakeFlatMesh(
MakeBatch(0x08000042u, TranslucencyKind.Opaque, 3, 4, 6, 2)));
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
Assert.Single(batches);
Assert.Equal(missingSelectedGfx, Assert.Single(selections).GfxObjId);
}
[Fact]
public void WalkFrameBoundary_DeduplicatesMissWithinFrameAndRearmsNextAcceptedFrame()
{
const uint missingGfx = 0x01000049u;
var source = new RecordingPreparedAssetSource();
using var fx = new DispatcherFixture(preparedAssets: source);
RenderProjectionRecord record = MakeRecord(
249, 0, Vector3.Zero,
[new MeshRef(0x01000048u, Matrix4x4.Identity)],
isBuildingShell: true);
var selected = new WalkBuildingSelection(missingGfx, null, 1, 1u);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selections = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
FieldInfo requestedField = typeof(WbDrawDispatcher).GetField(
"_missRequested", BindingFlags.Instance | BindingFlags.NonPublic)!;
var requested = (HashSet<ulong>)requestedField.GetValue(fx.Dispatcher)!;
fx.Dispatcher.BeginWalkPartFrame();
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
Assert.Equal([missingGfx], requested);
Assert.Throws<InvalidOperationException>(() => fx.Dispatcher.BeginWalkPartFrame());
Assert.Equal([missingGfx], requested);
Assert.True(SpinWait.SpinUntil(() => source.ReadCount >= 1, TimeSpan.FromSeconds(5)));
Assert.Equal(1, source.ReadCount);
fx.Manager.CancelStagedUploads([missingGfx]);
fx.Dispatcher.EndWalkPartFrame();
fx.Dispatcher.BeginWalkPartFrame();
Assert.Empty(requested);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
Assert.True(SpinWait.SpinUntil(() => source.ReadCount >= 2, TimeSpan.FromSeconds(5)));
Assert.Equal([missingGfx], requested);
fx.Dispatcher.EndWalkPartFrame();
for (int frame = 0; frame < 32; frame++)
{
fx.Manager.CancelStagedUploads([missingGfx]);
fx.Dispatcher.BeginWalkPartFrame();
Assert.Empty(requested);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
Assert.Single(requested);
fx.Dispatcher.EndWalkPartFrame();
}
Assert.Single(requested);
}
[Fact]
public void OwnedWalkLadderDependency_PreparesPublishesAndClassifiesWithoutInjection()
{
const uint selectedGfx = 0x0100004Au;
var prepared = PreparedTriangle(selectedGfx, 0x0800004Au);
var source = new RecordingPreparedAssetSource(prepared);
using var fx = new DispatcherFixture(preparedAssets: source);
var ownership = new LandblockSpawnAdapter(fx.Adapter);
var building = new WalkBuilding
{
PositionCellId = 0x8C040001u,
DegradeLevels =
[
new WalkBuildingDegradeLevel(selectedGfx, 1u, 0f, 10f, 20f, null),
],
};
var envCells = new EnvCellLandblockBuild(
0x8C04FFFFu,
Array.Empty<LoadedCell>(),
Array.Empty<EnvCellShellPlacement>(),
[new WalkBuildingFactory.Entry(building, Matrix4x4.Identity, Matrix4x4.Identity)]);
var landblock = new LoadedLandblock(
0x8C04FFFFu, new LandBlock(), Array.Empty<WorldEntity>());
ownership.OnLandblockLoaded(
landblock,
additionalOrdinaryIds: envCells.WalkBuildingMeshDependencies);
Assert.True(fx.Manager.IsOwned(selectedGfx));
Assert.True(SpinWait.SpinUntil(() =>
{
fx.Adapter.Tick();
return fx.Adapter.TryGetRenderData(selectedGfx) is not null;
}, TimeSpan.FromSeconds(5)));
Assert.Equal(1, source.ReadCount);
RenderProjectionRecord record = MakeRecord(
250, 0, Vector3.Zero,
[new MeshRef(0x0100004Bu, Matrix4x4.Identity)],
isBuildingShell: true);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selections = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
fx.Dispatcher.BeginWalkPartFrame();
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: building.Select(0f, 1f, 1f),
buildingPartTransform: Matrix4x4.Identity);
fx.Dispatcher.EndWalkPartFrame();
Assert.Single(batches);
Assert.Equal(selectedGfx, Assert.Single(selections).GfxObjId);
ownership.OnLandblockUnloaded(landblock.LandblockId);
Assert.False(fx.Manager.IsOwned(selectedGfx));
}
[Fact]
public void WarmedBuildingSelectionAndClassificationAllocateZeroAndDoNotMutateRetainedRecord()
{
using var fx = new DispatcherFixture();
const uint baseGfx = 0x0100_0051u;
const uint selectedGfx = 0x0100_0052u;
InjectRenderData(fx.Manager, selectedGfx, MakeFlatMesh(
MakeBatch(0x08000052u, TranslucencyKind.Opaque, 3, 4, 6, 2)));
RenderProjectionRecord record = MakeRecord(
251, 0x7000_0251u, new Vector3(8, 9, 10),
[new MeshRef(baseGfx, Matrix4x4.Identity)],
isBuildingShell: true,
parentCellId: 0x8C040112u,
casterIdentity: RenderCasterIdentityKind.Building) with
{
Source = new RenderSourceMetadata(
LocalEntityId: 251,
ServerGuid: 0x7000_0251u,
SourceId: 0x02000051u,
ParentCellId: 0x8C040112u,
EffectCellId: 0x8C040113u,
BuildingShellAnchorCellId: 0x8C040001u,
TransformFingerprint: new RenderSceneHash128(11, 21),
GeometryFingerprint: new RenderSceneHash128(12, 22),
AppearanceFingerprint: new RenderSceneHash128(13, 23)),
};
RenderProjectionRecord original = record;
var building = new WalkBuilding
{
DegradeLevels =
[
new(selectedGfx, 4u, 0f, 10f, 20f, null),
new(0u, 5u, 20f, 30f, 40f, null),
],
};
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>(1);
var selections = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>(1);
void Classify()
{
batches.Clear();
selections.Clear();
WalkBuildingSelection selected = building.Select(0f, 50f, 0f);
fx.Dispatcher.ClassifyEntityForWalk(
in record, 0x8C04u, batches, selections,
buildingSelection: selected,
buildingPartTransform: Matrix4x4.Identity);
}
Classify();
long allocated = ZeroAllocationProbe.MeasureWarmed(
Classify,
batchSize: 10_000,
warmupBatches: 2,
samples: 5);
Assert.Equal(0, allocated);
Assert.Equal(original, record);
Assert.Equal(selectedGfx, Assert.Single(selections).GfxObjId);
WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches);
Assert.Equal(1u, batch.DetailCategory);
Assert.Equal(Matrix4x4.CreateTranslation(8, 9, 10), batch.Transform);
}
[Fact]
public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass()
{
@ -1005,6 +1259,31 @@ public sealed class WalkStaticStreamPopulatorTests
return group;
}
private static ObjectMeshData PreparedTriangle(uint objectId, uint surfaceId) => new()
{
ObjectId = objectId,
Vertices =
[
new VertexPositionNormalTexture { Position = Vector3.Zero },
new VertexPositionNormalTexture { Position = Vector3.UnitX },
new VertexPositionNormalTexture { Position = Vector3.UnitY },
],
TextureBatches =
{
[(2, 2, Chorizite.Core.Render.Enums.TextureFormat.RGBA8)] =
[
new TextureBatchData
{
Key = new TextureKey { SurfaceId = surfaceId },
TextureData = Enumerable.Repeat((byte)0xFF, 16).ToArray(),
Indices = [0, 1, 2],
Translucency = TranslucencyKind.Opaque,
CullMode = CullMode.Clockwise,
},
],
},
};
[Fact]
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
{
@ -1098,7 +1377,8 @@ public sealed class WalkStaticStreamPopulatorTests
bool withAlphaQueue = false,
IRetailSelectionRenderSink? selectionSink = null,
bool detailAvailable = false,
bool detailEnabled = false)
bool detailEnabled = false,
IPreparedAssetSource? preparedAssets = null)
{
Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device);
@ -1107,7 +1387,7 @@ public sealed class WalkStaticStreamPopulatorTests
_meshAdapter = new WbMeshAdapter(
Device,
new NoopDatReaderWriter(),
new NullPreparedAssetSource(),
preparedAssets ?? new NullPreparedAssetSource(),
NullLogger<WbMeshAdapter>.Instance,
Device.Retirement);
var entitySpawnAdapter = new EntitySpawnAdapter(
@ -1150,6 +1430,8 @@ public sealed class WalkStaticStreamPopulatorTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public WbMeshAdapter Adapter => _meshAdapter;
public DrawScope BeginDraw(bool beginAlpha = false)
{
if (beginAlpha)
@ -1201,6 +1483,31 @@ public sealed class WalkStaticStreamPopulatorTests
}
}
private sealed class RecordingPreparedAssetSource(ObjectMeshData? data = null)
: IPreparedAssetSource
{
private int _readCount;
public int ReadCount => Volatile.Read(ref _readCount);
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) => PreparedAssetPresence.Available;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _readCount);
return data is not null && data.ObjectId == request.RuntimeObjectId
? PreparedAssetReadResult.Loaded(data)
: PreparedAssetReadResult.Missing;
}
public void Dispose()
{
}
}
private sealed class NoopDatReaderWriter : IDatReaderWriter
{
private readonly StubDatabase _portal = new();

View file

@ -130,8 +130,10 @@ public sealed partial class WalkTraceConformanceTests
public void OnBuildingTurn(WalkBuilding building) =>
_inner.OnBuildingTurn(building);
public void OnBuildingShellTurn(WalkBuilding building) =>
_inner.OnBuildingShellTurn(building);
public void OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection) =>
_inner.OnBuildingShellTurn(building, selection);
public void OnPunchGeometry(
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
@ -334,7 +336,7 @@ public sealed partial class WalkTraceConformanceTests
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
leaf.Driver = driver;
var sink = new AlphaDepthCollectSink(driver);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
// R2-2: seed the persistent counter from the fixture's own
// pre-capture value instead of priming a throwaway first pass.
@ -416,7 +418,7 @@ public sealed partial class WalkTraceConformanceTests
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
leaf.Driver = driver;
var sink = new AlphaDepthCollectSink(driver);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
driver.PortalsDrawnCount = initialCounter;
queue.BeginFrame();

View file

@ -139,7 +139,7 @@ public sealed partial class WalkTraceConformanceTests
{
Buildings = world.Buildings,
};
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, null, world.Landscape, ctx, recorder);
@ -178,7 +178,7 @@ public sealed partial class WalkTraceConformanceTests
// look-in floods. Every other fixture pins ≈ +0.99 (thresholds at
// max). The recon session's live dump read 0.99 under the same cdb
// load. Re-dump deg_mul per capture at the next retail session.
var walk = new RetailFrameWalk { DegradeMultiplier = 0f };
var walk = new RetailFrameWalk(100f, 0f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder);
@ -257,7 +257,7 @@ public sealed partial class WalkTraceConformanceTests
Buildings = world.Buildings,
};
WalkCell camera = Assert.Contains(frame.Pose.CellId, world.Cells);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder);
@ -304,7 +304,7 @@ public sealed partial class WalkTraceConformanceTests
WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
? Assert.Contains(frame.Pose.CellId, world.Cells)
: null;
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder);
@ -363,7 +363,7 @@ public sealed partial class WalkTraceConformanceTests
WalkOraclePose anchor = frames[1].Pose!;
WalkLandscapeDatBuilder.BuiltWorld world =
WalkLandscapeDatBuilder.Build(dats, anchor.CellId, anchor.Origin);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
for (int n = 1; n < frames.Count - 1; n++)
{
@ -423,7 +423,7 @@ public sealed partial class WalkTraceConformanceTests
Assert.NotNull(frame.Pose);
WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
var ctx = new WalkTraceReplayContext(frame.Pose, cells);
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder);
@ -467,7 +467,7 @@ public sealed partial class WalkTraceConformanceTests
{
Buildings = world.Buildings,
};
var walk = new RetailFrameWalk();
var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder);

View file

@ -169,7 +169,9 @@ public static class WalkWorldDatAdapter
{
levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
}
degradeLevels.Add(new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp));
degradeLevels.Add(new WalkBuildingDegradeLevel(
(uint)level.Id, level.DegradeMode,
level.MinDist, level.IdealDist, level.MaxDist, levelBsp));
}
}
}
@ -183,6 +185,7 @@ public static class WalkWorldDatAdapter
{
PositionCellId = positionCellId,
Portals = portals,
GfxObjId = buildingInfo.ModelId,
DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter,

View file

@ -2,6 +2,7 @@ using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Wb;
@ -85,6 +86,41 @@ public class EnvCellLandblockBuildTests
Assert.Throws<InvalidOperationException>(() => builder.Build());
}
[Fact]
public void Constructor_PrecomputesUniqueNonzeroWalkBuildingMeshDependencies()
{
WalkBuildingFactory.Entry[] buildings =
[
Building(0x01000010u, 0u, 0x01000020u, 0x01000010u),
Building(0x01000020u, 0x01000030u),
];
var build = new EnvCellLandblockBuild(
LandblockId,
Array.Empty<LoadedCell>(),
Array.Empty<EnvCellShellPlacement>(),
buildings);
Assert.Equal<ulong>(
[0x01000010ul, 0x01000020ul, 0x01000030ul],
build.WalkBuildingMeshDependencies);
buildings[0].Building.DegradeLevels = [];
Assert.Equal<ulong>(
[0x01000010ul, 0x01000020ul, 0x01000030ul],
build.WalkBuildingMeshDependencies);
}
private static WalkBuildingFactory.Entry Building(params uint[] ids) =>
new(
new WalkBuilding
{
PositionCellId = 0x8C040001u,
DegradeLevels = ids.Select(id =>
new WalkBuildingDegradeLevel(id, 1u, 0f, 10f, 20f, null)).ToArray(),
},
Matrix4x4.Identity,
Matrix4x4.Identity);
private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId)
{
var min = new Vector3(cellId & 0xFF, 0, 0);

View file

@ -81,6 +81,85 @@ public sealed class LandblockSpawnAdapterReadinessTests
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts);
}
[Fact]
public void WalkLadderDependencies_AreOrdinaryOwnedDeduplicatedAndBalancedAcrossRevisit()
{
const ulong baseAndLadder = 0x01000010ul;
const ulong ladderOnly = 0x01000020ul;
const ulong prepared = 0x2_0000_1234ul;
var meshes = new ReadinessMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)baseAndLadder);
adapter.OnLandblockLoaded(
landblock,
[prepared],
[0ul, baseAndLadder, ladderOnly, ladderOnly]);
Assert.Equal(1, meshes.ReferenceCounts[baseAndLadder]);
Assert.Equal(1, meshes.ReferenceCounts[ladderOnly]);
Assert.Equal(1, meshes.ReferenceCounts[prepared]);
Assert.Equal([prepared], meshes.PreparedPinCalls);
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
adapter.OnLandblockLoaded(landblock, [prepared], [baseAndLadder, ladderOnly]);
Assert.Equal(1, meshes.ReferenceCounts[baseAndLadder]);
Assert.Equal(1, meshes.ReferenceCounts[ladderOnly]);
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
}
[Fact]
public void WalkLadderDependency_PartialAcquireFailureCanCancelThenReplaceWithoutLeak()
{
const ulong first = 0x01000010ul;
const ulong failed = 0x01000020ul;
const ulong replacement = 0x01000030ul;
var meshes = new FaultInjectingMeshAdapter();
meshes.FailNext(ReferenceOperation.Increment, failed, committed: false);
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu);
Assert.Throws<InvalidOperationException>(() =>
adapter.OnLandblockLoaded(landblock, null, [first, failed]));
Assert.Equal(1, meshes.ReferenceCounts[first]);
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
adapter.OnLandblockLoaded(landblock, null, [replacement]);
Assert.Equal(1, meshes.ReferenceCounts[replacement]);
adapter.OnLandblockLoaded(landblock, null, [replacement]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, replacement));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
}
[Fact]
public void WalkLadderDependency_ExactReplacementReleasesDisplacedDependencyOnce()
{
const ulong displaced = 0x01000010ul;
const ulong retained = 0x01000020ul;
const ulong added = 0x01000030ul;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu);
adapter.OnLandblockLoaded(landblock, null, [displaced, retained]);
adapter.OnLandblockLoaded(
landblock,
additionalOrdinaryIds: [retained, added],
replaceExisting: true);
Assert.DoesNotContain(displaced, meshes.ReferenceCounts);
Assert.Equal(1, meshes.ReferenceCounts[retained]);
Assert.Equal(1, meshes.ReferenceCounts[added]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, displaced));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, retained));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
}
[Fact]
public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences()
{

View file

@ -717,6 +717,7 @@ public sealed class WorldSceneRendererTests
var building = new WalkBuilding
{
PositionCellId = landblockId | 1u,
GfxObjId = 0x0100_0001u,
DrawingBsp = new WalkBspNode { InPortals = [] },
};
var entry = new WalkBuildingFactory.Entry(

View file

@ -8,6 +8,8 @@ using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatReaderWriter.Enums;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Streaming;
@ -140,6 +142,52 @@ public sealed class LandblockBuildFactoryTests
Assert.Equal((LandblockId & 0xFFFF0000u) | 1u, buildingEntry.Building.PositionCellId);
}
[Fact]
public void WalkBuildingFactory_SetupModelUsesExactlyRestingPartZeroAndDefaultScale()
{
const uint setupId = 0x0200_0100u;
const uint partZeroId = 0x0100_0101u;
var dat = CreateDat(out RecordingDatProxy proxy);
var resting = new AnimationFrame(1);
resting.Frames.Add(new Frame
{
Origin = new Vector3(1f, 2f, 3f),
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.25f),
});
var setup = new Setup();
setup.Parts.Add(partZeroId);
setup.DefaultScale.Add(new Vector3(2f, 3f, 4f));
setup.PlacementFrames[Placement.Resting] = resting;
proxy.Add(setupId, setup);
proxy.Add(partZeroId, new GfxObj
{
Id = partZeroId,
SortCenter = new Vector3(4f, 5f, 6f),
});
var info = new BuildingInfo
{
ModelId = setupId,
Frame = new Frame
{
Origin = new Vector3(12f, 12f, 0f),
Orientation = Quaternion.Identity,
},
Portals = [],
};
WalkBuildingFactory.Entry entry = Assert.Single(
WalkBuildingFactory.Build(dat, LandblockId, [info], Vector3.Zero));
Assert.Equal(partZeroId, entry.Building.GfxObjId);
Assert.Equal(new Vector3(4f, 5f, 6f), entry.Building.SortCenter);
Assert.Equal(4f, entry.Building.PartZeroScaleZ);
Matrix4x4 expected = Matrix4x4.CreateScale(2f, 3f, 4f)
* Matrix4x4.CreateFromQuaternion(resting.Frames[0].Orientation)
* Matrix4x4.CreateTranslation(1f, 2f, 3f);
Assert.Equal(expected, entry.Building.PartZeroTransform);
Assert.Equal(expected * entry.WorldTransform, entry.PartZeroWorldTransform);
}
[Fact]
public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull()
{

View file

@ -1,14 +1,96 @@
using AcDream.App.Streaming;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using System.Collections.Immutable;
using System.Numerics;
namespace AcDream.App.Tests.Streaming;
public sealed class StreamingControllerReadinessTests
{
[Fact]
public void NearPublication_OwnsWalkLadderAsOrdinaryReadinessAndReleasesOnRetirement()
{
const uint landblockId = 0x1236FFFFu;
const ulong ladderId = 0x01001234ul;
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
var outbox = new Queue<LandblockStreamResult>();
var building = new WalkBuilding
{
PositionCellId = 0x12360001u,
DegradeLevels =
[
new WalkBuildingDegradeLevel((uint)ladderId, 1u, 0f, 10f, 20f, null),
],
};
var envCells = new EnvCellLandblockBuild(
landblockId,
Array.Empty<LoadedCell>(),
Array.Empty<EnvCellShellPlacement>(),
[new WalkBuildingFactory.Entry(building, Matrix4x4.Identity, Matrix4x4.Identity)]);
var build = new LandblockBuild(
new LoadedLandblock(landblockId, new LandBlock(), Array.Empty<WorldEntity>()),
envCells);
outbox.Enqueue(new LandblockStreamResult.Loaded(
landblockId,
LandblockStreamTier.Near,
build,
new AcDream.Core.Terrain.LandblockMeshData([], [])));
var controller = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: maximum =>
{
var drained = new List<LandblockStreamResult>();
while (drained.Count < maximum && outbox.TryDequeue(out var result))
drained.Add(result);
return drained;
},
applyTerrain: (_, _) => { },
state: state,
nearRadius: 0,
farRadius: 0);
for (int frame = 0; frame < 32 && !meshes.ReferenceCounts.ContainsKey(ladderId); frame++)
controller.Tick(0x12, 0x36);
Assert.Equal(1, meshes.ReferenceCounts[ladderId]);
Assert.False(controller.IsRenderNeighborhoodResident(landblockId, 0, 0));
meshes.ReadyIds.Add(ladderId);
Assert.True(controller.IsRenderNeighborhoodResident(landblockId, 0, 0));
state.RemoveEntitiesFromLandblock(landblockId);
Assert.Empty(meshes.ReferenceCounts);
GpuLandblockSpatialPublication revisit = state.CommitEntitiesToExistingLandblockSpatial(
landblockId,
Array.Empty<WorldEntity>(),
additionalRenderIds: null,
additionalOrdinaryRenderIds: envCells.WalkBuildingMeshDependencies);
state.ActivateLandblockPresentation(revisit);
Assert.Equal(1, meshes.ReferenceCounts[ladderId]);
state.RemoveLandblock(landblockId);
Assert.Empty(meshes.ReferenceCounts);
GpuLandblockSpatialPublication resetPublication =
state.CommitLandblockSpatial(
build.Landblock,
additionalRenderIds: null,
tier: LandblockStreamTier.Near,
additionalOrdinaryRenderIds:
envCells.WalkBuildingMeshDependencies);
state.ActivateLandblockPresentation(resetPublication);
Assert.Equal(1, meshes.ReferenceCounts[ladderId]);
GpuWorldRecenterRetirement reset = state.DetachAllForOriginRecenter();
Assert.Equal(landblockId, Assert.Single(reset.Landblocks).LandblockId);
state.ReleaseLandblockMeshReferences(landblockId);
Assert.Empty(meshes.ReferenceCounts);
}
[Fact]
public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing()
{

View file

@ -1144,6 +1144,25 @@ public sealed class ConfigOptionsPageControllerTests
}
}
[Fact]
public void AdaptiveDegradeRowsApplyToTheSharedDisplaySettingsSnapshot()
{
(OptionsPanelController controller, FakeBindings bindings, bool bound) = BindReal();
Assert.True(bound);
var automatic = Assert.IsType<BoolOptionRow>(controller.ConfigPage.Rows[16]);
var bias = Assert.IsType<FloatOptionRow>(controller.ConfigPage.Rows[17]);
var distance = Assert.IsType<FloatOptionRow>(controller.ConfigPage.Rows[18]);
automatic.SetCurrentValue(true);
bias.SetCurrentValue(-0.35f);
distance.SetCurrentValue(77f);
controller.ConfigPage.Apply();
Assert.True(bindings.Display.AutomaticDegrades);
Assert.Equal(-0.35f, bindings.Display.GraphicsPerformance);
Assert.Equal(77f, bindings.Display.DegradeDistance);
}
[Fact]
public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey()
{
@ -1412,9 +1431,9 @@ public sealed class ConfigOptionsPageControllerTests
(15, RowKind.Toggle, false, "Full Screen"), // LIVE
(16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH
(17, RowKind.Slider, true, "Screen Brightness"), // review S2
(18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198
(19, RowKind.Slider, true, "Graphics Performance"), // AP-198
(20, RowKind.Slider, true, "Degrade Distance"), // AP-198
(18, RowKind.Toggle, false, "Automatic Degrades"), // LIVE
(19, RowKind.Slider, false, "Graphics Performance"), // LIVE
(20, RowKind.Slider, false, "Degrade Distance"), // LIVE
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
@ -1446,6 +1465,7 @@ public sealed class ConfigOptionsPageControllerTests
// at its empty default and hide the very thing this test checks.
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x");
Assert.True(bound);
Assert.Equal(16, DimmingExpectations.Count(expectation => expectation.StoreOnly));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
@ -1506,6 +1526,22 @@ public sealed class ConfigOptionsPageControllerTests
Assert.Equal(25, bindings.Display.LandscapeDrawDistance);
}
[Fact]
public void Ap198TruthNamesFiveResidualsAndLandscapeDrawDistanceAsLive()
{
string registerPath = Path.Combine(
FindRepoRoot(), "docs", "architecture", "retail-divergence-register.md");
string row = Assert.Single(File.ReadLines(registerPath), static line =>
line.StartsWith("| AP-198 |", StringComparison.Ordinal));
Assert.Contains("author five rows", row, StringComparison.Ordinal);
Assert.Contains("Render_LandscapeDrawDistance` was already live", row, StringComparison.Ordinal);
Assert.Contains("ApplyLandscapeDrawDistance", row, StringComparison.Ordinal);
Assert.DoesNotContain("author six rows", row, StringComparison.Ordinal);
Assert.DoesNotContain("six residual", row, StringComparison.Ordinal);
Assert.DoesNotContain("no highlighted selection", row, StringComparison.Ordinal);
}
// ── #412-class regression: Config tab content escaping the window frame ──
//
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's
@ -1620,4 +1656,17 @@ public sealed class ConfigOptionsPageControllerTests
ApplyAnchorRecursive(child);
}
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new InvalidOperationException("Could not locate repository root.");
}
}