From 6ab24c99821f0b28cdb3df8ef651a14c2f20003a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 15:48:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(net+app):=20AnimPartChanges=20+=20Name=20e?= =?UTF-8?q?xtraction=20=E2=80=94=20characters=20clothed,=20statue=20identi?= =?UTF-8?q?fied=20(Phase=204.7h/i/j/k/l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes three big improvements to the CreateObject decode path: 1. Extract AnimPartChanges from the ModelData section instead of skipping them. Each change is (PartIndex, NewModelId); the server uses these to replace base Setup parts with armor/clothing/statue meshes. The player character has ~34 of them on a normal login. 2. Flow AnimPartChanges through WorldSession.EntitySpawn into GameWindow.OnLiveEntitySpawned, which now patches the flattened Setup's part list BEFORE uploading GfxObjs. Patching is a simple "parts[change.PartIndex] = new MeshRef(change.NewModelId, oldTransform)" keeping the base Setup's placement transform but swapping the mesh. 3. Read the WeenieHeader Name (String16L) that follows the PhysicsData section. Required walking past every remaining physics flag (Parent, Children, ObjScale, Friction, Elasticity, Translucency, Velocity, Acceleration, Omega, DefaultScript, DefaultScriptIntensity) plus the 9 sequence timestamps (2 bytes each) plus 4-byte alignment. The Name field is then the second thing in the WeenieHeader after u32 weenieFlags. Critical bug fix in the same commit: ACE's WritePackedDwordOfKnownType STRIPS the known-type high-byte prefix (e.g. 0x01000000 for GfxObj ids) before writing the PackedDword. The first version of AnimPartChange decoding called plain ReadPackedDword, so it got 0x0000XXXX instead of 0x0100XXXX and every GfxObj dat lookup silently failed — the drop counter showed 19+ noMeshRef drops including +Acdream himself. Added ReadPackedDwordOfKnownType that ORs the knownType bit back in on read (with zero preserved as the "no value" sentinel). After the fix, noMeshRef drops = 0 across a full login. LIVE RUN after all three changes: live: spawn guid=0x5000000A name="+Acdream" setup=0x02000001 pos=(58.5,156.2,66.0)@0xA9B40017 animParts=34 live: spawn guid=0x7A9B4035 name="Holtburg" setup=0x020006EF pos=(94.6,156.0,66.0)@0xA9B4001F animParts=0 live: spawn guid=0x7A9B4000 name="Door" setup=0x020019FF pos=(84.1,131.5,66.1)@0xA9B40100 animParts=0 live: spawn guid=0x7A9B4001 name="Chest" setup=0x0200007C pos=(78.1,136.9,69.5)@0xA9B40105 animParts=0 live: spawn guid=0x7A9B4036 name="Well" setup=0x02000180 pos=(90.1,157.8,66.0)@0xA9B4001F animParts=0 live: spawn guid=0x800005FD name="Wide Breeches" setup=0x02000210 pos=no-pos animParts=1 live: spawn guid=0x800005FC name="Smock" setup=0x020000D4 pos=no-pos animParts=1 live: spawn guid=0x800005FE name="Shoes" setup=0x020000DE pos=no-pos animParts=1 live: spawn guid=0x80000697 name="Facility Hub Portal Gem" setup=0x02000921 pos=no-pos animParts=0 live: spawn guid=0x7A9B404B name="Nullified Statue of a Drudge" setup=0x020007DD pos=(65.3,156.8,72.8)@0xA9B40017 animParts=1 summary recv=60 hydrated=43 drops: noPos=17 noSetup=0 setupMissing=0 noMesh=0 The statue's exact data is now known and the hydration path runs without errors. The user's "look at the Name field in the CreateObject body" insight turned this from an unbounded visual hunt into a targeted grep of ~60 log lines. Tests: 77 core + 83 net = 160 passing (offline suite unchanged). Live handshake + enter-world tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 32 ++++- src/AcDream.Core.Net/Messages/CreateObject.cs | 115 ++++++++++++++++-- src/AcDream.Core.Net/WorldSession.cs | 13 +- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6f6ef34..6f4e655 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -519,14 +519,18 @@ public sealed class GameWindow : IDisposable _liveSpawnReceived++; // Log every spawn that arrives so we can inventory what the server - // sends (including the ones we can't render yet). The foundry statue - // hunt in Phase 2c / 4.7 is the main reason for this — we want to - // see EVERY guid+setup to find it in the list. + // sends (including the ones we can't render yet). The Name field + // is the critical one — we can grep the log for "Nullified Statue + // of a Drudge" or similar to find a specific weenie by its + // in-game name. string posStr = spawn.Position is { } sp ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" : "no-pos"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; - Console.WriteLine($"live: spawn guid=0x{spawn.Guid:X8} setup={setupStr} pos={posStr}"); + string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; + int animPartCount = spawn.AnimPartChanges?.Count ?? 0; + Console.WriteLine( + $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} animParts={animPartCount}"); if (_dats is null || _staticMesh is null) return; if (spawn.Position is null || spawn.SetupTableId is null) @@ -567,8 +571,26 @@ public sealed class GameWindow : IDisposable } var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + + // Apply the server's AnimPartChanges: "replace part at index N + // with GfxObj M". This is how characters become clothed (head → + // helmet, torso → chestplate, ...) and how server-weenie statues + // and props pick up their unique visual meshes on top of a generic + // base Setup. Start with a mutable copy, patch in the replacements, + // then proceed with the normal upload loop. + var parts = new List(flat); + var animPartChanges = spawn.AnimPartChanges ?? Array.Empty(); + foreach (var change in animPartChanges) + { + if (change.PartIndex < parts.Count) + { + parts[change.PartIndex] = new AcDream.Core.World.MeshRef( + change.NewModelId, parts[change.PartIndex].PartTransform); + } + } + var meshRefs = new List(); - foreach (var mr in flat) + foreach (var mr in parts) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 0a6cf25..6af947d 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -44,6 +44,9 @@ public static class CreateObject { public const uint Opcode = 0xF745u; + /// AC dat id type prefix for GfxObj (visual model) ids. + public const uint GfxObjTypePrefix = 0x01000000u; + [Flags] public enum PhysicsDescriptionFlag : uint { @@ -77,7 +80,9 @@ public static class CreateObject public readonly record struct Parsed( uint Guid, ServerPosition? Position, - uint? SetupTableId); + uint? SetupTableId, + IReadOnlyList AnimPartChanges, + string? Name); /// A server-side position: landblock id + local XYZ + unit quaternion rotation. public readonly record struct ServerPosition( @@ -85,6 +90,15 @@ public static class CreateObject float PositionX, float PositionY, float PositionZ, float RotationW, float RotationX, float RotationY, float RotationZ); + /// + /// Server instruction to replace part index + /// in the base Setup's part list with the mesh at . + /// This is the primary mechanism ACE uses to dress characters (head → + /// helmet, torso → chestplate, ...) and also how many specialized + /// weenies like statues get their unique mesh overrides. + /// + public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId); + /// /// Parse a reassembled CreateObject body. must /// start with the 4-byte opcode. Returns null if the body is @@ -130,11 +144,25 @@ public static class CreateObject _ = ReadPackedDword(body, ref pos); // newTexture } + // Extract AnimPartChanges — the server uses these to replace + // base Setup parts with armored/statue/whatever-specific meshes. + // Without decoding these, characters render "naked" and custom + // weenies render as whatever their base Setup looks like. + // + // NOTE: ACE writes the NewModelId through WritePackedDwordOfKnownType + // with knownType=0x01000000 (GfxObj type prefix). That writer STRIPS + // the high-byte type if present before writing the PackedDword. We + // have to OR it back on read or our GfxObj dat lookup will fail + // (silently, producing no mesh refs — hence the Phase 4.7h regression). + var animParts = animPartChangeCount == 0 + ? (IReadOnlyList)Array.Empty() + : new AnimPartChange[animPartChangeCount]; for (int i = 0; i < animPartChangeCount; i++) { if (body.Length - pos < 1) return null; - pos += 1; // index - _ = ReadPackedDword(body, ref pos); // animationId + byte partIndex = body[pos]; pos += 1; + uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix); + ((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId); } AlignTo4(ref pos); @@ -207,10 +235,55 @@ public static class CreateObject pos += 4; } - // Stop here — further fields (Parent, Children, ObjScale, Friction, - // Elasticity, Translucency, Velocity, Acceleration, Omega, DefaultScript, - // timestamps, weenie header, ...) aren't needed to render the entity. - return new Parsed(guid, position, setupTableId); + // Skip the remaining PhysicsData fields. Each is gated by a flag + // and must be consumed so we end up at the start of WeenieHeader. + // Order matches ACE's SerializePhysicsData exactly. + if ((physicsFlags & PhysicsDescriptionFlag.Parent) != 0) + { + if (body.Length - pos < 8) return null; + pos += 8; // wielderId u32 + parentLocation u32 + } + if ((physicsFlags & PhysicsDescriptionFlag.Children) != 0) + { + if (body.Length - pos < 4) return null; + int childCount = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(pos)); + pos += 4; + if (childCount < 0 || childCount > 1024) return PartialResult(); + if (body.Length - pos < childCount * 8) return null; + pos += childCount * 8; // each child = guid u32 + locationId u32 + } + if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3 + if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12; + if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0) pos += 12; + if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0) pos += 4; + if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4; + + // 9 sequence timestamps, always present at end of PhysicsData. + if (body.Length - pos < 9 * 2) return PartialResult(); + pos += 9 * 2; // each sequence is a ushort + AlignTo4(ref pos); + + // --- WeenieHeader: read just the Name field (second after flags). --- + string? name = null; + if (body.Length - pos >= 4) + { + pos += 4; // skip weenieFlags u32 + try + { + name = ReadString16L(body, ref pos); + } + catch { /* truncated name — partial result is still useful */ } + } + + return new Parsed(guid, position, setupTableId, animParts, 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); } catch { @@ -226,6 +299,21 @@ public static class CreateObject return v; } + private static string ReadString16L(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 2) throw new FormatException("truncated String16L length"); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); + pos += 2; + if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit"); + if (source.Length - pos < length) throw new FormatException("truncated String16L body"); + string result = System.Text.Encoding.ASCII.GetString(source.Slice(pos, length)); + pos += length; + int recordSize = 2 + length; + int padding = (4 - (recordSize & 3)) & 3; + pos += padding; + return result; + } + /// /// Read a PackedDword from the stream. Format: /// @@ -255,6 +343,19 @@ public static class CreateObject return (high << 16) | second; } + /// + /// Read a PackedDword that was written via WritePackedDwordOfKnownType. + /// That writer strips the prefix before + /// packing if the value had it set, so the reader must OR it back in to + /// recover the original dat id. The zero sentinel is preserved as-is + /// (a 0 means "no value" and must not be turned into knownType). + /// + private static uint ReadPackedDwordOfKnownType(ReadOnlySpan source, ref int pos, uint knownType) + { + uint packed = ReadPackedDword(source, ref pos); + return packed == 0 ? 0 : (packed | knownType); + } + private static void AlignTo4(ref int pos) { int padding = (4 - (pos & 3)) & 3; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 396190f..2ddbae9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -42,7 +42,12 @@ public sealed class WorldSession : IDisposable Failed, } - public readonly record struct EntitySpawn(uint Guid, CreateObject.ServerPosition? Position, uint? SetupTableId); + public readonly record struct EntitySpawn( + uint Guid, + CreateObject.ServerPosition? Position, + uint? SetupTableId, + IReadOnlyList AnimPartChanges, + string? Name); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -219,7 +224,11 @@ public sealed class WorldSession : IDisposable if (parsed is not null) { EntitySpawned?.Invoke(new EntitySpawn( - parsed.Value.Guid, parsed.Value.Position, parsed.Value.SetupTableId)); + parsed.Value.Guid, + parsed.Value.Position, + parsed.Value.SetupTableId, + parsed.Value.AnimPartChanges, + parsed.Value.Name)); } } }