Fix AE001H cyclic color blending

This commit is contained in:
gamer147
2026-07-11 22:21:10 -04:00
parent f195934324
commit b41ae36168
8 changed files with 140 additions and 36 deletions

View File

@@ -105,6 +105,25 @@ public class AnimChannelTests
Assert.Equal(GfxState.PackColor(0x80, 0xFF0000), o.ColorTarget);
}
[Fact]
public void Op0x232_NegativeRgbPreservesNativeDefaultStaticColor()
{
var t = T();
var scene = ScriptAssembler.Assemble(t, "AE001H_COLOR", new List<(int, Operand[])>
{
(0x55, new[]{G(1), I(0xcb8e)}), (0x55, new[]{G(2), I(1200)}),
(0x55, new[]{G(3), I(224)}), (0x55, new[]{G(4), I(1)}),
(0x51, new[]{G(4), I(0), G(4)}),
(0x232, new[]{G(1), G(2), G(3), G(4)}), Exit(),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
vm.Run();
var o = vm.Gfx.TryGet(0xcb8e)!;
Assert.Equal(0xffffffff, o.Color);
Assert.Equal(0xe0ffffff, o.ColorTarget);
Assert.Equal(0, o.StaticColorMode);
}
[Fact]
public void Op0x239_SetsSpritesheetGridCell()
{

View File

@@ -45,17 +45,43 @@ public class AnimInterpolatorTests
}
[Fact]
public void ColorAnim_PingPongsTintStrength_ObjectStaysOpaque()
public void ColorAnim_Mode0Textured_UsesRgbModulationAndIgnoresAlpha()
{
var g = VisibleObj(0x100);
// base = no tint (strength 0); target = full-strength (alpha 0xff) red glow => strength pulses 0<->255
g.SetColorAnim(0x100, period: 1000, target: GfxState.PackColor(0xFF, 0xFF0000));
g.SetColorAnim(0x100, period: 1000, target: GfxState.PackColor(0x80, 0xFF0000));
g.SnapshotVisibleObjects(0); // seeds start=0
var baseFrame = g.SnapshotVisibleObjects(0).Single();
Assert.Equal(255, baseFrame.Alpha); // object opacity ALWAYS opaque (never the color alpha)
Assert.Equal(0, baseFrame.TintStrength); // t=0 -> no tint
var peak = g.SnapshotVisibleObjects(500).Single();
Assert.Equal(255, peak.Alpha); // still opaque
Assert.Equal(255, peak.TintStrength); // t=1 -> full tint strength (the glow peak)
Assert.Equal((255, 0xffffffL, 0, true),
(baseFrame.Alpha, baseFrame.Tint, baseFrame.TintStrength, baseFrame.MultiplyTint));
Assert.Equal((255, 0xff0000L, 0, true),
(peak.Alpha, peak.Tint, peak.TintStrength, peak.MultiplyTint));
}
[Fact]
public void ColorAnim_Mode1_UsesSampledAlphaAsOpacity()
{
var g = VisibleObj(0x100);
g.SetStaticObjectColorResolved(0x100, mode: 1, alpha: 255, rgb: 0xffffff);
g.SetColorAnim(0x100, period: 1000, target: GfxState.PackColor(0x80, 0xffffff));
g.SnapshotVisibleObjects(0);
var peak = g.SnapshotVisibleObjects(500).Single();
Assert.Equal(0x80, peak.Alpha);
Assert.Equal(0, peak.TintStrength);
Assert.True(peak.MultiplyTint);
}
[Fact]
public void Ae001hMode0AlphaCycle_IsVisuallyInert()
{
var g = VisibleObj(0x100);
g.SetColorAnimResolved(0x100, period: 1200, alpha: 224, rgb: -1);
var start = g.SnapshotVisibleObjects(1000).Single();
var peak = g.SnapshotVisibleObjects(1600).Single();
Assert.Equal(0xe0ffffff, g.TryGet(0x100)!.ColorTarget);
Assert.Equal((255, 0xffffffL, 0, true),
(start.Alpha, start.Tint, start.TintStrength, start.MultiplyTint));
Assert.Equal((start.Alpha, start.Tint, start.TintStrength, start.MultiplyTint),
(peak.Alpha, peak.Tint, peak.TintStrength, peak.MultiplyTint));
}
}

View File

@@ -28,9 +28,9 @@ public readonly record struct ColorTransitionState(long Current, long Target,
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
/// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md,
/// "The full gfx render model").</summary>
/// <summary>The packed-color channel is mode-dependent. Static mode 0 uses <paramref name="TintStrength"/>
/// to blend <paramref name="Tint"/> into the texel. A mode-0 color which has passed through op 0x202, and
/// mode 1, use <paramref name="Alpha"/> as opacity and multiply the texel by <paramref name="Tint"/>.
/// <summary>The packed-color channel is mode-dependent. Textured mode 0 ignores packed alpha and
/// multiplicatively modulates by <paramref name="Tint"/>; surfaceless mode 0 uses <paramref name="TintStrength"/>.
/// A mode-0 color which has passed through op 0x202, and mode 1, use <paramref name="Alpha"/> as opacity.
/// <paramref name="MultiplyTint"/> selects the latter compositor path.</summary>
public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey,
int SrcX, int SrcY, int W, int H, int DstX, int DstY,
@@ -59,7 +59,7 @@ public sealed class GfxState
{
public (long X, long Y, long Z) V18, V24, V16c;
public long Field64, Field68, Field6c;
public long Color;
public long Color = 0xffffffff; // native gfx_object_init_default obj+0x60: identity packed ARGB
public bool HasColor; // true once op 0x202/0x203 set a color/alpha modulation on this object
public long StaticColorMode; // op 0x203 operand 2 -> obj+0x30; mode 2 is transition alpha/identity
// Op 0x202 one-shot packed-color channel: current +0x60, target +0x64, delay +0x38,
@@ -297,8 +297,8 @@ public sealed class GfxState
}
}
/// <summary>Op 0x232 (anim-color): ping-pong the object's color/alpha toward <paramref name="target"/>
/// (packed 0xAARRGGBB) over <paramref name="period"/> ms — the pulsing glow. Distinct from static 0x202/0x203.</summary>
/// <summary>Op 0x232 worker contract: ping-pong a temporary copy of static packed ARGB toward
/// <paramref name="target"/>. The sampled value is consumed by the object's unchanged blend mode.</summary>
public void SetColorAnim(long handle, long period, long target)
{
lock (_lock)
@@ -307,6 +307,23 @@ public sealed class GfxState
o.ColorPeriod = period; o.ColorTarget = target; o.ColorStart = -1; o.ColorAnim = true;
}
}
/// <summary>Op 0x232 handler contract: resolve negative alpha/RGB sentinels from static obj+0x60,
/// clamp alpha above 255, then arm the cyclic packed-color channel without changing blend mode.</summary>
public void SetColorAnimResolved(long handle, long period, long alpha, long rgb)
{
lock (_lock)
{
var o = GetOrCreate(handle);
long current = o.Color & 0xffffffff;
long resolvedAlpha = alpha < 0 ? (current >> 24) & 0xff : System.Math.Min(alpha, 0xff);
long resolvedRgb = rgb < 0 ? current & 0xffffff : rgb & 0xffffff;
o.ColorPeriod = period;
o.ColorTarget = PackColor(resolvedAlpha, resolvedRgb);
o.ColorStart = -1;
o.ColorAnim = true;
}
}
public void ClearSurface(int slot) { lock (_lock) { _surfaces[slot] = (0, 0); } } // create-texture (blank)
/// <summary>Op 0x223: queue a type-0 timed alpha transition into a target surface slot.</summary>
@@ -524,17 +541,23 @@ public sealed class GfxState
// ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has
// armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style
// multiplicative RGB modulation (including after target commit). Mode 1 uses the same blend.
// 0x232 remains the separate ping-pong mode-0 strength/tint channel. ----
// Op 0x232 modifies this temporary packed color before the unchanged mode consumes it. ----
int alpha = 255; long tint = 0xFFFFFF; int strength = 0; var blend = BlendKind.Opaque;
bool multiplyTint = false;
long sampledColor = o.Color;
long sampledColor = o.HasColor ? o.Color : 0xffffffff;
ColorTransitionState? colorTransition = null;
if (o.OneShotColorEnabled)
{
if (o.OneShotStartMs < 0) o.OneShotStartMs = nowMs;
(sampledColor, colorTransition) = SampleOneShotColor(o, nowMs);
}
if (o.HasColor)
if (o.ColorAnim)
{
if (o.ColorStart < 0) o.ColorStart = nowMs;
sampledColor = InterpolatePackedColor(sampledColor, o.ColorTarget,
PingPongWeight(nowMs, o.ColorStart, o.ColorPeriod));
}
if (o.HasColor || o.ColorAnim)
{
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
@@ -563,17 +586,6 @@ public sealed class GfxState
strength = a; blend = BlendKind.Alpha;
}
}
if (o.ColorAnim)
{
if (o.ColorStart < 0) o.ColorStart = nowMs;
double t = PingPongWeight(nowMs, o.ColorStart, o.ColorPeriod);
var (ta, tr, tg, tb) = BlendMath.UnpackArgb(o.ColorTarget);
var (br, bg, bb) = ((int)((tint >> 16) & 0xff), (int)((tint >> 8) & 0xff), (int)(tint & 0xff));
strength = (int)(strength + (ta - strength) * t);
tint = ((long)(br + (tr - br) * t) << 16) | ((long)(bg + (tg - bg) * t) << 8) | (long)(bb + (tb - bb) * t);
blend = BlendKind.Alpha;
}
// ---- src-rect: preserve cell dimensions and offset it row-major through the sheet ----
int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
if (o.SrcAnim && o.SrcFrameCount >= 1)
@@ -696,6 +708,19 @@ public sealed class GfxState
current.Angle + (target.Angle - current.Angle) * t);
}
private static long InterpolatePackedColor(long current, long target, double t)
{
t = System.Math.Clamp(t, 0.0, 1.0);
long packed = 0;
for (int shift = 0; shift <= 24; shift += 8)
{
long c = (current >> shift) & 0xff;
long v = (target >> shift) & 0xff;
packed |= ((long)(c + (v - c) * t) & 0xff) << shift;
}
return packed;
}
/// <summary>Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period.</summary>
private static double PingPongWeight(long now, long start, long period)
{

View File

@@ -397,8 +397,8 @@ public sealed class VirtualMachine
Gfx.SetSrcRect(Read(a[0]), Read(a[3]), Read(a[4]), Read(a[5]), 0); return pc + 1;
case "u00421EA0": // 0x231 looping spritesheet: (handle)(ms per frame)(frame count)(columns)
Gfx.SetSrcRect(Read(a[0]), Read(a[2]), Read(a[3]), 0, Read(a[1])); return pc + 1;
case "u00421EF0": // 0x232 anim color/glow: (handle)(period)(alpha)(color) — ping-pong the color
Gfx.SetColorAnim(Read(a[0]), Read(a[1]), GfxState.PackColor(Read(a[2]), Read(a[3]))); return pc + 1;
case "u00421EF0": // 0x232 cyclic packed ARGB; negative alpha/RGB preserve static obj color
Gfx.SetColorAnimResolved(Read(a[0]), Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- target translation matrix
{
if (Gfx.TryQueryTranslationTarget(Read(a[1]), out var v))