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:
parent
4752b8a528
commit
a71db90310
12 changed files with 675 additions and 45 deletions
|
|
@ -258,7 +258,7 @@ public sealed class GameWindow : IDisposable
|
|||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.GfxObj>(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<uint, uint>? surfaceOverrides = null;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<WorldEntity> ?? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the GL texture id for a sub-mesh, honouring palette and
|
||||
/// texture overrides carried on the entity and the mesh-ref.
|
||||
/// </summary>
|
||||
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;
|
||||
/// <summary>
|
||||
/// Cached from GfxObjSubMesh.Translucency at upload time.
|
||||
/// Avoids any per-draw lookup into external state.
|
||||
/// </summary>
|
||||
public TranslucencyKind Translucency;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
src/AcDream.Core.Net/Messages/UpdateMotion.cs
Normal file
142
src/AcDream.Core.Net/Messages/UpdateMotion.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>UpdateMotion</c> GameMessage (opcode <c>0xF74C</c>). 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (see
|
||||
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs</c>
|
||||
/// and <c>references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write</c>
|
||||
/// with <c>header = true</c>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>u32 opcode</b> — 0xF74C</item>
|
||||
/// <item><b>u32 objectGuid</b> — which entity this update is for</item>
|
||||
/// <item><b>u16 instanceSequence</b> — Sequences.ObjectInstance, tracked but not used for pose</item>
|
||||
/// <item><b>MovementData with header</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item>u16 movementSequence</item>
|
||||
/// <item>u16 serverControlSequence</item>
|
||||
/// <item>u8 isAutonomous, then align to 4 bytes</item>
|
||||
/// <item>u8 movementType</item>
|
||||
/// <item>u8 motionFlags</item>
|
||||
/// <item>u16 currentStyle (MotionStance)</item>
|
||||
/// <item>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.</item>
|
||||
/// </list>
|
||||
/// </item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// We only extract the two fields the animation system actually consumes:
|
||||
/// the current <c>Stance</c> and the <c>ForwardCommand</c>. 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class UpdateMotion
|
||||
{
|
||||
public const uint Opcode = 0xF74Cu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ForwardCommand</c> flag may be
|
||||
/// unset in the InterpretedMotionState; the stance is always present
|
||||
/// (even if 0, meaning "no specific stance").
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint Guid,
|
||||
CreateObject.ServerMotionState MotionState);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
|
||||
/// start with the 4-byte opcode. Returns null on malformed input
|
||||
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,22 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="MotionUpdated"/>: 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.
|
||||
/// </summary>
|
||||
public readonly record struct EntityMotionUpdate(
|
||||
uint Guid,
|
||||
CreateObject.ServerMotionState MotionState);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<EntityMotionUpdate>? MotionUpdated;
|
||||
|
||||
/// <summary>Raised every time the state machine transitions.</summary>
|
||||
public event Action<State>? 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
75
src/AcDream.Core/Meshing/TranslucencyKind.cs
Normal file
75
src/AcDream.Core/Meshing/TranslucencyKind.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
134
tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
Normal file
134
tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="UpdateMotion.TryParse"/> — 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.
|
||||
/// </summary>
|
||||
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<byte>()));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
81
tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs
Normal file
81
tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.Core.Tests.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="TranslucencyKindExtensions.FromSurfaceType"/> maps
|
||||
/// SurfaceType flag combinations to the correct <see cref="TranslucencyKind"/>
|
||||
/// according to the documented priority order:
|
||||
/// Additive > InvAlpha > AlphaBlend (Alpha|Translucent) > ClipMap > Opaque
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue