feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation
Delivers the CC6a foundation half of the chargen 3D preview: the missing index->ObjDesc appearance factory the campaign plan's acdream-seams section named, plus a static-pose offscreen renderer following PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture. Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges). Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces): ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's ObjDesc rebuild in its exact decompiled order - base body, hair style, clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the UI tab order or the wire's field order, both of which differ), eyes (bald-aware), nose, mouth, then the unconditional skin subpalette, hair color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's shade-to-index formula, cross-checked three ways (decomp control flow, ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's identical slider math). ChargenPalSet/ChargenClothingTable are pure projections behind IChargenPalSetSource/IChargenClothingTableSource so the factory itself never touches a dat. Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the cached dat-backed implementation of those two source interfaces, mirroring ChargenTableReader's no-leak discipline. App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and CreatureAppraisalViewportRenderer - no existing rendering file touched. ChargenPreviewCamera carries the four retail-verbatim per-heritage eye profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked against ZoomIn/ZoomOut's identical literals) plus the recovered rotation (3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the decompiler's garbled float literals - the plan's own "measure if it matters" note is resolved, not garbled beyond recovery). Rotation applies to the character model, not the camera, per gmCGAppearancePage::DoRotation. ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself (there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's surface-override algorithm and RetailPaperdollPoseApplicator's held-pose technique, generalized to chargen's per-heritage rest-pose DID. Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail- idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the un-ported clothing Setup-substitution fallback chain costs nothing for the 9 standard heritages with clothing UI, but Undead's default gear choices genuinely lack ClothingBaseEffects coverage for Undead's own body Setup). Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests (installed-DAT-gated, proves a real 34-part Aluvian mesh resolves). Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all pre-existing skips, zero failures, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3a6b7e3115
commit
55bfd9ca82
16 changed files with 2131 additions and 2 deletions
258
src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
Normal file
258
src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the static-pose chargen preview <see cref="WorldEntity"/> from a
|
||||
/// <see cref="ChargenAppearanceResult"/> — the App-layer counterpart to
|
||||
/// <see cref="DollEntityBuilder"/>, except this one resolves its OWN
|
||||
/// MeshRefs from a Setup + the composed ObjDesc rather than receiving
|
||||
/// already-resolved refs from a live entity (there is no live entity yet;
|
||||
/// character creation hasn't happened). DAT-touching, unlike
|
||||
/// <see cref="DollEntityBuilder"/>'s pure index-agnostic builder — the
|
||||
/// closest existing precedent for the actual mesh-flatten/apply-changes/
|
||||
/// resolve-surface-overrides steps is
|
||||
/// <c>DatLiveEntityProjectionMaterializer.TryMaterialize</c>, trimmed to
|
||||
/// what a private, non-animated, non-collision preview scene needs.
|
||||
/// </summary>
|
||||
internal static class ChargenPreviewEntityBuilder
|
||||
{
|
||||
/// <summary>Reserved synthetic guid for the chargen preview clone —
|
||||
/// same reserved family as <see cref="DollEntityBuilder.DollServerGuid"/>
|
||||
/// (0xDA11D0xx) and <c>CreatureAppraisalEntityBuilder</c> (0xDA11D02x).</summary>
|
||||
public const uint PreviewServerGuid = 0xDA11_D031u;
|
||||
|
||||
/// <summary>Reserved render-local entity id — passed in
|
||||
/// <c>animatedEntityIds</c> by the renderer so a re-dress (a new
|
||||
/// selection) bypasses <c>WbDrawDispatcher</c>'s Tier-1 classification
|
||||
/// cache, mirroring <see cref="DollEntityBuilder.DollRenderId"/>'s own
|
||||
/// doc comment.</summary>
|
||||
public const uint PreviewRenderId = 0xDA11_D032u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's held-pose animation DID enum key, resolved through master
|
||||
/// map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
|
||||
/// — 0x10000005 for every standard heritage (the SAME enum id the
|
||||
/// paperdoll's own held pose reads), matching
|
||||
/// <c>gmCG3DView</c>'s ctor / <c>::Update</c> per-heritage
|
||||
/// <c>m_didAnimationRest</c> assignment (pseudo-C ~0x004EE948,
|
||||
/// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest
|
||||
/// DID — the one divergence from the paperdoll, which never needs an
|
||||
/// Olthoi branch because a live player can't be one.
|
||||
/// </summary>
|
||||
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
|
||||
{
|
||||
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
|
||||
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
|
||||
_ => 0x10000005u,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the preview entity, or null when the resolved body Setup
|
||||
/// isn't in the dat source (a corrupted/incomplete install — the same
|
||||
/// failure shape <see cref="DatLiveEntityProjectionMaterializer"/> treats
|
||||
/// as "drop this spawn").
|
||||
/// </summary>
|
||||
public static WorldEntity? TryBuild(
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animations,
|
||||
ChargenAppearanceResult appearance,
|
||||
uint heritageId,
|
||||
Quaternion heading)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(animations);
|
||||
ArgumentNullException.ThrowIfNull(appearance);
|
||||
|
||||
Setup? setup = dats.Get<Setup>(appearance.SetupId);
|
||||
if (setup is null)
|
||||
return null;
|
||||
|
||||
var flattened = new List<MeshRef>(SetupMesh.Flatten(setup));
|
||||
|
||||
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
|
||||
{
|
||||
if (change.PartIndex < flattened.Count)
|
||||
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
|
||||
}
|
||||
|
||||
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
|
||||
|
||||
Dictionary<int, Dictionary<uint, uint>>? surfaceOverrides =
|
||||
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
|
||||
|
||||
var meshRefs = new List<MeshRef>(flattened.Count);
|
||||
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
|
||||
{
|
||||
MeshRef part = flattened[partIndex];
|
||||
if (dats.Get<GfxObj>(part.GfxObjId) is null)
|
||||
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
|
||||
|
||||
IReadOnlyDictionary<uint, uint>? overrides = null;
|
||||
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
|
||||
overrides = perPart;
|
||||
|
||||
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
|
||||
}
|
||||
if (meshRefs.Count == 0)
|
||||
return null;
|
||||
|
||||
PaletteOverride? paletteOverride = null;
|
||||
if (appearance.ObjDesc.SubPalettes.Count > 0)
|
||||
{
|
||||
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
|
||||
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
|
||||
{
|
||||
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
|
||||
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
|
||||
}
|
||||
paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges);
|
||||
}
|
||||
|
||||
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
|
||||
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
|
||||
{
|
||||
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
|
||||
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
|
||||
}
|
||||
|
||||
return new WorldEntity
|
||||
{
|
||||
Id = PreviewRenderId,
|
||||
ServerGuid = PreviewServerGuid,
|
||||
SourceGfxObjOrSetupId = appearance.SetupId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = heading,
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = paletteOverride,
|
||||
PartOverrides = partOverrides,
|
||||
ParentCellId = null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overwrites every part's transform from the resolved rest pose's
|
||||
/// FINAL frame — same "hold the settled last frame at zero frame rate"
|
||||
/// approach as <c>RetailPaperdollPoseApplicator.Apply</c>
|
||||
/// (<c>RedressCreature @ 0x004A3C22</c>), applied to the FULL
|
||||
/// setup-part-indexed array (before drawable filtering) so the index
|
||||
/// alignment holds even if a later part turns out to have a missing
|
||||
/// GfxObj. No-ops (keeps the default placement frame) when the pose
|
||||
/// DID or its animation can't be resolved.
|
||||
/// </summary>
|
||||
private static void ApplyHeldPose(
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animations,
|
||||
Setup setup,
|
||||
uint heritageId,
|
||||
List<MeshRef> flattened)
|
||||
{
|
||||
uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId));
|
||||
if ((poseDid >> 24) != 0x03u)
|
||||
return;
|
||||
|
||||
Animation? animation = animations.LoadAnimation(poseDid);
|
||||
if (animation is null || animation.PartFrames.Count == 0)
|
||||
return;
|
||||
|
||||
var frame = animation.PartFrames[^1];
|
||||
for (int index = 0; index < flattened.Count; index++)
|
||||
{
|
||||
Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One;
|
||||
Vector3 origin = Vector3.Zero;
|
||||
Quaternion orientation = Quaternion.Identity;
|
||||
if (index < frame.Frames.Count)
|
||||
{
|
||||
origin = frame.Frames[index].Origin;
|
||||
orientation = frame.Frames[index].Orientation;
|
||||
}
|
||||
|
||||
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin);
|
||||
flattened[index] = new MeshRef(flattened[index].GfxObjId, transform);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent — verbatim
|
||||
/// port of <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>,
|
||||
/// parameterized by the target enum key.
|
||||
/// </summary>
|
||||
private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
|
||||
{
|
||||
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|
||||
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
||||
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Part-index → (old texture id → new texture id) resolution, verbatim
|
||||
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
|
||||
/// algorithm against <see cref="ChargenTextureChange"/> instead of the
|
||||
/// wire's <c>CreateObject.TextureChange</c>.
|
||||
/// </summary>
|
||||
private static Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
|
||||
IDatReaderWriter dats,
|
||||
IReadOnlyList<MeshRef> parts,
|
||||
IReadOnlyList<ChargenTextureChange> textureChanges)
|
||||
{
|
||||
if (textureChanges.Count == 0)
|
||||
return null;
|
||||
|
||||
var oldToNewByPart = new Dictionary<int, Dictionary<uint, uint>>();
|
||||
foreach (ChargenTextureChange change in textureChanges)
|
||||
{
|
||||
if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew))
|
||||
{
|
||||
oldToNew = [];
|
||||
oldToNewByPart.Add(change.PartIndex, oldToNew);
|
||||
}
|
||||
oldToNew[change.OldTextureId] = change.NewTextureId;
|
||||
}
|
||||
|
||||
var result = new Dictionary<int, Dictionary<uint, uint>>();
|
||||
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
|
||||
{
|
||||
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
|
||||
continue;
|
||||
|
||||
GfxObj? gfx = dats.Get<GfxObj>(parts[partIndex].GfxObjId);
|
||||
if (gfx is null)
|
||||
continue;
|
||||
|
||||
Dictionary<uint, uint>? resolved = null;
|
||||
foreach (var surfaceQid in gfx.Surfaces)
|
||||
{
|
||||
uint surfaceId = (uint)surfaceQid;
|
||||
Surface? surface = dats.Get<Surface>(surfaceId);
|
||||
if (surface is null)
|
||||
continue;
|
||||
uint originalTexture = (uint)surface.OrigTextureId;
|
||||
if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture))
|
||||
continue;
|
||||
|
||||
(resolved ??= [])[surfaceId] = newTexture;
|
||||
}
|
||||
|
||||
if (resolved is not null)
|
||||
result[partIndex] = resolved;
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue