From b69d776179d636a8ac193960089a10eb9bd30e5b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 16:22:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(net+app):=20TextureChanges=20applied=20via?= =?UTF-8?q?=20Surface=E2=86=92OrigTex=20resolution=20(Phase=205a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the TextureChange half of ObjDesc. Characters' clothing now renders with correct per-part textures (user-verified "looks good" after previous "partial coverage" / "wrong clothes"). The Nullified Statue still looks like a flesh-colored drudge because the statue's color comes from SubPalettes (palette-indexed texture recoloring), which is the remaining major Phase 5 piece. The first attempt at TextureChange application was silently broken by an ID-type mismatch: the server encodes OldTexture/NewTexture as SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by Surface (0x08XXXXXX) ids. The override dict was keyed by one type and looked up by the other, so TryGetValue never hit and no override actually applied. Diagnosed via Phase 1 systematic debugging with resolve-level logging: live: spawn +Acdream texChanges=20 live: texChange part=0 old=0x05000BB0 new=0x0500025D ... live: resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH] live: resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH] ... 10/10 lines [MATCH] The [MATCH] lines proved the server's OldTexture IS reachable via a Surface→OrigTextureId lookup, just needed keying by the right value. Fix: - TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride): loads the base Surface dat for its color/flags/palette, but substitutes the override SurfaceTexture id in the decode chain. Caches under a (surfaceId, origTexOverride) composite key. - MeshRef.SurfaceOverrides is now Dictionary keyed by Surface id, value = replacement OrigTextureId. Null means no overrides. - GameWindow.OnLiveEntitySpawned now does TWO passes when texture changes are present: 1. Group the raw server changes by PartIndex into (oldOrigTex → newOrigTex) dicts 2. For each affected part's post-animPartChange GfxObj, iterate its Surfaces list, resolve each Surface → OrigTextureId, and if that matches a raw change's oldOrigTex, write an entry Surface id → newOrigTex into the final override map - StaticMeshRenderer.Draw: when sub-mesh surface id has an override, call GetOrUploadWithOrigTextureOverride instead of GetOrUpload. Verified live: +Acdream's clothing renders correctly, NPCs are "much better" (characters previously naked are now dressed). Statue has the full mechanical pipeline working (resolve diagnostic shows 2/2 Surfaces [MATCH] for the statue's override dict) but its visible color comes from the separate SubPalette overlay that isn't wired yet. Also added a statue-targeted diagnostic block that dumps its full ObjDesc contents (texChanges + subPalettes + animPartChanges) by name match, which is how I traced the Nullified Statue of a Drudge's specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins aren't spammed. Cross-referenced against two new references this session: * references/Chorizite.ACProtocol (cloned from github.com/Chorizite/ Chorizite.ACProtocol.git on user's suggestion) — confirms the ObjDesc field order and PackedDword-of-known-type convention. * references/WorldBuilder/... (already in repo) — confirms the Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the P8/INDEX16 palette decode path. Tests: 77 core + 83 net = 160, all green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 112 +++++++++++++++++- .../Rendering/StaticMeshRenderer.cs | 16 ++- src/AcDream.App/Rendering/TextureCache.cs | 50 ++++++-- src/AcDream.Core.Net/Messages/CreateObject.cs | 57 +++++++-- src/AcDream.Core.Net/WorldSession.cs | 6 + src/AcDream.Core/World/MeshRef.cs | 36 +++++- 6 files changed, 255 insertions(+), 22 deletions(-) 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; } +}