fix(chargen): Campaign CC gate round 1 Batch D — gmCG3DView environment backdrop
Retail's chargen 3D views (Appearance and Summary) are not black behind the model: gmCG3DView::Update @0x004EE9D0 constructs a SECOND CPhysicsObj from the current heritage's HeritageGroup_CG.environmentSetupID field (acclient.h verbatim struct layout; the decompiler elides the actual field read, but HeritageGroup_CG::GetSubDataIDs @0x005c05d0 explicitly walks iconImage/setupID/environmentSetupID by name, confirming the identity) and adds it to the SAME viewport's creature_mode_objects the player object lives in, inserted BEFORE the player (whose own re-AddObject happens much later, at ~0x004ef199, after the full clothing ObjDesc composes). The backdrop gets no explicit position/orientation/scale — CPhysicsObj:: makeObject(eax_32, 0, 1) leaves it at the scene origin with identity orientation, same as the player object's own placement. This id was already parsed as ChargenHeritageOptions.EnvironmentSetupId (ChargenTableReader.cs) but never consumed anywhere in production (GF-7/ GF-14). Fixed by: - ChargenPreviewEntityBuilder.TryBuildBackdrop: builds a plain, unposed Setup mesh from the heritage's EnvironmentSetupId, returning null for id 0/unset or an unresolvable Setup (retail's own INVALID_DID gate). - PrivateEntityViewportRenderer: an optional second entity slot (SetBackdrop), reserved via a backdropRenderId constructor parameter so paperdoll and creature-appraisal — which never pass one — cannot acquire a second entity even by accident (SetBackdrop throws without a reserved slot). Per-entity mesh-reference/texture-owner lifetime is factored into a private EntitySlot helper shared by both the main and backdrop slots. Draw-entity assembly is a pure, directly-testable helper (BuildDrawEntities) that puts the backdrop first, matching retail's own AddObject insertion order. - ChargenPreviewController.Rebuild: rebuilds the backdrop whenever the HERITAGE changes (narrower than the existing camera-eye-reset gate, since environmentSetupID is a pure function of heritage, never gender or appearance selection). Both Appearance and Summary get the fix from the same ChargenPreviewRenderer facade — confirmed both pages call the identical gmCG3DView::Update on their own gmCG3DView instance, so no page-specific code was needed. Lighting was independently re-verified against the same function's SetLight call (DISTANT_LIGHT, intensity 2.0, direction (0.3, 1.9, 0.65), default white color) and found to already match byte-for-byte what CC6a shipped. Also files docs/ISSUES.md #409 for GF-16 (client-wide UI tooltip system), investigated in the same root-cause pass but explicitly out of this batch's scope, and marks it DEFERRED in the findings doc. Tests: 11 new/extended (ChargenPreviewEntityBuilderTests.TryBuildBackdrop_*, ChargenPreviewControllerTests backdrop rebuild/swap/absent/no-op cases, PrivateEntityViewportRendererDrawOrderTests pinning the paperdoll/creature- appraisal single-entity invariant). Live-DAT measurement: all 13 retail heritages' EnvironmentSetupId resolve to a real, drawable installed Setup. App suite 5307/3 -> 5321/3 (+14, 0 regressions). Runtime 1735/0 unchanged. Launcher.Core.Tests 337/0 and Launcher.Tests 67/0 unchanged (first build of the merged tree carrying the #406 launcher merge). Full solution: 14508 total / 14504 passed / 4 skipped / 0 failed, dotnet test exit code 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0b05b58514
commit
63bf64c934
9 changed files with 866 additions and 103 deletions
|
|
@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera
|
|||
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
|
||||
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
|
||||
/// this renderer publishes on both call sites the same way.</para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Campaign CC gate round 1, Batch D (GF-7/GF-14).</b> Retail's
|
||||
/// <c>gmCG3DView::Update @0x004EE9D0</c> draws a SECOND private entity — a
|
||||
/// heritage-authored environment Setup (<c>m_pbgObject</c>) — behind the main
|
||||
/// one, in the SAME <c>creature_mode_objects</c> list. This renderer now
|
||||
/// supports that as an OPTIONAL second entity slot, reserved at construction
|
||||
/// via <paramref name="backdropRenderId"/>-shaped ctor param (see below) —
|
||||
/// paperdoll and creature-appraisal never pass one, so <see cref="SetBackdrop"/>
|
||||
/// throws for them rather than silently doing nothing (the slot does not
|
||||
/// exist). See <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for
|
||||
/// the full decomp citation of the backdrop's placement (unposed, at the
|
||||
/// scene origin, added to the draw list BEFORE the main entity).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class PrivateEntityViewportRenderer :
|
||||
IUiViewportRenderer,
|
||||
|
|
@ -68,21 +82,23 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
|
||||
private readonly WbDrawDispatcher _dispatcher;
|
||||
private readonly SceneLightingUboBinding _lightUbo;
|
||||
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
|
||||
private readonly IWbMeshAdapter _meshAdapter;
|
||||
private readonly IPrivateEntityViewportCamera _camera;
|
||||
private readonly HashSet<uint> _animatedIds;
|
||||
private readonly string _diagnosticName;
|
||||
private readonly List<SyntheticEntityMeshReferenceOwner>
|
||||
_retiringMeshReferences = [];
|
||||
|
||||
private readonly EntitySlot _mainSlot;
|
||||
|
||||
/// <summary>Null for every renderer that never reserved a
|
||||
/// <c>backdropRenderId</c> (paperdoll, creature-appraisal) — the backdrop
|
||||
/// feature does not exist for them, not just "unused".</summary>
|
||||
private readonly EntitySlot? _backdropSlot;
|
||||
|
||||
private IGpuRenderTarget? _target;
|
||||
private IGpuSampler? _sampler;
|
||||
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
||||
private int _fbW;
|
||||
private int _fbH;
|
||||
private WorldEntity? _entity;
|
||||
private SyntheticEntityMeshReferenceOwner? _meshReferences;
|
||||
|
||||
public PrivateEntityViewportRenderer(
|
||||
IWorldPassScope scope,
|
||||
|
|
@ -94,10 +110,13 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
IWbMeshAdapter meshAdapter,
|
||||
uint renderId,
|
||||
IPrivateEntityViewportCamera camera,
|
||||
string diagnosticName)
|
||||
string diagnosticName,
|
||||
uint? backdropRenderId = null)
|
||||
{
|
||||
if (renderId == 0u)
|
||||
throw new ArgumentOutOfRangeException(nameof(renderId));
|
||||
if (backdropRenderId == 0u)
|
||||
throw new ArgumentOutOfRangeException(nameof(backdropRenderId));
|
||||
|
||||
_scope = scope ?? throw new ArgumentNullException(
|
||||
nameof(scope),
|
||||
|
|
@ -112,10 +131,18 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
||||
? "creature viewport"
|
||||
: diagnosticName;
|
||||
_animatedIds = [renderId];
|
||||
_textureOwnerLease = new FixedEntityTextureOwnerLease(
|
||||
textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)),
|
||||
renderId);
|
||||
|
||||
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
|
||||
?? throw new ArgumentNullException(nameof(textureLifetime));
|
||||
|
||||
_mainSlot = new EntitySlot(_meshAdapter, textureLifetimeChecked, renderId, _diagnosticName);
|
||||
_backdropSlot = backdropRenderId is uint backdropId
|
||||
? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop")
|
||||
: null;
|
||||
|
||||
_animatedIds = backdropRenderId is uint animatedBackdropId
|
||||
? [renderId, animatedBackdropId]
|
||||
: [renderId];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -125,65 +152,27 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// </summary>
|
||||
public bool TextureIsBottomUp => false;
|
||||
|
||||
public void SetEntity(WorldEntity? entity)
|
||||
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or clears the environment backdrop entity drawn BEHIND the main
|
||||
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
|
||||
/// valid on a renderer constructed with a <c>backdropRenderId</c>
|
||||
/// (<see cref="ChargenPreviewRenderer"/>'s own construction); calling this
|
||||
/// on a renderer that never reserved one (paperdoll, creature-appraisal)
|
||||
/// throws — the slot does not exist for them, so there is nothing to make
|
||||
/// "inert" by silently ignoring the call instead.
|
||||
/// </summary>
|
||||
public void SetBackdrop(WorldEntity? entity)
|
||||
{
|
||||
ReleaseRetiringMeshReferences();
|
||||
|
||||
if (ReferenceEquals(_entity, entity))
|
||||
return;
|
||||
|
||||
SyntheticEntityMeshReferenceOwner? replacement = null;
|
||||
if (entity is not null)
|
||||
if (_backdropSlot is null)
|
||||
{
|
||||
replacement = new SyntheticEntityMeshReferenceOwner(
|
||||
_meshAdapter,
|
||||
CollectMeshIds(entity));
|
||||
replacement.Acquire();
|
||||
throw new InvalidOperationException(
|
||||
$"The {_diagnosticName} was not constructed with a "
|
||||
+ "backdropRenderId and cannot render a second (backdrop) entity.");
|
||||
}
|
||||
|
||||
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
|
||||
try
|
||||
{
|
||||
_textureOwnerLease.Replace(entity is not null);
|
||||
}
|
||||
catch (Exception textureFailure)
|
||||
{
|
||||
if (replacement is null)
|
||||
throw;
|
||||
|
||||
try
|
||||
{
|
||||
replacement.Dispose();
|
||||
}
|
||||
catch (Exception rollbackFailure)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"The {_diagnosticName} texture-owner replacement failed "
|
||||
+ "and the replacement mesh-owner rollback did not converge.",
|
||||
textureFailure,
|
||||
rollbackFailure);
|
||||
}
|
||||
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo
|
||||
.Capture(textureFailure)
|
||||
.Throw();
|
||||
}
|
||||
|
||||
_meshReferences = replacement;
|
||||
_entity = entity;
|
||||
|
||||
if (previous is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
previous.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_retiringMeshReferences.Add(previous);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
_backdropSlot.Set(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -193,7 +182,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
/// </summary>
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
WorldEntity? entity = _entity;
|
||||
WorldEntity? entity = _mainSlot.Entity;
|
||||
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||
return 0u;
|
||||
|
||||
|
|
@ -232,7 +221,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
|
||||
UploadCreatureLight();
|
||||
|
||||
WorldEntity[] entities = [entity];
|
||||
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
|
||||
var entries =
|
||||
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
|
||||
IReadOnlyDictionary<uint, WorldEntity>?)[]
|
||||
|
|
@ -241,7 +230,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
PrivateLandblockId,
|
||||
new Vector3(-1024f),
|
||||
new Vector3(1024f),
|
||||
entities,
|
||||
drawEntities,
|
||||
null),
|
||||
};
|
||||
|
||||
|
|
@ -255,9 +244,34 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
return UiTextureTableHandle.FromSlot(_slot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helper assembling this frame's draw-entity list in retail's own
|
||||
/// insertion order — <c>gmCG3DView::Update</c> adds the backdrop object to
|
||||
/// <c>creature_mode_objects</c> BEFORE the main (player) object is
|
||||
/// re-added (the player's own re-<c>AddObject</c> happens much later, at
|
||||
/// ~0x004ef199, after the full clothing ObjDesc composes — see
|
||||
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/>'s own decomp
|
||||
/// citation). A null or empty-meshed backdrop degrades to exactly the main
|
||||
/// entity — this is the paperdoll/creature-appraisal invariant (they never
|
||||
/// configure a backdrop slot at all, so this always takes this branch for
|
||||
/// them), pinned directly by
|
||||
/// <c>PrivateEntityViewportRendererDrawOrderTests</c> without needing a
|
||||
/// live GPU device or a constructed <see cref="WbDrawDispatcher"/>.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<WorldEntity> BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) =>
|
||||
backdrop is not null && backdrop.MeshRefs.Count > 0
|
||||
? [backdrop, main]
|
||||
: [main];
|
||||
|
||||
/// <summary>
|
||||
/// Both retail paperdoll and creature examination call
|
||||
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
|
||||
/// Byte-decoded confirmation (Batch D re-derivation): the SAME three
|
||||
/// float32 constants (<c>0x3e99999a</c>/<c>0x3ff33333</c>/<c>0x3F266666</c>
|
||||
/// = 0.3/1.9/0.65) appear verbatim at <c>gmCG3DView::Update</c>'s own
|
||||
/// <c>SetLight</c> call site (pseudo-C ~0x004eecd3-0x004eece3) — the
|
||||
/// chargen preview uses the EXACT same light this method already ported,
|
||||
/// not a different value.
|
||||
/// </summary>
|
||||
private void UploadCreatureLight()
|
||||
{
|
||||
|
|
@ -342,17 +356,10 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_entity = null;
|
||||
if (_meshReferences is { } current)
|
||||
{
|
||||
_meshReferences = null;
|
||||
_retiringMeshReferences.Add(current);
|
||||
}
|
||||
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
_textureOwnerLease.Dispose();
|
||||
_mainSlot.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -360,7 +367,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
}
|
||||
try
|
||||
{
|
||||
ReleaseRetiringMeshReferences();
|
||||
_backdropSlot?.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -391,30 +398,158 @@ internal sealed class PrivateEntityViewportRenderer :
|
|||
yield return entity.PartOverrides[i].GfxObjId;
|
||||
}
|
||||
|
||||
private void ReleaseRetiringMeshReferences()
|
||||
/// <summary>
|
||||
/// One private entity's own mesh-reference/texture-owner lifetime,
|
||||
/// independent of any other slot on the same renderer. Factored out at
|
||||
/// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets
|
||||
/// the EXACT SAME acquire/replace/retire behavior the main entity already
|
||||
/// had — a single-owner class shared by both slots rather than a second,
|
||||
/// hand-duplicated copy of <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s
|
||||
/// pre-Batch-D body.
|
||||
/// </summary>
|
||||
private sealed class EntitySlot
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--)
|
||||
private readonly IWbMeshAdapter _meshAdapter;
|
||||
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
|
||||
private readonly string _diagnosticName;
|
||||
private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = [];
|
||||
|
||||
private SyntheticEntityMeshReferenceOwner? _meshReferences;
|
||||
|
||||
public EntitySlot(
|
||||
IWbMeshAdapter meshAdapter,
|
||||
IEntityTextureLifetime textureLifetime,
|
||||
uint ownerLocalId,
|
||||
string diagnosticName)
|
||||
{
|
||||
SyntheticEntityMeshReferenceOwner owner =
|
||||
_retiringMeshReferences[i];
|
||||
_meshAdapter = meshAdapter;
|
||||
_textureOwnerLease = new FixedEntityTextureOwnerLease(textureLifetime, ownerLocalId);
|
||||
_diagnosticName = diagnosticName;
|
||||
}
|
||||
|
||||
public WorldEntity? Entity { get; private set; }
|
||||
|
||||
public void Set(WorldEntity? entity)
|
||||
{
|
||||
ReleaseRetiringMeshReferences();
|
||||
|
||||
if (ReferenceEquals(Entity, entity))
|
||||
return;
|
||||
|
||||
SyntheticEntityMeshReferenceOwner? replacement = null;
|
||||
if (entity is not null)
|
||||
{
|
||||
replacement = new SyntheticEntityMeshReferenceOwner(
|
||||
_meshAdapter,
|
||||
CollectMeshIds(entity));
|
||||
replacement.Acquire();
|
||||
}
|
||||
|
||||
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
|
||||
try
|
||||
{
|
||||
owner.Dispose();
|
||||
if (owner.IsDisposed)
|
||||
_retiringMeshReferences.RemoveAt(i);
|
||||
_textureOwnerLease.Replace(entity is not null);
|
||||
}
|
||||
catch (Exception textureFailure)
|
||||
{
|
||||
if (replacement is null)
|
||||
throw;
|
||||
|
||||
try
|
||||
{
|
||||
replacement.Dispose();
|
||||
}
|
||||
catch (Exception rollbackFailure)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"The {_diagnosticName} texture-owner replacement failed "
|
||||
+ "and the replacement mesh-owner rollback did not converge.",
|
||||
textureFailure,
|
||||
rollbackFailure);
|
||||
}
|
||||
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo
|
||||
.Capture(textureFailure)
|
||||
.Throw();
|
||||
}
|
||||
|
||||
_meshReferences = replacement;
|
||||
Entity = entity;
|
||||
|
||||
if (previous is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
previous.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_retiringMeshReferences.Add(previous);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Entity = null;
|
||||
if (_meshReferences is { } current)
|
||||
{
|
||||
_meshReferences = null;
|
||||
_retiringMeshReferences.Add(current);
|
||||
}
|
||||
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
_textureOwnerLease.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
try
|
||||
{
|
||||
ReleaseRetiringMeshReferences();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"The {_diagnosticName} resources did not fully release.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
private void ReleaseRetiringMeshReferences()
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"One or more {_diagnosticName} mesh owners remain pending.",
|
||||
failures);
|
||||
List<Exception>? failures = null;
|
||||
for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--)
|
||||
{
|
||||
SyntheticEntityMeshReferenceOwner owner =
|
||||
_retiringMeshReferences[i];
|
||||
try
|
||||
{
|
||||
owner.Dispose();
|
||||
if (owner.IsDisposed)
|
||||
_retiringMeshReferences.RemoveAt(i);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"One or more {_diagnosticName} mesh owners remain pending.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue