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

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