diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6f4e655..4cc21dc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -529,8 +529,35 @@ public sealed class GameWindow : IDisposable string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; int animPartCount = spawn.AnimPartChanges?.Count ?? 0; + int texChangeCount = spawn.TextureChanges?.Count ?? 0; + int subPalCount = spawn.SubPalettes?.Count ?? 0; Console.WriteLine( - $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} animParts={animPartCount}"); + $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + + $"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); + + // Target the statue specifically for full diagnostic dump: Name match + // is cheap and gives us exactly one entity's worth of log regardless + // of arrival order. + bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); + if (isStatue && (texChangeCount > 0 || subPalCount > 0 || animPartCount > 0)) + { + if (spawn.TextureChanges is { } tcs) + { + foreach (var tc in tcs) + Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}"); + } + if (spawn.SubPalettes is { } sps) + { + Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}"); + foreach (var subPal in sps) + Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); + } + if (spawn.AnimPartChanges is { } apcs) + { + foreach (var apc in apcs) + Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}"); + } + } if (_dats is null || _staticMesh is null) return; if (spawn.Position is null || spawn.SetupTableId is null) @@ -589,14 +616,91 @@ public sealed class GameWindow : IDisposable } } - var meshRefs = new List(); - foreach (var mr in parts) + // Build per-part texture overrides. The server sends TextureChanges as + // (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids + // are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed + // by Surface (0x08) ids whose `OrigTextureId` field points to a + // SurfaceTexture. So we have to resolve each Surface → OrigTextureId, + // match that against the part's oldSurfaceTextureId set, and build + // a new dict keyed by Surface id → replacement OrigTextureId. The + // renderer then calls TextureCache.GetOrUploadWithOrigTextureOverride + // to get a texture decoded with the replacement SurfaceTexture + // substituted inside the Surface's decode chain. + var textureChanges = spawn.TextureChanges ?? Array.Empty(); + Dictionary>? resolvedOverridesByPart = null; + if (textureChanges.Count > 0) { + // First pass: group (oldOrigTex → newOrigTex) per part. + var perPartOldToNew = new Dictionary>(); + foreach (var tc in textureChanges) + { + if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict)) + { + dict = new Dictionary(); + perPartOldToNew[tc.PartIndex] = dict; + } + // Last write wins — matches observed duplicate semantics. + dict[tc.OldTexture] = tc.NewTexture; + } + + // Second pass: resolve each affected part's Surface chain and + // build the Surface-id-keyed override map the renderer consumes. + bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); + resolvedOverridesByPart = new Dictionary>(); + for (int pi = 0; pi < parts.Count; pi++) + { + if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue; + var partGfx = _dats.Get(parts[pi].GfxObjId); + if (partGfx is null) + { + if (isStatueDiag) + Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing"); + continue; + } + + if (isStatueDiag) + Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}"); + + Dictionary? resolved = null; + foreach (var surfQid in partGfx.Surfaces) + { + uint surfId = (uint)surfQid; + var surfDat = _dats.Get(surfId); + if (surfDat is null) continue; + uint origTexId = (uint)surfDat.OrigTextureId; + bool hit = origTexId != 0 && oldToNew.TryGetValue(origTexId, out uint newOrigTex) && (newOrigTex != 0 || true); + if (isStatueDiag) + Console.WriteLine($"live: [STATUE] surface=0x{surfId:X8} origTex=0x{origTexId:X8} " + (hit ? "[MATCH]" : "[miss]")); + if (origTexId == 0) continue; + if (oldToNew.TryGetValue(origTexId, out uint newId)) + { + resolved ??= new Dictionary(); + resolved[surfId] = newId; + } + } + + if (resolved is not null) + resolvedOverridesByPart[pi] = resolved; + } + } + + var meshRefs = new List(); + for (int partIdx = 0; partIdx < parts.Count; partIdx++) + { + var mr = parts[partIdx]; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); - meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform)); + + IReadOnlyDictionary? surfaceOverrides = null; + if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides)) + surfaceOverrides = partOverrides; + + meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform) + { + SurfaceOverrides = surfaceOverrides, + }); } if (meshRefs.Count == 0) { diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 424eb8b..79ccafc 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -99,7 +99,21 @@ public sealed unsafe class StaticMeshRenderer : IDisposable foreach (var sub in subMeshes) { - uint tex = _textures.GetOrUpload(sub.SurfaceId); + // Honor per-part surface overrides from CreateObject's + // TextureChanges. The map is Surface id (0x08) → replacement + // OrigTextureId (0x05 SurfaceTexture). A hit means "decode + // this Surface but swap its OrigTextureId for the override." + uint tex; + if (meshRef.SurfaceOverrides is not null + && meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out var overrideOrigTex)) + { + tex = _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex); + } + else + { + tex = _textures.GetOrUpload(sub.SurfaceId); + } + _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index dd95e63..f3d725a 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -12,6 +12,12 @@ public sealed unsafe class TextureCache : IDisposable private readonly GL _gl; private readonly DatCollection _dats; private readonly Dictionary _handlesBySurfaceId = new(); + /// + /// Composite cache for surface-with-override-origtex entries (Phase 5 + /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), + /// value = GL texture handle. + /// + private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new(); private uint _magentaHandle; public TextureCache(GL gl, DatCollection dats) @@ -30,27 +36,54 @@ public sealed unsafe class TextureCache : IDisposable if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h)) return h; - var decoded = DecodeFromDats(surfaceId); + var decoded = DecodeFromDats(surfaceId, origTextureOverride: null); h = UploadRgba8(decoded); _handlesBySurfaceId[surfaceId] = h; return h; } - private DecodedTexture DecodeFromDats(uint surfaceId) + /// + /// Get or upload a texture for a Surface id but with its + /// OrigTextureId replaced by . + /// The Surface's other properties (type flags, color, translucency, + /// clipmap handling, default palette) are preserved — only the + /// SurfaceTexture lookup is swapped. This is how the server's + /// CreateObject.TextureChanges are applied at render time. + /// Caches under a composite key so multiple entities can share. + /// + public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId) + { + var key = (surfaceId, overrideOrigTextureId); + if (_handlesByOverridden.TryGetValue(key, out var h)) + return h; + + var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId); + h = UploadRgba8(decoded); + _handlesByOverridden[key] = h; + return h; + } + + private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride) { var surface = _dats.Get(surfaceId); if (surface is null) return DecodedTexture.Magenta; // Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue - // instead of a texture chain. Without this, surfaces with no texture - // would fall through to the magenta fallback. Translucency is honored - // so Base1Solid|Translucent surfaces with Translucency=1.0 become - // alpha=0, which the mesh shader's discard cutout makes invisible. + // instead of a texture chain. Overrides are irrelevant here — there's + // no texture chain to swap — so the override is ignored for solid-color + // surfaces. Translucency is honored so Base1Solid|Translucent surfaces + // with Translucency=1.0 become alpha=0, which the mesh shader's discard + // cutout makes invisible. if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0) return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency); - var surfaceTexture = _dats.Get((uint)surface.OrigTextureId); + // Use the override SurfaceTexture id when present, otherwise the + // Surface's native OrigTextureId. This is the whole point of the + // override path — caller says "this Surface, but with a different + // SurfaceTexture underneath." + uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId; + var surfaceTexture = _dats.Get(surfaceTextureId); if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) return DecodedTexture.Magenta; @@ -99,6 +132,9 @@ public sealed unsafe class TextureCache : IDisposable foreach (var h in _handlesBySurfaceId.Values) _gl.DeleteTexture(h); _handlesBySurfaceId.Clear(); + foreach (var h in _handlesByOverridden.Values) + _gl.DeleteTexture(h); + _handlesByOverridden.Clear(); if (_magentaHandle != 0) { _gl.DeleteTexture(_magentaHandle); diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 6af947d..edafedd 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -46,6 +46,10 @@ public static class CreateObject /// AC dat id type prefix for GfxObj (visual model) ids. public const uint GfxObjTypePrefix = 0x01000000u; + /// Palette dat id type prefix. + public const uint PaletteTypePrefix = 0x04000000u; + /// SurfaceTexture dat id type prefix. + public const uint SurfaceTextureTypePrefix = 0x05000000u; [Flags] public enum PhysicsDescriptionFlag : uint @@ -82,8 +86,30 @@ public static class CreateObject ServerPosition? Position, uint? SetupTableId, IReadOnlyList AnimPartChanges, + IReadOnlyList TextureChanges, + IReadOnlyList SubPalettes, + uint? BasePaletteId, string? Name); + /// + /// Server instruction to replace the surface texture at + /// that currently uses + /// with . + /// Used to paint armor pieces the right color, make the statue + /// look stone instead of flesh, etc. + /// + public readonly record struct TextureChange(byte PartIndex, uint OldTexture, uint NewTexture); + + /// + /// Palette-range swap: overlay 's colors + /// into the entity's base palette starting at index + /// for colors. Used for skin/hair color + /// on characters and team-color variations. Both Offset and Length + /// are encoded as 8-bit values that the client historically multiplies + /// by 8 to get the final palette index. + /// + public readonly record struct SubPaletteSwap(uint SubPaletteId, byte Offset, byte Length); + /// A server-side position: landblock id + local XYZ + unit quaternion rotation. public readonly record struct ServerPosition( uint LandblockId, @@ -126,22 +152,32 @@ public static class CreateObject byte textureChangeCount = body[pos]; pos += 1; byte animPartChangeCount = body[pos]; pos += 1; + uint? basePaletteId = null; if (subPaletteCount > 0) - _ = ReadPackedDword(body, ref pos); // overall palette id + basePaletteId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix); + var subPalettes = subPaletteCount == 0 + ? (IReadOnlyList)Array.Empty() + : new SubPaletteSwap[subPaletteCount]; for (int i = 0; i < subPaletteCount; i++) { - _ = ReadPackedDword(body, ref pos); // subPaletteId + uint subPalId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix); if (body.Length - pos < 2) return null; - pos += 2; // offset + length bytes + byte offset = body[pos]; pos += 1; + byte length = body[pos]; pos += 1; + ((SubPaletteSwap[])subPalettes)[i] = new SubPaletteSwap(subPalId, offset, length); } + var textureChanges = textureChangeCount == 0 + ? (IReadOnlyList)Array.Empty() + : new TextureChange[textureChangeCount]; for (int i = 0; i < textureChangeCount; i++) { if (body.Length - pos < 1) return null; - pos += 1; // partIndex - _ = ReadPackedDword(body, ref pos); // oldTexture - _ = ReadPackedDword(body, ref pos); // newTexture + byte partIndex = body[pos]; pos += 1; + uint oldTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix); + uint newTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix); + ((TextureChange[])textureChanges)[i] = new TextureChange(partIndex, oldTex, newTex); } // Extract AnimPartChanges — the server uses these to replace @@ -279,11 +315,14 @@ public static class CreateObject catch { /* truncated name — partial result is still useful */ } } - return new Parsed(guid, position, setupTableId, animParts, name); + return new Parsed(guid, position, setupTableId, animParts, + textureChanges, subPalettes, basePaletteId, name); // Local helper: if we ran out of fields past PhysicsData, still - // return the useful prefix (guid/position/setup/animParts). - Parsed PartialResult() => new(guid, position, setupTableId, animParts, null); + // return the useful prefix (guid/position/setup/animParts/textures/palettes). + Parsed PartialResult() => new( + guid, position, setupTableId, animParts, + textureChanges, subPalettes, basePaletteId, null); } catch { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 2ddbae9..c85de0e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -47,6 +47,9 @@ public sealed class WorldSession : IDisposable CreateObject.ServerPosition? Position, uint? SetupTableId, IReadOnlyList AnimPartChanges, + IReadOnlyList TextureChanges, + IReadOnlyList SubPalettes, + uint? BasePaletteId, string? Name); /// Fires when the session finishes parsing a CreateObject. @@ -228,6 +231,9 @@ public sealed class WorldSession : IDisposable parsed.Value.Position, parsed.Value.SetupTableId, parsed.Value.AnimPartChanges, + parsed.Value.TextureChanges, + parsed.Value.SubPalettes, + parsed.Value.BasePaletteId, parsed.Value.Name)); } } diff --git a/src/AcDream.Core/World/MeshRef.cs b/src/AcDream.Core/World/MeshRef.cs index 09c3d26..841d812 100644 --- a/src/AcDream.Core/World/MeshRef.cs +++ b/src/AcDream.Core/World/MeshRef.cs @@ -2,4 +2,38 @@ using System.Numerics; namespace AcDream.Core.World; -public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform); +/// +/// One part of a mesh: a GfxObj id + its local transform in the owning +/// entity's frame. Optionally carries a set of server-specified surface +/// overrides () so the renderer can +/// substitute textures at draw time without having to change the base +/// Setup / GfxObj dat lookup — e.g. the Nullified Statue of +/// a Drudge remaps the drudge mesh's flesh textures to stone textures +/// and characters remap their armor piece textures to match the clothing +/// item they're wearing. +/// +public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform) +{ + /// + /// + /// Surface id (0x08XXXXXX)replacement OrigTextureId + /// (0x05XXXXXX SurfaceTexture). When the renderer is about to + /// bind a sub-mesh's texture, it consults this map. A hit means + /// "use the base Surface's colors/flags/palette but swap its + /// OrigTextureId for the value here." The renderer calls + /// TextureCache.GetOrUploadWithOrigTextureOverride which caches + /// the decoded result under a composite key so multiple entities can + /// share the same override without redecoding. + /// + /// + /// Null means "no overrides, use each sub-mesh's native surface as-is." + /// + /// + /// Why not key by SurfaceTexture id directly? Because sub-meshes + /// are keyed by Surface id (0x08) in the GfxObj; we have to resolve + /// each one to its SurfaceTexture id at hydration time so the render + /// hot path only does a single dict lookup. + /// + /// + public IReadOnlyDictionary? SurfaceOverrides { get; init; } +}