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)); } } }