feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 20:33:26 +02:00
parent 4752b8a528
commit a71db90310
12 changed files with 675 additions and 45 deletions

View file

@ -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 <paramref name="envCell"/>.Surfaces
/// (OR'd with 0x08000000 to form the full dat id). Polygons are triangulated as fans.
/// </summary>
public static IReadOnlyList<GfxObjSubMesh> Build(EnvCell envCell, CellStruct cellStruct)
/// <param name="envCell">The EnvCell that owns the surface list.</param>
/// <param name="cellStruct">The CellStruct containing the polygon + vertex geometry.</param>
/// <param name="dats">
/// Optional dat collection used to read Surface.Type flags and set
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
/// </param>
public static IReadOnlyList<GfxObjSubMesh> Build(EnvCell envCell, CellStruct cellStruct, DatCollection? dats = null)
{
// Group output vertices and indices per surface dat id.
var perSurface = new Dictionary<uint, (List<Vertex> Vertices, List<uint> Indices, Dictionary<(int pos, int uv), uint> Dedupe)>();
@ -97,10 +105,23 @@ public static class CellMesh
var result = new List<GfxObjSubMesh>(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<Surface>(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;
}

View file

@ -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 <see cref="GfxObjSubMesh"/>
/// per referenced Surface. Polygons are triangulated as fans.
/// </summary>
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj)
/// <param name="gfxObj">The GfxObj to build sub-meshes from.</param>
/// <param name="dats">
/// Optional dat collection used to read Surface.Type flags and set
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
/// </param>
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj, DatCollection? dats = null)
{
// Group output vertices and indices per surface index.
var perSurface = new Dictionary<int, (List<Vertex> Vertices, List<uint> 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<Surface>(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;
}

View file

@ -9,4 +9,13 @@ namespace AcDream.Core.Meshing;
public sealed record GfxObjSubMesh(
uint SurfaceId,
Vertex[] Vertices,
uint[] Indices);
uint[] Indices)
{
/// <summary>
/// How this sub-mesh should be composited into the frame.
/// Populated from Surface.Type flags at upload time (requires a DatCollection).
/// Defaults to <see cref="TranslucencyKind.Opaque"/> so offline fixtures
/// that don't supply dat access compile and pass unchanged.
/// </summary>
public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque;
}

View file

@ -0,0 +1,75 @@
using DatReaderWriter.Enums;
namespace AcDream.Core.Meshing;
/// <summary>
/// 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.
/// </summary>
public enum TranslucencyKind
{
/// <summary>Standard opaque. Depth write + test, no blend.</summary>
Opaque = 0,
/// <summary>
/// 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.
/// </summary>
ClipMap = 1,
/// <summary>
/// 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.
/// </summary>
AlphaBlend = 2,
/// <summary>
/// Additive blend: src*a + dst. Depth-write off, depth-test on.
/// Used for portal swirls, magical glows, and particle effects.
/// </summary>
Additive = 3,
/// <summary>
/// Inverted alpha blend: src*(1-a) + dst*a. Rare but present in
/// the AC dat files.
/// </summary>
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.
/// <summary>
/// Maps a <see cref="SurfaceType"/> flags value to the correct
/// <see cref="TranslucencyKind"/> for the two-pass render split.
/// </summary>
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;
}
}