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, EntityEffectPoseRegistry EffectPoses,
WorldRenderRangeState RenderRange, WorldRenderRangeState RenderRange,
RuntimeSettingsController Settings, RuntimeSettingsController Settings,
BuildingDegradeController BuildingDegrades,
DisplayFramePacingController DisplayFramePacing, DisplayFramePacingController DisplayFramePacing,
WorldSceneDebugState WorldSceneDebugState, WorldSceneDebugState WorldSceneDebugState,
RetailAlphaQueue RetailAlphaQueue, RetailAlphaQueue RetailAlphaQueue,
@ -506,7 +507,8 @@ internal sealed class FrameRootCompositionPhase
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."), "The retail frame walk requires the landscape registry."),
d.CellVisibility, d.CellVisibility,
d.PhysicsEngine.ShadowObjects), d.PhysicsEngine.ShadowObjects,
d.BuildingDegrades),
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,
worldScenePasses, worldScenePasses,
@ -798,7 +800,8 @@ internal sealed class FrameRootCompositionPhase
privatePresentation, privatePresentation,
live.FrameDiagnostics, live.FrameDiagnostics,
postDiagnostics, postDiagnostics,
NullRenderFrameFailureRecovery.Instance); NullRenderFrameFailureRecovery.Instance,
d.BuildingDegrades);
Fault(FrameRootCompositionPoint.RenderRootCreated); Fault(FrameRootCompositionPoint.RenderRootCreated);
var liveFrameCoordinator = new RetailLiveFrameCoordinator( 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. // Save button writes here, same file GameWindow's startup load reads.
string KeyBindingsFilePath, string KeyBindingsFilePath,
RuntimeSettingsController Settings, RuntimeSettingsController Settings,
BuildingDegradeController BuildingDegrades,
GameRuntime Runtime, GameRuntime Runtime,
IRuntimeCombatAttackOperations CombatAttackOperations, IRuntimeCombatAttackOperations CombatAttackOperations,
RuntimeCombatTargetOperationsSlot CombatTargetOperations, RuntimeCombatTargetOperationsSlot CombatTargetOperations,
@ -310,6 +311,18 @@ internal interface IInteractionRetainedUiCompositionFactory
internal sealed class RetailInteractionRetainedUiCompositionFactory internal sealed class RetailInteractionRetainedUiCompositionFactory
: IInteractionRetainedUiCompositionFactory : 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( public IDisposable BindCombatTarget(
InteractionRetainedUiDependencies d, InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) => DeferredSelectionUiAuthority selection) =>
@ -787,9 +800,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.ClientTime), d.ClientTime),
JumpPowerbar: new JumpPowerbarRuntimeBindings( JumpPowerbar: new JumpPowerbarRuntimeBindings(
() => d.PlayerController.Controller?.JumpCharge ?? default), () => d.PlayerController.Controller?.JumpCharge ?? default),
Fps: new FpsRuntimeBindings( Fps: CreateFpsBindings(
() => d.FrameDiagnostics.Snapshot.Fps, d.BuildingDegrades,
() => 1.0,
() => d.Settings.DisplayPreview.ShowFps), () => d.Settings.DisplayPreview.ShowFps),
VividTarget: new VividTargetRuntimeBindings( VividTarget: new VividTargetRuntimeBindings(
d.Actions.Selection, 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 Exception? _runFailure;
private readonly DisplayFramePacingController _displayFramePacing; private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings; private readonly RuntimeSettingsController _runtimeSettings;
private readonly BuildingDegradeController _buildingDegrades;
// Phase A.1: streaming fields replacing the one-shot _entities list. // Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.LandblockStreamer? _streamer;
@ -784,6 +785,8 @@ public sealed class GameWindow :
_applicationPaths.SettingsFile), _applicationPaths.SettingsFile),
log: Console.WriteLine, log: Console.WriteLine,
characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit); characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit);
_buildingDegrades = new BuildingDegradeController(
() => _runtimeSettings.DisplayPreview);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_renderPackRegistry = renderPackRegistry; _renderPackRegistry = renderPackRegistry;
@ -1552,6 +1555,7 @@ public sealed class GameWindow :
_localPlayerTeleportSink, _localPlayerTeleportSink,
_applicationPaths.KeyBindingsFile, _applicationPaths.KeyBindingsFile,
_runtimeSettings, _runtimeSettings,
_buildingDegrades,
_runtime, _runtime,
_combatAttackOperations, _combatAttackOperations,
_combatTargetOperations, _combatTargetOperations,
@ -1746,6 +1750,7 @@ public sealed class GameWindow :
_effectPoses, _effectPoses,
_renderRange, _renderRange,
_runtimeSettings, _runtimeSettings,
_buildingDegrades,
_displayFramePacing, _displayFramePacing,
_worldSceneDebugState, _worldSceneDebugState,
_retailAlphaQueue, _retailAlphaQueue,

View file

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

View file

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

View file

@ -68,6 +68,9 @@ public interface IRetailFrameWalkContext : IWalkBuildingFrameContext
/// </summary> /// </summary>
public sealed class RetailFrameWalk public sealed class RetailFrameWalk
{ {
private readonly BuildingDegradeController? _degradation;
private readonly float? _fixedDegradeDistance;
private readonly float? _fixedDegradeMultiplier;
// RenderDeviceD3D::Init @0x0059efb0: indoor_pview = PView(…, 1) — the // RenderDeviceD3D::Init @0x0059efb0: indoor_pview = PView(…, 1) — the
// interior pview DRAWS the landscape through its surviving exit views. // interior pview DRAWS the landscape through its surviving exit views.
private readonly WalkPView _interiorPView = new() { DrawLandscape = true }; private readonly WalkPView _interiorPView = new() { DrawLandscape = true };
@ -86,14 +89,19 @@ public sealed class RetailFrameWalk
/// regardless of per-cell visibility.</summary> /// regardless of per-cell visibility.</summary>
public bool AlwaysDrawObjects = true; public bool AlwaysDrawObjects = true;
/// <summary>Retail global <c>Render::deg_mul</c> — DYNAMIC: the public RetailFrameWalk() { }
/// auto-tuner (<c>auto_update_deg_mul</c>) swings it with frame load
/// (positive ⇒ degrade thresholds slide toward each level's max; internal RetailFrameWalk(BuildingDegradeController degradation)
/// negative ⇒ toward its min). The oracle captures pin ≈+0.99 for every => _degradation = degradation ?? throw new ArgumentNullException(nameof(degradation));
/// fixture except doorway-still, whose capture ran under cdb load with
/// the multiplier depressed (the recon session's live dump read 0.99 /// <summary>Fixture-only capture-state constructor. Production always
/// under the same conditions).</summary> /// supplies the renderer-lifetime controller; historical retail captures
public float DegradeMultiplier = WalkBuilding.DefaultDegradeMultiplier; /// 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 /// <summary>FW4 slice 1: the interior root's <c>outside_view</c> — the
/// exit-view polygons THIS walk's own <c>ConstructView</c> installed /// 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 /// portal pass, and the shell draw — sits inside retail's
/// <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c> @0x0059f2d3; a /// <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c> @0x0059f2d3; a
/// degraded-out slot draws NOTHING beyond the BLD event. /// degraded-out slot draws NOTHING beyond the BLD event.
/// <c>HasGeometry</c> + a non-null <c>SelectDrawingBsp</c> together model /// The selected GfxObj id alone models that gate; its drawing BSP is an
/// that one gate. Inside the gate, retail's own order /// optional portal-walk input. Inside the gate, retail's own order
/// (@0x0059f30b0x0059f345) is <c>D3DPolyRender::FlushAlphaList(0f)</c> → /// (@0x0059f30b0x0059f345) is <c>D3DPolyRender::FlushAlphaList(0f)</c> →
/// <c>CPhysicsPart::Draw(parts, 1)</c> (the PORTAL flavor — the two-pass /// <c>CPhysicsPart::Draw(parts, 1)</c> (the PORTAL flavor — the two-pass
/// punch/look-in walk below) → <c>CPhysicsPart::Draw(parts, 0)</c> (the /// punch/look-in walk below) → <c>CPhysicsPart::Draw(parts, 0)</c> (the
@ -285,47 +293,47 @@ public sealed class RetailFrameWalk
IRetailFrameWalkContext ctx, IWalkEventSink sink) IRetailFrameWalkContext ctx, IWalkEventSink sink)
{ {
sink.Emit(WalkEvent.Building(building.PositionCellId)); sink.Emit(WalkEvent.Building(building.PositionCellId));
if (!building.HasGeometry) return; WalkBuildingSelection selection = building.Select(
// 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(
ctx.ViewerDistanceTo(building), ctx.ViewerDistanceTo(building),
degradeMultiplier: DegradeMultiplier); _degradation?.DegradeDistance ?? _fixedDegradeDistance ?? 50f,
if (bsp is null) return; _degradation?.ActiveMultiplier ?? _fixedDegradeMultiplier ?? 0f);
if (selection.GfxObjId == 0)
return;
// Additive (Campaign FW3.2b-1): the alpha barrier // Additive (Campaign FW3.2b-1): the alpha barrier
// (D3DPolyRender::FlushAlphaList(0f) @0x0059f30b) — gated by the // (D3DPolyRender::FlushAlphaList(0f) @0x0059f30b) — gated by the
// SAME part->gfxobj[deg_level]!=0 check as everything below it, so // SAME part->gfxobj[deg_level]!=0 check as everything below it, so
// this fires only now that both HasGeometry and the bsp lookup have // this fires after the selected-id gate, independently of whether
// passed. // that selected object's drawing BSP exists.
sink.OnBuildingTurn(building); sink.OnBuildingTurn(building);
int viewCount = Math.Max(activeViews.ViewCount, 0); if (selection.DrawingBsp is WalkBspNode bsp)
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 int viewCount = Math.Max(activeViews.ViewCount, 0);
// two-pass walk (DrawMeshInternal @0x0059f3bf) — the punch fans var passSink = new PortalPassSink(building, sink);
// it emits clip against THAT view, so the sink carries the index. Vector3 viewpoint = ctx.ViewpointInBuilding(building);
passSink.ActiveViewIndex = v; for (int v = 0; v < viewCount; v++)
ctx.SetActiveView(activeViews, v); {
WalkBuildingPortals.BuildDrawPortalsOnly( // Retail pins building_view = the CURRENT view for the whole
bsp, 1, viewpoint, // two-pass walk (DrawMeshInternal @0x0059f3bf).
(portalRef, pass) => WalkBuildingPortals.DrawPortal( passSink.ActiveViewIndex = v;
_outdoorPView, building, portalRef, pass, ctx, passSink)); ctx.SetActiveView(activeViews, v);
WalkBuildingPortals.BuildDrawPortalsOnly( WalkBuildingPortals.BuildDrawPortalsOnly(
bsp, 2, viewpoint, bsp, 1, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal( (portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink)); _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) // Additive (Campaign FW3.2b-1): CPhysicsPart::Draw(parts, 0)
// @0x0059f331 — the building's own shell mesh — runs AFTER the // @0x0059f331 — the building's own shell mesh — runs AFTER the
// portal walk completes (CPhysicsPart::Draw(parts, 1) just above), // portal walk completes (CPhysicsPart::Draw(parts, 1) just above),
// not before it. // not before it.
sink.OnBuildingShellTurn(building); sink.OnBuildingShellTurn(building, selection);
} }
/// <summary>Returns the extracted cell-id array so <see cref="DrawInside"/> /// <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 /// <summary>One degrade-ladder level: the drawing BSP of that level's
/// GfxObj (portal-only view) and the level's authored distance bands.</summary> /// GfxObj (portal-only view) and the level's authored distance bands.</summary>
public readonly record struct WalkBuildingDegradeLevel( 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> + /// <summary>The walk's building model (retail <c>CBuildingObj</c> +
/// <c>BuildInfo</c> as the frame walk consumes them).</summary> /// <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> /// Used directly when the model has no degrade ladder.</summary>
public WalkBspNode? DrawingBsp; 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 /// <summary>The degrade ladder (near→far). Retail walks the CURRENT
/// level's drawing BSP (<c>part->gfxobj[deg_level]</c>) — and building /// level's drawing BSP (<c>part->gfxobj[deg_level]</c>) — and building
/// degrade models beyond the first band carry NO portal nodes, which is /// 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> /// every Holtburg building has ports at level 0 only).</summary>
public WalkBuildingDegradeLevel[] DegradeLevels = []; 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 /// <summary>The base GfxObj's sort center — retail measures the viewer
/// distance to the part's SCALED sort center, not the position origin /// distance to the part's SCALED sort center, not the position origin
/// (<c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030).</summary> /// (<c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030).</summary>
public Vector3 SortCenter; public Vector3 SortCenter;
/// <summary>Retail <c>Render::s_rDegradeDistance</c> — a subtractive /// <summary>Part-zero Setup resting/default-scale transform. Direct Gfx
/// slack before the ladder applies (live-dumped 100 on the capture /// buildings use identity.</summary>
/// client; registry-configurable).</summary> public Matrix4x4 PartZeroTransform = Matrix4x4.Identity;
public const float DefaultDegradeDistance = 100f;
/// <summary>The live capture client's <c>Render::deg_mul</c> arm: /// <summary>The selected part scale's Z component used by retail's
/// magnitude 0.99 pinned by the walkout-F2 fixture (building a9b4001e, /// UpdateViewerDistance division.</summary>
/// effective 30.3, level 0 ideal/max 24/48 — retail floods, so its public float PartZeroScaleZ = 1f;
/// 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> /// <summary>
/// <c>GfxObjDegradeInfo::get_degrade</c> @0x0051e4b0 (BN flag-mush on /// <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, /// mul is <c>Render::deg_mul</c> when <c>auto_update_deg_mul</c> is on,
/// else <c>s_rUserSuppliedDegradeBias</c>. /// else <c>s_rUserSuppliedDegradeBias</c>.
/// </summary> /// </summary>
public WalkBspNode? SelectDrawingBsp( public WalkBuildingSelection Select(
float viewerDistance, float viewerDistance,
float degradeDistance = DefaultDegradeDistance, float degradeDistance,
float degradeMultiplier = DefaultDegradeMultiplier) 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); 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 WalkBuildingDegradeLevel level = DegradeLevels[i];
? level.IdealDist - (level.IdealDist - level.MaxDist) * degradeMultiplier double threshold = degradeMultiplier >= 0f
: level.IdealDist + (level.IdealDist - level.MinDist) * degradeMultiplier; ? (double)level.IdealDist
- ((double)level.IdealDist - level.MaxDist) * degradeMultiplier
: (double)level.IdealDist
+ ((double)level.IdealDist - level.MinDist) * degradeMultiplier;
if (effective < threshold) 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 System.Numerics;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -37,7 +39,21 @@ public static class WalkBuildingFactory
/// placement transform (<paramref name="WorldTransform"/> already has the /// placement transform (<paramref name="WorldTransform"/> already has the
/// caller's block offset baked in — see <see cref="Build"/>).</summary> /// caller's block offset baked in — see <see cref="Build"/>).</summary>
public sealed record Entry( 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"/> /// <summary>Builds every building of one landblock. <paramref name="lbOffset"/>
/// is the SAME landblock-local world offset every other production /// is the SAME landblock-local world offset every other production
@ -84,8 +100,32 @@ public static class WalkBuildingFactory
WalkBspNode? bsp = null; WalkBspNode? bsp = null;
Vector3 sortCenter = Vector3.Zero; Vector3 sortCenter = Vector3.Zero;
uint partZeroGfxObjId = 0;
Matrix4x4 partZeroTransform = Matrix4x4.Identity;
float partZeroScaleZ = 1f;
var degradeLevels = new List<WalkBuildingDegradeLevel>(); 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); bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
sortCenter = new Vector3(gfxObj.SortCenter.X, gfxObj.SortCenter.Y, gfxObj.SortCenter.Z); 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); levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
} }
degradeLevels.Add( 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, PositionCellId = positionCellId,
Portals = portals, Portals = portals,
GfxObjId = partZeroGfxObjId,
DrawingBsp = bsp, DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(), DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter, SortCenter = sortCenter,
PartZeroTransform = partZeroTransform,
PartZeroScaleZ = partZeroScaleZ,
}, },
worldTransform, worldTransform,
inverse)); inverse));

View file

@ -176,9 +176,8 @@ public interface IWalkEventSink
/// draw) in <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c> /// draw) in <c>if (part-&gt;gfxobj[part-&gt;deg_level] != 0)</c>
/// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the /// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the
/// unconditional <see cref="WalkEventKind.Building"/> <see cref="Emit"/> /// unconditional <see cref="WalkEventKind.Building"/> <see cref="Emit"/>
/// call. <see cref="WalkBuilding.HasGeometry"/> and a non-null /// call. The selected GfxObj id alone models that gate; a null drawing
/// <see cref="WalkBuilding.SelectDrawingBsp"/> result together model that /// BSP only suppresses the portal sub-walk. This is the
/// one gate, so this hook fires only after both have passed. This is the
/// ALPHA BARRIER turn (<c>D3DPolyRender::FlushAlphaList(0f)</c> /// ALPHA BARRIER turn (<c>D3DPolyRender::FlushAlphaList(0f)</c>
/// @0x0059f30b) — retail's own order runs it BEFORE the portal pass /// @0x0059f30b) — retail's own order runs it BEFORE the portal pass
/// (<c>CPhysicsPart::Draw(parts, 1)</c>), which in turn runs BEFORE the /// (<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 /// fires when <see cref="OnBuildingTurn"/> also fired (same gate; see its
/// doc comment) — a degraded-out slot reaches neither. Default no-op. /// doc comment) — a degraded-out slot reaches neither. Default no-op.
/// </summary> /// </summary>
void OnBuildingShellTurn(WalkBuilding building) { } void OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection) { }
/// <summary> /// <summary>
/// Fires when the two-pass portal machinery /// 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 /// <summary>Building-local → world, for transforming a punch polygon
/// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the /// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the
/// production implementation is <see cref="WalkBuildingRegistry.TryGetEntry"/>'s /// 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); Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
} }
@ -1590,7 +1591,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_currentDcStage = WalkDrawStage.LookInStatic; _currentDcStage = WalkDrawStage.LookInStatic;
} }
void IWalkEventSink.OnBuildingShellTurn(WalkBuilding building) void IWalkEventSink.OnBuildingShellTurn(
WalkBuilding building,
WalkBuildingSelection selection)
{ {
ArgumentNullException.ThrowIfNull(building); ArgumentNullException.ThrowIfNull(building);
RequireOpenFrame(); RequireOpenFrame();
@ -1602,10 +1605,27 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// look-in flood appended (keeps every range single-stage). // look-in flood appended (keeps every range single-stage).
MarkIfGrown(); MarkIfGrown();
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building); WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
_populator.PopulateCell( if (shell.Records.Count > 1)
_stream, WalkDrawStage.BuildingShell, building.PositionCellId, {
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection, throw new InvalidOperationException(
alphaSubmissions: _alphaSubmissions); $"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) if (_alphaSubmissions.Count != _alphaSubmitMark)
{ {
MarkIfGrown(); MarkIfGrown();

View file

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

View file

@ -543,6 +543,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
throw new InvalidOperationException( throw new InvalidOperationException(
$"walk building 0x{building.PositionCellId:X8} has no committed registry entry"); $"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> /// <summary>
/// The landscape entry point: outdoor static content /// The landscape entry point: outdoor static content
/// (<c>RenderProjectionClass.OutdoorStatic</c> — /// (<c>RenderProjectionClass.OutdoorStatic</c> —
@ -275,7 +303,9 @@ internal sealed class WalkStaticStreamPopulator
bool liveDynamic = false, bool liveDynamic = false,
IWalkLookInViewSource? lookInViews = null, IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1, int lookInRouteIndex = -1,
List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null) List<WbDrawDispatcher.WalkClassifiedBatch>? alphaSubmissions = null,
WalkBuildingSelection? buildingSelection = null,
Matrix4x4 buildingPartTransform = default)
{ {
_batchScratch.Clear(); _batchScratch.Clear();
_selectionScratch.Clear(); _selectionScratch.Clear();
@ -288,7 +318,9 @@ internal sealed class WalkStaticStreamPopulator
lookInViews, lookInViews,
lookInRouteIndex, lookInRouteIndex,
cellId, cellId,
diagnosticViewProjection: viewProjection); diagnosticViewProjection: viewProjection,
buildingSelection: buildingSelection,
buildingPartTransform: buildingPartTransform);
for (int i = 0; i < _batchScratch.Count; i++) for (int i = 0; i < _batchScratch.Count; i++)
{ {

View file

@ -44,6 +44,7 @@ public sealed class EnvCellLandblockBuild
VisibilityCells = visibilityCells.ToImmutableArray(); VisibilityCells = visibilityCells.ToImmutableArray();
Shells = shells.ToImmutableArray(); Shells = shells.ToImmutableArray();
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray(); WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
WalkBuildingMeshDependencies = CollectWalkBuildingMeshDependencies(WalkBuildings);
WalkMaxZ = walkMaxZ; WalkMaxZ = walkMaxZ;
WalkMinZ = walkMinZ; WalkMinZ = walkMinZ;
@ -67,6 +68,14 @@ public sealed class EnvCellLandblockBuild
/// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary> /// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary>
public ImmutableArray<WalkBuildingFactory.Entry> WalkBuildings { get; } 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 /// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block /// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block
/// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>). /// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>).
@ -77,6 +86,24 @@ public sealed class EnvCellLandblockBuild
/// <summary>Campaign FW3.1: this landblock's retail z-slab /// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[minByte] 1</c>). See <see cref="WalkMaxZ"/>.</summary> /// (<c>heightTable[minByte] 1</c>). See <see cref="WalkMaxZ"/>.</summary>
public float WalkMinZ { get; } 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> /// <summary>

View file

@ -76,7 +76,9 @@ public sealed class LandblockSpawnAdapter
/// </summary> /// </summary>
public void OnLandblockLoaded( public void OnLandblockLoaded(
LoadedLandblock landblock, LoadedLandblock landblock,
IEnumerable<ulong>? additionalReadinessIds = null) IEnumerable<ulong>? additionalReadinessIds = null,
IEnumerable<ulong>? additionalOrdinaryIds = null,
bool replaceExisting = false)
{ {
System.ArgumentNullException.ThrowIfNull(landblock); System.ArgumentNullException.ThrowIfNull(landblock);
@ -90,6 +92,8 @@ public sealed class LandblockSpawnAdapter
foreach (var meshRef in entity.MeshRefs) foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId); unique.Add((ulong)meshRef.GfxObjId);
} }
if (additionalOrdinaryIds is not null)
unique.UnionWith(additionalOrdinaryIds.Where(static id => id != 0));
HashSet<ulong>? preparedIds = additionalReadinessIds is null HashSet<ulong>? preparedIds = additionalReadinessIds is null
? null ? null
@ -110,6 +114,11 @@ public sealed class LandblockSpawnAdapter
MarkAllUndesired(registration.Prepared); MarkAllUndesired(registration.Prepared);
registration.WantsLoaded = true; registration.WantsLoaded = true;
} }
else if (replaceExisting)
{
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
}
MarkDesired(registration.Ordinary, unique); MarkDesired(registration.Ordinary, unique);
if (preparedIds is not null) 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 /// (<c>TranslucencyFadeManager</c>/<c>EntityOpacity</c>) is NOT threaded
/// through: that mechanic is <c>TransparentPartHook</c>, a retail LIVE-entity /// through: that mechanic is <c>TransparentPartHook</c>, a retail LIVE-entity
/// behavior keyed by ServerGuid, not something world statics undergo — every /// behavior keyed by ServerGuid, not something world statics undergo — every
/// classified batch here carries <c>Alpha = 1f</c>. Likewise the async /// classified batch here carries <c>Alpha = 1f</c>. A mesh miss uses the
/// mesh-miss self-heal request (<c>_missRequested</c>/<c>EnsureLoaded</c>, /// existing frame-scoped <c>_missRequested</c>/<c>EnsureLoaded</c> seam and
/// frame-scoped state cleared by <c>BeginEntityDispatch</c>) is not fired /// skips this frame; the caller retries naturally on the following frame.</para>
/// 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>
/// </summary> /// </summary>
public sealed partial class WbDrawDispatcher 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."); "A walk part frame was opened before the previous scope closed.");
} }
_missRequested.Clear();
_walkDrawnParts.Clear(); _walkDrawnParts.Clear();
BeginFacilityStairSubmissionProbeFrame(); BeginFacilityStairSubmissionProbeFrame();
_walkPartFrameActive = true; _walkPartFrameActive = true;
@ -274,7 +273,9 @@ public sealed partial class WbDrawDispatcher
IWalkLookInViewSource? lookInViews = null, IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1, int lookInRouteIndex = -1,
uint lookInCellId = 0, uint lookInCellId = 0,
Matrix4x4 diagnosticViewProjection = default) Matrix4x4 diagnosticViewProjection = default,
WalkBuildingSelection? buildingSelection = null,
Matrix4x4 buildingPartTransform = default)
{ {
ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts); ArgumentNullException.ThrowIfNull(selectionParts);
@ -316,12 +317,22 @@ public sealed partial class WbDrawDispatcher
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride); paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
IReadOnlyList<MeshRef>? meshRefs = projection.EntityPayload.MeshRefs; IReadOnlyList<MeshRef>? meshRefs = projection.EntityPayload.MeshRefs;
if (meshRefs is null) if (buildingSelection is null && meshRefs is null)
return; 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)) if (_meshAdapter.IsRuntimeHiddenMarker(meshRef.GfxObjId))
continue; continue;
@ -333,7 +344,9 @@ public sealed partial class WbDrawDispatcher
continue; 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 // Same entity-scoped OR the classic/packed classifiers compute
// — a Setup composite's parts are separate GfxObjs with their // — a Setup composite's parts are separate GfxObjs with their

View file

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

View file

@ -957,6 +957,8 @@ public sealed class LandblockPresentationPipeline
IEnumerable<ulong>? renderIds = IEnumerable<ulong>? renderIds =
transaction.Build.EnvCells?.Shells.Select( transaction.Build.EnvCells?.Shells.Select(
static shell => shell.GeometryId); static shell => shell.GeometryId);
IEnumerable<ulong>? ordinaryRenderIds =
transaction.Build.EnvCells?.WalkBuildingMeshDependencies;
transaction.SpatialPublication = transaction.Kind switch transaction.SpatialPublication = transaction.Kind switch
{ {
PublicationKind.Loaded PublicationKind.Loaded
@ -965,12 +967,14 @@ public sealed class LandblockPresentationPipeline
_state.CommitLandblockSpatial( _state.CommitLandblockSpatial(
transaction.Build.Landblock, transaction.Build.Landblock,
renderIds, renderIds,
transaction.Tier), transaction.Tier,
ordinaryRenderIds),
PublicationKind.PromoteExisting => PublicationKind.PromoteExisting =>
_state.CommitEntitiesToExistingLandblockSpatial( _state.CommitEntitiesToExistingLandblockSpatial(
transaction.LandblockId, transaction.LandblockId,
transaction.Build.Landblock.Entities, transaction.Build.Landblock.Entities,
renderIds), renderIds,
ordinaryRenderIds),
_ => throw new InvalidOperationException( _ => throw new InvalidOperationException(
$"Unknown landblock publication kind {transaction.Kind}."), $"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> /// Brightness (OP6 rework, review S2: its OWN <c>DisplaySettings.ScreenBrightness</c>
/// field, range [-1,1] default 0 — NOT the pre-existing <c>Gamma</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 /// multiplier, a different unit system with its own live legacy-panel
/// consumer; no gamma-correction render pass exists for either); Automatic /// consumer; no gamma-correction render pass exists for either); Landscape
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail- /// Texture Detail/Environment Texture Detail/Texture Filtering/Multi-Pass
/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate /// Alpha (the renderer is Vulkan + one aggregate <c>QualityPreset</c>, no
/// <c>QualityPreset</c>, no per-feature knobs). Building Detail Textures is /// per-feature knobs). Automatic Degrades, Graphics Performance, and Degrade
/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass; /// 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 /// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no /// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to /// persistent mouse-turning camera mode exists for ANY of these six to
@ -138,8 +141,8 @@ namespace AcDream.App.UI.Layout;
/// ///
/// <para> /// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> Every /// <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 /// STORE-ONLY row above (the 16 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 /// -- 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 /// AP-200's two Chat-font rows) renders its caption in
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> instead of the normal /// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> instead of the normal
/// white/DAT-authored color. The row stays fully interactive -- it still /// 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, listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().AutomaticDegrades, read: () => bindings.LoadDisplay().AutomaticDegrades,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }),
storeOnly: true); // AP-198 storeOnly: false);
BuildSliderRow( BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias", listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
min: -1f, max: 1f, defaultValue: 0f, page, resolveString, min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().GraphicsPerformance, read: () => bindings.LoadDisplay().GraphicsPerformance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }), 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"); rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail");
BuildSliderRow( BuildSliderRow(
@ -1246,7 +1249,7 @@ public static class ConfigOptionsPageController
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString, min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
read: () => bindings.LoadDisplay().DegradeDistance, read: () => bindings.LoadDisplay().DegradeDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }), 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"); rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far");
display = bindings.LoadDisplay(); display = bindings.LoadDisplay();

View file

@ -156,11 +156,10 @@ public sealed record DisplaySettings(
bool ShowFps, bool ShowFps,
QualityPreset Quality, QualityPreset Quality,
ParticleRange ParticleRange, ParticleRange ParticleRange,
// Campaign OP slice OP6: the Config tab's "Graphics Options" + // Campaign OP slice OP6: Config's graphics preferences. S5-c3 makes
// "Rendering Quality Options" rows with no acdream renderer consumer — // AutomaticDegrades, GraphicsPerformance and DegradeDistance live for
// the world renderer is Vulkan + one aggregate QualityPreset, not // building part-zero selection; the remaining per-feature knobs stay
// per-feature knobs (register row, OP6). Persisted faithfully; every // store-only under AP-198. Every default below is retail's byte-verified
// default below is retail's own byte-verified
// gmClient::InitUIPreferences / gmConfigUI::InitOptions literal. // gmClient::InitUIPreferences / gmConfigUI::InitOptions literal.
// //
// OP6 rework (2026-08-11, review S2): Screen Brightness gets its OWN // 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;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Composition; namespace AcDream.App.Tests.Composition;
@ -36,6 +37,29 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint.InventoryContainerBound, 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] [Fact]
public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter() public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter()
{ {
@ -278,6 +302,8 @@ public sealed class InteractionRetainedUiCompositionTests
new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(), new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(),
KeyBindingsFilePath: "keybinds.json", KeyBindingsFilePath: "keybinds.json",
Settings: null!, Settings: null!,
BuildingDegrades: new BuildingDegradeController(
() => DisplaySettings.Default),
Runtime: runtime, Runtime: runtime,
CombatAttackOperations: new NoopCombatOperations(), CombatAttackOperations: new NoopCombatOperations(),
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(), 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 System.Reflection;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
@ -84,6 +85,43 @@ public sealed class RenderFrameOrchestratorTests
Assert.Empty(phases.ObservedInputs); 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] [Fact]
public void BeginFailure_DoesNotAttemptAnyPhaseOrClose() public void BeginFailure_DoesNotAttemptAnyPhaseOrClose()
{ {
@ -278,6 +316,7 @@ public sealed class RenderFrameOrchestratorTests
typeof(IRenderFrameDiagnosticsPhase), typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFramePostDiagnosticsPhase), typeof(IRenderFramePostDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery), typeof(IRenderFrameFailureRecovery),
typeof(IBuildingDegradeFrameTick),
]; ];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic); BindingFlags.Instance | BindingFlags.NonPublic);
@ -293,7 +332,7 @@ public sealed class RenderFrameOrchestratorTests
Assert.All( Assert.All(
expectedFieldTypes, expectedFieldTypes,
contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow)))); 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()) foreach (MethodInfo method in contract.GetMethods())
{ {
@ -395,6 +434,25 @@ public sealed class RenderFrameOrchestratorTests
private static RenderFrameOrchestrator Create(RecordingPhases phases) => private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases, phases, phases, 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 : private sealed class RecordingPhases :
IRenderFrameLifetime, IRenderFrameLifetime,
IRenderFrameGpuMeasurement, IRenderFrameGpuMeasurement,

View file

@ -15,6 +15,14 @@ public sealed class RetailFrameWalkTests
/// them — proves the terrain/building/object-turn ORDER, not just /// them — proves the terrain/building/object-turn ORDER, not just
/// each vocabulary in isolation.</summary> /// each vocabulary in isolation.</summary>
public readonly List<string> Combined = new(); 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) public void Emit(in WalkEvent walkEvent)
{ {
@ -66,7 +74,7 @@ public sealed class RetailFrameWalkTests
=> new(screenX, screenY, 100f); => new(screenX, screenY, 100f);
} }
private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext private class TestContext : IWalkFrameContext, IRetailFrameWalkContext
{ {
public readonly Dictionary<uint, WalkCell> Cells = new(); public readonly Dictionary<uint, WalkCell> Cells = new();
private readonly Matrix4x4 _viewProj; private readonly Matrix4x4 _viewProj;
@ -88,7 +96,7 @@ public sealed class RetailFrameWalkTests
public float ViewportHeight => 480f; public float ViewportHeight => 480f;
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero; public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
public float ViewerDistanceTo(WalkBuilding building) => 0f; public virtual float ViewerDistanceTo(WalkBuilding building) => 0f;
public IWalkFrameContext CellContext => this; public IWalkFrameContext CellContext => this;
// Permissive near plane: every column wholly inside. // Permissive near plane: every column wholly inside.
// S3 chunk 3 (T2): settable, default unchanged — a permissive // S3 chunk 3 (T2): settable, default unchanged — a permissive
@ -165,11 +173,154 @@ public sealed class RetailFrameWalkTests
var ctx = new TestContext(); var ctx = new TestContext();
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk();
var recorder = new Recorder(); 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); walk.DrawBuilding(building, new WalkPortalView(), ctx, recorder);
Assert.Equal("BLD:f518002e", recorder.Signature()); 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) // ── T2 (S3 chunk 3 §9.3): the per-cell interleave — DrawLandCell (LC)
@ -224,7 +375,7 @@ public sealed class RetailFrameWalkTests
nearBlock.EnsureCellArrays(); nearBlock.EnsureCellArrays();
// A building at an ordinary in-view cell (cx=3, cy=0), away from // A building at an ordinary in-view cell (cx=3, cy=0), away from
// both the excluded column and the closest/farthest cells. // 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; nearBlock.CellBuildings[3 * 8 + 0] = building;
// The far block (grid slot 3 = (1,1), the diagonal corner — drawn // 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 var building = new WalkBuilding
{ {
PositionCellId = 0xA9B4000Fu, PositionCellId = 0xA9B4000Fu,
GfxObjId = (uint)shellGfxObj,
Portals = Portals =
[ [
new WalkBldPortal new WalkBldPortal
@ -1139,6 +1140,36 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); 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 // ── S4-c1 C1/T2 (quantifier corrected at fix round 1 F1): DrawPortalPoly
// Internal's degenerate-input guard, ported at the punch-fan producer // Internal's degenerate-input guard, ported at the punch-fan producer
// (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL // (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL

View file

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

View file

@ -161,7 +161,7 @@ public sealed class WalkProductionWorldConformanceTests
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) = Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin); BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings }; var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, null, assembler.Landscape, ctx, 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: // Matches WalkTraceConformanceTests.Doorway_still_first_frame_diff:
// this capture ran under cdb load with Render::deg_mul depressed to // this capture ran under cdb load with Render::deg_mul depressed to
// the portless-arm threshold. Same environment pin, same reason. // 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(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, 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 WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
? Assert.Contains(frame.Pose.CellId, cells) ? Assert.Contains(frame.Pose.CellId, cells)
: null; : null;
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, 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, cameraCellId,
camera, camera,
assembler.Landscape, assembler.Landscape,
@ -348,7 +348,7 @@ public sealed class WalkProductionWorldConformanceTests
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells, (WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) = Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
BuildProductionWorld(adapter, anchor.CellId, anchor.Origin); 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++) for (int n = 1; n < frames.Count - 1; n++)
{ {
@ -402,7 +402,7 @@ public sealed class WalkProductionWorldConformanceTests
Assert.NotNull(frame.Pose); Assert.NotNull(frame.Pose);
WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells); WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
var ctx = new WalkTraceReplayContext(frame.Pose, cells); var ctx = new WalkTraceReplayContext(frame.Pose, cells);
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, 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); 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] [Fact]
public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass() public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass()
{ {
@ -1005,6 +1259,31 @@ public sealed class WalkStaticStreamPopulatorTests
return group; 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] [Fact]
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope() public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
{ {
@ -1098,7 +1377,8 @@ public sealed class WalkStaticStreamPopulatorTests
bool withAlphaQueue = false, bool withAlphaQueue = false,
IRetailSelectionRenderSink? selectionSink = null, IRetailSelectionRenderSink? selectionSink = null,
bool detailAvailable = false, bool detailAvailable = false,
bool detailEnabled = false) bool detailEnabled = false,
IPreparedAssetSource? preparedAssets = null)
{ {
Device = new RecordingGpuDevice(); Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device); FrameLifetime = new GpuDeviceFrameLifetime(Device);
@ -1107,7 +1387,7 @@ public sealed class WalkStaticStreamPopulatorTests
_meshAdapter = new WbMeshAdapter( _meshAdapter = new WbMeshAdapter(
Device, Device,
new NoopDatReaderWriter(), new NoopDatReaderWriter(),
new NullPreparedAssetSource(), preparedAssets ?? new NullPreparedAssetSource(),
NullLogger<WbMeshAdapter>.Instance, NullLogger<WbMeshAdapter>.Instance,
Device.Retirement); Device.Retirement);
var entitySpawnAdapter = new EntitySpawnAdapter( var entitySpawnAdapter = new EntitySpawnAdapter(
@ -1150,6 +1430,8 @@ public sealed class WalkStaticStreamPopulatorTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!; public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public WbMeshAdapter Adapter => _meshAdapter;
public DrawScope BeginDraw(bool beginAlpha = false) public DrawScope BeginDraw(bool beginAlpha = false)
{ {
if (beginAlpha) 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 sealed class NoopDatReaderWriter : IDatReaderWriter
{ {
private readonly StubDatabase _portal = new(); private readonly StubDatabase _portal = new();

View file

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

View file

@ -139,7 +139,7 @@ public sealed partial class WalkTraceConformanceTests
{ {
Buildings = world.Buildings, Buildings = world.Buildings,
}; };
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, null, world.Landscape, ctx, 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 // 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 // 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. // 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(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder);
@ -257,7 +257,7 @@ public sealed partial class WalkTraceConformanceTests
Buildings = world.Buildings, Buildings = world.Buildings,
}; };
WalkCell camera = Assert.Contains(frame.Pose.CellId, world.Cells); WalkCell camera = Assert.Contains(frame.Pose.CellId, world.Cells);
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, 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 WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
? Assert.Contains(frame.Pose.CellId, world.Cells) ? Assert.Contains(frame.Pose.CellId, world.Cells)
: null; : null;
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, 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!; WalkOraclePose anchor = frames[1].Pose!;
WalkLandscapeDatBuilder.BuiltWorld world = WalkLandscapeDatBuilder.BuiltWorld world =
WalkLandscapeDatBuilder.Build(dats, anchor.CellId, anchor.Origin); 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++) for (int n = 1; n < frames.Count - 1; n++)
{ {
@ -423,7 +423,7 @@ public sealed partial class WalkTraceConformanceTests
Assert.NotNull(frame.Pose); Assert.NotNull(frame.Pose);
WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells); WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
var ctx = new WalkTraceReplayContext(frame.Pose, cells); var ctx = new WalkTraceReplayContext(frame.Pose, cells);
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder); walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder);
@ -467,7 +467,7 @@ public sealed partial class WalkTraceConformanceTests
{ {
Buildings = world.Buildings, Buildings = world.Buildings,
}; };
var walk = new RetailFrameWalk(); var walk = new RetailFrameWalk(100f, 0.99f);
var recorder = new Recorder(); var recorder = new Recorder();
walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, 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); 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, PositionCellId = positionCellId,
Portals = portals, Portals = portals,
GfxObjId = buildingInfo.ModelId,
DrawingBsp = bsp, DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(), DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter, SortCenter = sortCenter,

View file

@ -2,6 +2,7 @@ using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Wb; namespace AcDream.App.Tests.Rendering.Wb;
@ -85,6 +86,41 @@ public class EnvCellLandblockBuildTests
Assert.Throws<InvalidOperationException>(() => builder.Build()); 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) private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId)
{ {
var min = new Vector3(cellId & 0xFF, 0, 0); var min = new Vector3(cellId & 0xFF, 0, 0);

View file

@ -81,6 +81,85 @@ public sealed class LandblockSpawnAdapterReadinessTests
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts); 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] [Fact]
public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences() public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences()
{ {

View file

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

View file

@ -8,6 +8,8 @@ using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using DatReaderWriter.Enums;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Streaming; namespace AcDream.App.Tests.Streaming;
@ -140,6 +142,52 @@ public sealed class LandblockBuildFactoryTests
Assert.Equal((LandblockId & 0xFFFF0000u) | 1u, buildingEntry.Building.PositionCellId); 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] [Fact]
public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull() public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull()
{ {

View file

@ -1,14 +1,96 @@
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Numerics;
namespace AcDream.App.Tests.Streaming; namespace AcDream.App.Tests.Streaming;
public sealed class StreamingControllerReadinessTests 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] [Fact]
public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing() 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] [Fact]
public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey() public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey()
{ {
@ -1412,9 +1431,9 @@ public sealed class ConfigOptionsPageControllerTests
(15, RowKind.Toggle, false, "Full Screen"), // LIVE (15, RowKind.Toggle, false, "Full Screen"), // LIVE
(16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH (16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH
(17, RowKind.Slider, true, "Screen Brightness"), // review S2 (17, RowKind.Slider, true, "Screen Brightness"), // review S2
(18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198 (18, RowKind.Toggle, false, "Automatic Degrades"), // LIVE
(19, RowKind.Slider, true, "Graphics Performance"), // AP-198 (19, RowKind.Slider, false, "Graphics Performance"), // LIVE
(20, RowKind.Slider, true, "Degrade Distance"), // AP-198 (20, RowKind.Slider, false, "Degrade Distance"), // LIVE
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198 (23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198 (24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // 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. // at its empty default and hide the very thing this test checks.
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x"); (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x");
Assert.True(bound); Assert.True(bound);
Assert.Equal(16, DimmingExpectations.Count(expectation => expectation.StoreOnly));
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>( var listBox = Assert.IsType<UiTemplateListBox>(
@ -1506,6 +1526,22 @@ public sealed class ConfigOptionsPageControllerTests
Assert.Equal(25, bindings.Display.LandscapeDrawDistance); 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 ── // ── #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 // 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); 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.");
}
} }