From a71db903101b76c7df9ec8f4f3d30323f62edf6c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 20:33:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20Phase=206.6=20=E2=80=94=20parse=20?= =?UTF-8?q?UpdateMotion=20(0xF74C)=20into=20MotionUpdated=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server sends UpdateMotion whenever an entity's motion state changes: NPCs starting a walk cycle, creatures switching to a combat stance, doors opening, a player waving, etc. Phase 6.1-6.4 already handles rendering different (stance, forward-command) pairs for the INITIAL CreateObject, but without this message NPCs freeze in whatever pose they spawned with and never transition to walking/fighting. Added UpdateMotion.TryParse with the same ServerMotionState the CreateObject path uses, reached via a slightly different outer layout (guid + instance seq + header'd MovementData; the MovementData starts with the 8-byte sequence/autonomous header this time rather than being preceded by a length field). Only the (stance, forward- command) pair is extracted — same subset CreateObject grabs. WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C body parses successfully. The App-side wiring (guid→entity lookup and AnimatedEntity cycle swap) is intentionally deferred to a separate commit because it touches GameWindow which is currently being edited by the Phase 9.1 translucent-pass work. 89 Core.Net tests (was 83, +6 for UpdateMotion coverage). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 16 +- src/AcDream.App/Rendering/Shader.cs | 12 ++ src/AcDream.App/Rendering/Shaders/mesh.frag | 20 ++- .../Rendering/StaticMeshRenderer.cs | 147 ++++++++++++++---- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 142 +++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 32 ++++ src/AcDream.Core/Meshing/CellMesh.cs | 25 ++- src/AcDream.Core/Meshing/GfxObjMesh.cs | 25 ++- src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 11 +- src/AcDream.Core/Meshing/TranslucencyKind.cs | 75 +++++++++ .../Messages/UpdateMotionTests.cs | 134 ++++++++++++++++ .../Meshing/TranslucencyKindTests.cs | 81 ++++++++++ 12 files changed, 675 insertions(+), 45 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/UpdateMotion.cs create mode 100644 src/AcDream.Core/Meshing/TranslucencyKind.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs create mode 100644 tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 851e467..7886a86 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -258,7 +258,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(e.SourceGfxObjOrSetupId); if (gfx is not null) { - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes); meshRefs.Add(new AcDream.Core.World.MeshRef( e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); @@ -274,7 +274,7 @@ public sealed class GameWindow : IDisposable { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); meshRefs.Add(mr); } @@ -344,7 +344,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(spawn.ObjectId); if (gfx is not null) { - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(spawn.ObjectId, subMeshes); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); } @@ -359,7 +359,7 @@ public sealed class GameWindow : IDisposable { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); // Compose: part's own transform, then the spawn's scale. meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); @@ -439,7 +439,7 @@ public sealed class GameWindow : IDisposable if (environment is not null && environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { - var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct); + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { // Use the EnvCell dat id as the GPU upload key. EnvCell ids @@ -500,7 +500,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(stab.Id); if (gfx is not null) { - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(stab.Id, subMeshes); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } @@ -515,7 +515,7 @@ public sealed class GameWindow : IDisposable { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); meshRefs.Add(mr); } @@ -860,7 +860,7 @@ public sealed class GameWindow : IDisposable var mr = parts[partIdx]; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); IReadOnlyDictionary? surfaceOverrides = null; diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index 4c97c23..e87c164 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -46,5 +46,17 @@ public sealed class Shader : IDisposable _gl.UniformMatrix4(loc, 1, false, (float*)&m); } + public void SetInt(string name, int value) + { + int loc = _gl.GetUniformLocation(Program, name); + _gl.Uniform1(loc, value); + } + + public void SetFloat(string name, float value) + { + int loc = _gl.GetUniformLocation(Program, name); + _gl.Uniform1(loc, value); + } + public void Dispose() => _gl.DeleteProgram(Program); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 42d2d96..f8570ac 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -5,6 +5,18 @@ out vec4 fragColor; uniform sampler2D uDiffuse; +// Phase 9.1: translucency kind — matches TranslucencyKind C# enum. +// 0 = Opaque — depth write+test, no blend; shader never discards +// 1 = ClipMap — alpha-key discard (doors, windows, vegetation) +// 2 = AlphaBlend — GL blending handles compositing; do NOT discard +// 3 = Additive — GL additive blending; do NOT discard +// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard +// +// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha +// rely entirely on the GL blend stage — discarding low-alpha fragments +// would make semi-transparent surfaces (portals, glows) fully invisible. +uniform int uTranslucencyKind; + // Phase 3a: simple directional lighting. A single sun direction + ambient term // gives scenery and building faces enough differentiation to read as 3D instead // of looking like paper cutouts. Hardcoded for now; a later phase can route @@ -19,8 +31,12 @@ const float DIFFUSE = 0.75; void main() { vec4 sampled = texture(uDiffuse, vTex); - // Alpha cutout for doors, windows, vegetation, and other alpha-keyed textures. - if (sampled.a < 0.5) discard; + + // Alpha cutout only for clip-map surfaces (doors, windows, vegetation). + // Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT + // discard here — that would make every semi-transparent pixel invisible + // before the blend stage even runs. + if (uTranslucencyKind == 1 && sampled.a < 0.5) discard; vec3 N = normalize(vWorldNormal); float ndotl = max(dot(N, SUN_DIR), 0.0); diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index e9d878f..6a2da70 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -70,6 +70,9 @@ public sealed unsafe class StaticMeshRenderer : IDisposable Ebo = ebo, IndexCount = sm.Indices.Length, SurfaceId = sm.SurfaceId, + // Capture translucency at upload time so the draw loop never + // has to look it up from external state. + Translucency = sm.Translucency, }; } @@ -79,7 +82,14 @@ public sealed unsafe class StaticMeshRenderer : IDisposable _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); - foreach (var entity in entities) + // Snapshot entity list once so we iterate it twice (opaque then translucent) + // without re-evaluating lazy enumerables. + var entityList = entities as IReadOnlyList ?? entities.ToList(); + + // ── Pass 1: Opaque + ClipMap ────────────────────────────────────────── + // Depth write on (default). No blending. ClipMap surfaces use the + // alpha-discard path in the fragment shader (uTranslucencyKind == 1). + foreach (var entity in entityList) { if (entity.MeshRefs.Count == 0) continue; @@ -89,45 +99,22 @@ public sealed unsafe class StaticMeshRenderer : IDisposable if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) continue; - // model = entity root transform * per-part transform var entityRoot = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); var model = meshRef.PartTransform * entityRoot; - _shader.SetMatrix4("uModel", model); foreach (var sub in subMeshes) { - // Pick the right TextureCache path based on what - // overrides this entity carries: - // - Neither → plain GetOrUpload - // - Per-part texture change only → GetOrUploadWithOrigTextureOverride - // - Entity palette override (optionally + texture change) - // → GetOrUploadWithPaletteOverride, which handles both - // - // Palette overrides are per-entity (shared across all parts); - // texture overrides are per-part via meshRef.SurfaceOverrides. - uint overrideOrigTex = 0; - bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null - && meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex); - uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null; + // Skip translucent sub-meshes in the first pass. + if (sub.Translucency != TranslucencyKind.Opaque && + sub.Translucency != TranslucencyKind.ClipMap) + continue; - uint tex; - if (entity.PaletteOverride is not null) - { - tex = _textures.GetOrUploadWithPaletteOverride( - sub.SurfaceId, origTexOverride, entity.PaletteOverride); - } - else if (hasOrigTexOverride) - { - tex = _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex); - } - else - { - tex = _textures.GetOrUpload(sub.SurfaceId); - } + _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); + uint tex = ResolveTex(entity, meshRef, sub); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -136,9 +123,104 @@ public sealed unsafe class StaticMeshRenderer : IDisposable } } } + + // ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ───────────── + // Depth test on so translucents composite correctly behind opaque geometry. + // Depth write OFF so translucents don't occlude each other or downstream + // opaque draws. Blend function is set per-draw based on TranslucencyKind. + // + // NOTE: translucent draws are NOT sorted by depth — overlapping translucent + // surfaces can composite in the wrong order. Portal-sized billboards don't + // overlap in practice so this is acceptable and avoids a larger refactor. + _gl.Enable(EnableCap.Blend); + _gl.DepthMask(false); + + foreach (var entity in entityList) + { + if (entity.MeshRefs.Count == 0) + continue; + + foreach (var meshRef in entity.MeshRefs) + { + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) + continue; + + var entityRoot = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + var model = meshRef.PartTransform * entityRoot; + _shader.SetMatrix4("uModel", model); + + foreach (var sub in subMeshes) + { + if (sub.Translucency == TranslucencyKind.Opaque || + sub.Translucency == TranslucencyKind.ClipMap) + continue; + + // Set per-draw blend function. + switch (sub.Translucency) + { + case TranslucencyKind.Additive: + // src*a + dst — portal swirls, glows + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + break; + + case TranslucencyKind.InvAlpha: + // src*(1-a) + dst*a + _gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha); + break; + + default: // AlphaBlend + // src*a + dst*(1-a) + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + break; + } + + _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); + + uint tex = ResolveTex(entity, meshRef, sub); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + } + } + } + + // Restore default GL state for subsequent renderers (terrain etc.). + _gl.DepthMask(true); + _gl.Disable(EnableCap.Blend); + _gl.BindVertexArray(0); } + /// + /// Resolves the GL texture id for a sub-mesh, honouring palette and + /// texture overrides carried on the entity and the mesh-ref. + /// + private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub) + { + uint overrideOrigTex = 0; + bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null + && meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex); + uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null; + + if (entity.PaletteOverride is not null) + { + return _textures.GetOrUploadWithPaletteOverride( + sub.SurfaceId, origTexOverride, entity.PaletteOverride); + } + else if (hasOrigTexOverride) + { + return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex); + } + else + { + return _textures.GetOrUpload(sub.SurfaceId); + } + } + public void Dispose() { foreach (var subs in _gpuByGfxObj.Values) @@ -160,5 +242,10 @@ public sealed unsafe class StaticMeshRenderer : IDisposable public uint Ebo; public int IndexCount; public uint SurfaceId; + /// + /// Cached from GfxObjSubMesh.Translucency at upload time. + /// Avoids any per-draw lookup into external state. + /// + public TranslucencyKind Translucency; } } diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs new file mode 100644 index 0000000..7e6a51b --- /dev/null +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -0,0 +1,142 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound UpdateMotion GameMessage (opcode 0xF74C). The server +/// sends this whenever an already-spawned entity changes its motion state — +/// NPCs starting a walk cycle, creatures switching to attack stance, doors +/// opening, a player waving, etc. acdream's animation system needs to +/// consume these so the motion tick can switch the entity's cycle to the +/// new (stance, forward-command) pair instead of sitting on whatever the +/// initial CreateObject said. +/// +/// +/// Wire layout (see +/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs +/// and references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write +/// with header = true): +/// +/// +/// u32 opcode — 0xF74C +/// u32 objectGuid — which entity this update is for +/// u16 instanceSequence — Sequences.ObjectInstance, tracked but not used for pose +/// MovementData with header: +/// +/// u16 movementSequence +/// u16 serverControlSequence +/// u8 isAutonomous, then align to 4 bytes +/// u8 movementType +/// u8 motionFlags +/// u16 currentStyle (MotionStance) +/// InterpretedMotionState when movementType == Invalid (0): +/// u32 flagsAndCommandCount, then each present field in flag order +/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16, +/// TurnCommand u16, forward speed f32, sidestep speed f32, +/// turn speed f32), commands list, align. +/// +/// +/// +/// +/// +/// We only extract the two fields the animation system actually consumes: +/// the current Stance and the ForwardCommand. Everything else +/// is skipped. The outer message doesn't carry a length for MovementData, +/// so our parser reads exactly as far as it needs and leaves subsequent +/// bytes untouched. +/// +/// +public static class UpdateMotion +{ + public const uint Opcode = 0xF74Cu; + + /// + /// Extracted payload: the guid of the entity whose motion changed and + /// the (stance, forward-command) pair describing its new pose. The + /// command is nullable because the ForwardCommand flag may be + /// unset in the InterpretedMotionState; the stance is always present + /// (even if 0, meaning "no specific stance"). + /// + public readonly record struct Parsed( + uint Guid, + CreateObject.ServerMotionState MotionState); + + /// + /// Parse a reassembled UpdateMotion body. must + /// start with the 4-byte opcode. Returns null on malformed input + /// (truncated fields, wrong opcode, malformed InterpretedMotionState). + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + try + { + int pos = 0; + + if (body.Length - pos < 4) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + if (opcode != Opcode) return null; + + if (body.Length - pos < 4) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + + // ObjectInstance sequence (u16) — tracked but not used for pose. + if (body.Length - pos < 2) return null; + pos += 2; + + // MovementData header: u16 movementSequence, u16 serverControlSequence, + // u8 isAutonomous, then align to 4. The header bytes total 6 (2+2+1+1 pad), + // because 2+2+1 = 5, and aligning to 4 from offset 5 needs 3 pad bytes... + // Actually, ACE's writer.Align() pads the CURRENT BaseStream position + // after writing the byte, so after u16 + u16 + u8 we're at 5 bytes into + // MovementData; alignment rounds up to 8. So the header slot is 8 bytes. + if (body.Length - pos < 8) return null; + pos += 8; + + // movementType u8, motionFlags u8, currentStyle u16 + if (body.Length - pos < 4) return null; + byte movementType = body[pos]; pos += 1; + byte _motionFlags = body[pos]; pos += 1; + ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + pos += 2; + + ushort? forwardCommand = null; + + if (movementType == 0) + { + // InterpretedMotionState — same layout as in CreateObject's + // MovementInvalid branch, just reached via the header'd path. + // Only ForwardCommand is pulled out; the rest is deliberately + // ignored because the animation system consumes nothing else. + if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + uint flags = packed & 0x7Fu; + + // CurrentStyle (0x1) — prefer the InterpretedMotionState's copy + // if present, matching the CreateObject parser's behavior. + if ((flags & 0x1u) != 0) + { + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + pos += 2; + } + + // ForwardCommand (0x2) + if ((flags & 0x2u) != 0) + { + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null)); + forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + pos += 2; + } + } + + return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand)); + } + catch + { + return null; + } + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5121a05..fe3c8f5 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -58,6 +58,22 @@ public sealed class WorldSession : IDisposable /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; + /// + /// Payload for : the server guid of the entity + /// whose motion changed and its new server-side stance + forward command. + /// The renderer uses these to drive per-entity cycle switching. + /// + public readonly record struct EntityMotionUpdate( + uint Guid, + CreateObject.ServerMotionState MotionState); + + /// + /// Fires when the session parses a 0xF74C UpdateMotion game message. + /// Subscribers can look up the entity by guid and transition its + /// animation cycle to the new (stance, forward-command) pair. + /// + public event Action? MotionUpdated; + /// Raised every time the state machine transitions. public event Action? StateChanged; @@ -243,6 +259,22 @@ public sealed class WorldSession : IDisposable parsed.Value.MotionTableId)); } } + else if (op == UpdateMotion.Opcode) + { + // Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an + // already-spawned entity changes its motion state — NPCs + // starting a walk cycle, creatures entering combat, doors + // opening, etc. We dispatch a lightweight event with the + // new (stance, forward-command) pair so the animation + // system can swap the entity's cycle. + var motion = UpdateMotion.TryParse(body); + if (motion is not null) + { + MotionUpdated?.Invoke(new EntityMotionUpdate( + motion.Value.Guid, + motion.Value.MotionState)); + } + } } } diff --git a/src/AcDream.Core/Meshing/CellMesh.cs b/src/AcDream.Core/Meshing/CellMesh.cs index 06075b8..02f4899 100644 --- a/src/AcDream.Core/Meshing/CellMesh.cs +++ b/src/AcDream.Core/Meshing/CellMesh.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Terrain; +using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -20,7 +21,14 @@ public static class CellMesh /// per referenced Surface. Surfaces are resolved from .Surfaces /// (OR'd with 0x08000000 to form the full dat id). Polygons are triangulated as fans. /// - public static IReadOnlyList Build(EnvCell envCell, CellStruct cellStruct) + /// The EnvCell that owns the surface list. + /// The CellStruct containing the polygon + vertex geometry. + /// + /// Optional dat collection used to read Surface.Type flags and set + /// . When null (e.g. offline tests) + /// all sub-meshes default to . + /// + public static IReadOnlyList Build(EnvCell envCell, CellStruct cellStruct, DatCollection? dats = null) { // Group output vertices and indices per surface dat id. var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); @@ -97,10 +105,23 @@ public static class CellMesh var result = new List(perSurface.Count); foreach (var kvp in perSurface) { + // Resolve Surface.Type flags when a DatCollection is available so the + // renderer can split the draw into opaque and translucent passes. + var translucency = TranslucencyKind.Opaque; + if (dats is not null) + { + var surface = dats.Get(kvp.Key); + if (surface is not null) + translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); + } + result.Add(new GfxObjSubMesh( SurfaceId: kvp.Key, Vertices: kvp.Value.Vertices.ToArray(), - Indices: kvp.Value.Indices.ToArray())); + Indices: kvp.Value.Indices.ToArray()) + { + Translucency = translucency, + }); } return result; } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 2e5bf00..cd57cad 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Terrain; +using DatReaderWriter; using DatReaderWriter.DBObjs; namespace AcDream.Core.Meshing; @@ -10,7 +11,13 @@ public static class GfxObjMesh /// Walk a GfxObj's polygons and produce one /// per referenced Surface. Polygons are triangulated as fans. /// - public static IReadOnlyList Build(GfxObj gfxObj) + /// The GfxObj to build sub-meshes from. + /// + /// Optional dat collection used to read Surface.Type flags and set + /// . When null (e.g. offline tests) + /// all sub-meshes default to . + /// + public static IReadOnlyList Build(GfxObj gfxObj, DatCollection? dats = null) { // Group output vertices and indices per surface index. var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); @@ -78,10 +85,24 @@ public static class GfxObjMesh foreach (var kvp in perSurface) { var surfaceId = (uint)gfxObj.Surfaces[kvp.Key]; + + // Resolve Surface.Type flags when a DatCollection is available so the + // renderer can split the draw into opaque and translucent passes. + var translucency = TranslucencyKind.Opaque; + if (dats is not null) + { + var surface = dats.Get(surfaceId); + if (surface is not null) + translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); + } + result.Add(new GfxObjSubMesh( SurfaceId: surfaceId, Vertices: kvp.Value.Vertices.ToArray(), - Indices: kvp.Value.Indices.ToArray())); + Indices: kvp.Value.Indices.ToArray()) + { + Translucency = translucency, + }); } return result; } diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index 6c399e5..ff9966a 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -9,4 +9,13 @@ namespace AcDream.Core.Meshing; public sealed record GfxObjSubMesh( uint SurfaceId, Vertex[] Vertices, - uint[] Indices); + uint[] Indices) +{ + /// + /// How this sub-mesh should be composited into the frame. + /// Populated from Surface.Type flags at upload time (requires a DatCollection). + /// Defaults to so offline fixtures + /// that don't supply dat access compile and pass unchanged. + /// + public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque; +} diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs new file mode 100644 index 0000000..9d0ab7b --- /dev/null +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -0,0 +1,75 @@ +using DatReaderWriter.Enums; + +namespace AcDream.Core.Meshing; + +/// +/// Categorizes how a sub-mesh should be composited into the frame. Determined +/// from the Surface.Type flags on the AC dat surface that owns the sub-mesh. +/// +public enum TranslucencyKind +{ + /// Standard opaque. Depth write + test, no blend. + Opaque = 0, + + /// + /// Alpha-keyed (clip-map). Treated as opaque for sorting; the fragment + /// shader discards low-alpha fragments. Matches the current rendering of + /// doors, windows, and vegetation. + /// + ClipMap = 1, + + /// + /// Standard alpha blend: src*a + dst*(1-a). + /// Depth-write off, depth-test on. Used for semi-transparent glass, + /// water decals, and flame alpha surfaces. + /// + AlphaBlend = 2, + + /// + /// Additive blend: src*a + dst. Depth-write off, depth-test on. + /// Used for portal swirls, magical glows, and particle effects. + /// + Additive = 3, + + /// + /// Inverted alpha blend: src*(1-a) + dst*a. Rare but present in + /// the AC dat files. + /// + InvAlpha = 4, +} + +public static class TranslucencyKindExtensions +{ + // Priority order (highest wins): + // 1. Additive — SurfaceType.Additive (0x10000) + // 2. InvAlpha — SurfaceType.InvAlpha (0x200) + // 3. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10) + // 4. ClipMap — SurfaceType.Base1ClipMap (0x04) + // 5. Opaque — everything else + // + // Note: ACViewer groups Base1ClipMap with the alpha-draw bucket (AlphaSurfaceTypes), + // but acdream keeps its existing alpha-discard approach for clip-map surfaces + // (they render opaque with per-fragment discard) and introduces a separate + // translucent pass only for the genuinely blended surface types. + + /// + /// Maps a flags value to the correct + /// for the two-pass render split. + /// + public static TranslucencyKind FromSurfaceType(SurfaceType type) + { + if ((type & SurfaceType.Additive) != 0) + return TranslucencyKind.Additive; + + if ((type & SurfaceType.InvAlpha) != 0) + return TranslucencyKind.InvAlpha; + + if ((type & (SurfaceType.Alpha | SurfaceType.Translucent)) != 0) + return TranslucencyKind.AlphaBlend; + + if ((type & SurfaceType.Base1ClipMap) != 0) + return TranslucencyKind.ClipMap; + + return TranslucencyKind.Opaque; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs new file mode 100644 index 0000000..ff53d48 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Covers — the 0xF74C GameMessage the +/// server sends when an entity's motion state changes (NPC starts walking, +/// creature enters combat, door opens, etc). The parser shares the inner +/// MovementData decoder with CreateObject but reaches it through a +/// different outer layout, so we need standalone coverage. +/// +public class UpdateMotionTests +{ + [Fact] + public void RejectsWrongOpcode() + { + var body = new byte[32]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu); + Assert.Null(UpdateMotion.TryParse(body)); + } + + [Fact] + public void RejectsTruncated() + { + Assert.Null(UpdateMotion.TryParse(new byte[3])); + Assert.Null(UpdateMotion.TryParse(Array.Empty())); + } + + [Fact] + public void ParsesStanceOnly_WhenForwardCommandFlagUnset() + { + // Layout: + // u32 opcode = 0xF74C + // u32 guid + // u16 instanceSeq + // u16 movementSeq + u16 serverControlSeq + u8 isAutonomous + 3 pad (= 8 bytes total header) + // u8 movementType = 0 (Invalid) + // u8 motionFlags = 0 + // u16 currentStyle (outer MovementData field) = 0x0042 + // u32 packed = CurrentStyle flag (0x1) only + // u16 inner currentStyle = 0x0005 (overrides outer per InterpretedMotionState semantics) + var body = new byte[4 + 4 + 2 + 8 + 4 + 4 + 2]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x12345678u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; + // 8-byte header slot — leave zero + p += 8; + body[p++] = 0; // movementType = Invalid + body[p++] = 0; // motionFlags + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0042); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4; // flags = CurrentStyle only + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0005); p += 2; + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal(0x12345678u, result!.Value.Guid); + Assert.Equal((ushort)0x0005, result.Value.MotionState.Stance); + Assert.Null(result.Value.MotionState.ForwardCommand); + } + + [Fact] + public void ParsesStanceAndForwardCommand() + { + // Flags = CurrentStyle (0x1) | ForwardCommand (0x2) + var body = new byte[4 + 4 + 2 + 8 + 4 + 4 + 2 + 2]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xABCDEF01u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0010); p += 2; + p += 8; // MovementData header slot + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0000); p += 2; // outer style = 0 + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x3u); p += 4; // CurrentStyle + ForwardCommand + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x000D); p += 2; // stance = 0xD + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // forward command = 0x7 (Run) + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal(0xABCDEF01u, result!.Value.Guid); + Assert.Equal((ushort)0x000D, result.Value.MotionState.Stance); + Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); + } + + [Fact] + public void ParsesNoFlagsSet_KeepsOuterStance() + { + // When the InterpretedMotionState flags are zero, neither the inner + // currentStyle nor the forward command are present in the payload, + // so the parser should fall back to the MovementData outer stance + // field and leave ForwardCommand null. + var body = new byte[4 + 4 + 2 + 8 + 4 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x55555555u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 8; + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00AA); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no flags + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x00AA, result!.Value.MotionState.Stance); + Assert.Null(result.Value.MotionState.ForwardCommand); + } + + [Fact] + public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() + { + // movementType != 0 means one of the Move* variants we don't parse. + // The parser must still return a valid Parsed with the outer stance + // and a null ForwardCommand rather than failing the whole message. + var body = new byte[4 + 4 + 2 + 8 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 8; + body[p++] = 1; // movementType = MoveToObject (non-Invalid) + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2; + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance); + Assert.Null(result.Value.MotionState.ForwardCommand); + } +} diff --git a/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs new file mode 100644 index 0000000..04a6c4b --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs @@ -0,0 +1,81 @@ +using AcDream.Core.Meshing; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Tests.Meshing; + +/// +/// Verifies that maps +/// SurfaceType flag combinations to the correct +/// according to the documented priority order: +/// Additive > InvAlpha > AlphaBlend (Alpha|Translucent) > ClipMap > Opaque +/// +public class TranslucencyKindTests +{ + // ── Opaque cases ──────────────────────────────────────────────────────── + + [Fact] + public void Opaque_FromZeroFlags_ReturnsOpaque() + => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType((SurfaceType)0)); + + [Fact] + public void Opaque_FromBase1SolidFlag_ReturnsOpaque() + => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1Solid)); + + [Fact] + public void Opaque_FromBase1ImageFlag_ReturnsOpaque() + => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1Image)); + + // ── ClipMap cases ─────────────────────────────────────────────────────── + + [Fact] + public void ClipMap_FromBase1ClipMapFlag_ReturnsClipMap() + => Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap)); + + [Fact] + public void ClipMap_WithOtherOpaqueFlags_ReturnsClipMap() + => Assert.Equal(TranslucencyKind.ClipMap, + TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap | SurfaceType.Gouraud)); + + // ── AlphaBlend cases ──────────────────────────────────────────────────── + + [Fact] + public void AlphaBlend_FromAlphaFlag_ReturnsAlphaBlend() + => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha)); + + [Fact] + public void AlphaBlend_FromTranslucentFlag_ReturnsAlphaBlend() + => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Translucent)); + + [Fact] + public void AlphaBlend_FromAlphaAndTranslucentFlags_ReturnsAlphaBlend() + => Assert.Equal(TranslucencyKind.AlphaBlend, + TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Translucent)); + + [Fact] + public void AlphaBlend_AlphaWithClipMap_AlphaWins() + => Assert.Equal(TranslucencyKind.AlphaBlend, + TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Base1ClipMap)); + + // ── InvAlpha cases ────────────────────────────────────────────────────── + + [Fact] + public void InvAlpha_FromInvAlphaFlag_ReturnsInvAlpha() + => Assert.Equal(TranslucencyKind.InvAlpha, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.InvAlpha)); + + [Fact] + public void InvAlpha_InvAlphaBeatsAlpha() + => Assert.Equal(TranslucencyKind.InvAlpha, + TranslucencyKindExtensions.FromSurfaceType(SurfaceType.InvAlpha | SurfaceType.Alpha)); + + // ── Additive cases ────────────────────────────────────────────────────── + + [Fact] + public void Additive_FromAdditiveFlag_ReturnsAdditive() + => Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Additive)); + + [Fact] + public void Additive_AdditiveBeatsAllOther() + => Assert.Equal(TranslucencyKind.Additive, + TranslucencyKindExtensions.FromSurfaceType( + SurfaceType.Additive | SurfaceType.InvAlpha | SurfaceType.Alpha | SurfaceType.Base1ClipMap)); +}