using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; namespace AcDream.Core.Meshing; /// /// Resolve a base GfxObj id to its retail "close-detail" mesh by walking /// the DIDDegrade table to Degrades[0]. /// /// /// Why this exists (Issue #47). Many AC GfxObjs — most notably /// humanoid body parts — store the LOW-detail mesh as the GfxObj that /// the Setup or AnimPartChange references. The high-detail mesh used /// for close/player rendering is reached indirectly: the base GfxObj's /// HasDIDDegrade flag is set, DIDDegrade points at a /// , and at /// Degrades[0] is the close-detail variant. Drawing the base /// GfxObj id directly produces the LOD-3 mesh — visibly bulky and /// detail-less — which is exactly what acdream and ACViewer were both /// rendering for humanoid body parts before this fix. /// /// /// /// Concrete example. The Aluvian Male upper-arm GfxObj /// 0x01000055 is a 14-vertex / 17-poly low-detail stub. Its /// degrade table 0x110006D0 points at 0x01001795, the /// 32-vertex / 60-poly close-detail mesh that carries the bicep / /// deltoid / shoulder geometry retail draws on the player. Same story /// for the lower arm 0x01000056 → 0x0100178F and matching /// heritage variants (0x010004BF → 0x010017A8, /// 0x010004BD → 0x010017A7, 0x010004B7 → 0x0100179A). /// /// /// /// Retail flow (named-retail decomp). /// /// /// acclient!CPhysicsPart::LoadGfxObjArray at 0x0050DCF0 /// loads the base GfxObj solely to discover DIDDegrade; if /// a exists, retail loads each entry /// in Degrades into the part's render array. /// /// /// acclient!CPhysicsPart::UpdateViewerDistance at /// 0x0050E030 picks deg_level per part by distance. /// For close / player rendering deg_level == 0. /// /// /// acclient!CPhysicsPart::Draw at 0x0050D7A0 /// draws gfxobj[deg_level]. /// /// /// /// /// /// We don't yet have distance-based LOD plumbing, so this resolver /// always returns slot 0 (the close-detail mesh). That's correct for /// player + nearby NPC rendering; far-distance LOD is a future concern. /// /// public static class GfxObjDegradeResolver { /// /// DatCollection-backed convenience overload. Production callers use /// this; tests use the callback overload below for easy fakes. /// public static bool TryResolveCloseGfxObj( DatCollection dats, uint gfxObjId, out uint resolvedId, out GfxObj? resolvedGfxObj) => TryResolveCloseGfxObj( id => dats.Get(id), id => dats.Get(id), gfxObjId, out resolvedId, out resolvedGfxObj); /// /// Loader-callback overload. Returns the close-detail GfxObj id and /// loaded object when a degrade table is present, otherwise the /// base id and base GfxObj. /// /// /// Lookup for a GfxObj by id. May return null when not found. /// /// /// Lookup for a GfxObjDegradeInfo by id. May return null. /// /// Base GfxObj id (post-AnimPartChange). /// /// The id to actually render. Same as /// when no degrade table exists; Degrades[0].Id when it does. /// /// /// The loaded GfxObj for , cached so /// callers don't have to re-read. /// /// /// true if a usable GfxObj was resolved (either base or /// degrade slot 0 loaded). false only when the base GfxObj /// itself was missing — caller should drop this part. /// public static bool TryResolveCloseGfxObj( Func getGfxObj, Func getDegradeInfo, uint gfxObjId, out uint resolvedId, out GfxObj? resolvedGfxObj) { var gfxObj = getGfxObj(gfxObjId); if (gfxObj is null) { resolvedId = gfxObjId; resolvedGfxObj = null; return false; } // Default: base mesh stays selected unless the degrade table // resolves cleanly. Every fallback below leaves these set. resolvedId = gfxObjId; resolvedGfxObj = gfxObj; if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfxObj.DIDDegrade == 0) return true; var degradeInfo = getDegradeInfo(gfxObj.DIDDegrade); if (degradeInfo is null || degradeInfo.Degrades.Count == 0) return true; uint closeId = (uint)degradeInfo.Degrades[0].Id; if (closeId == 0) return true; var closeGfxObj = getGfxObj(closeId); if (closeGfxObj is null) return true; resolvedId = closeId; resolvedGfxObj = closeGfxObj; return true; } /// /// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based /// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible /// ONLY at distance 0 (Degrades[0].MaxDist == 0) and the table degrades to GfxObj /// id 0 (= nothing) at real distance. Retail /// (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw 0x0050D7A0 picks /// gfxobj[deg_level] by viewer distance) therefore never draws it in the live /// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame /// distance-LOD (the resolver above always returns slot 0), so without this check it /// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39 /// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at /// distance > 0) skip such GfxObjs. /// public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) => IsRuntimeHiddenMarker( id => dats.Get(id), id => dats.Get(id), gfxObjId); /// Loader-callback overload of . public static bool IsRuntimeHiddenMarker( Func getGfxObj, Func getDegradeInfo, uint gfxObjId) { var gfxObj = getGfxObj(gfxObjId); if (gfxObj is null || !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfxObj.DIDDegrade == 0) return false; var info = getDegradeInfo(gfxObj.DIDDegrade); if (info is null || info.Degrades.Count == 0) return false; // Closest slot visible only at distance exactly 0 = editor-only placement marker. bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f; if (!firstSlotEditorOnly) return false; // ...and the table degrades to NOTHING (id 0) at real distance — confirms it // becomes invisible at runtime rather than LOD-swapping to a real mesh. foreach (var d in info.Degrades) if ((uint)d.Id == 0u) return true; return false; } }