Implement native 0x202 color interpolation

This commit is contained in:
gamer147
2026-07-10 20:49:03 -04:00
parent 7f900c8fc6
commit 992215cc48
13 changed files with 300 additions and 53 deletions

View File

@@ -73,7 +73,9 @@ public class GfxCommandBufferTests
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
vm.Run();
Assert.Equal(0x80_112233L, vm.Gfx.TryGet(0x1000)!.Color);
var o = vm.Gfx.TryGet(0x1000)!;
Assert.Equal(0x80_112233L, o.OneShotColorTarget);
Assert.True(o.OneShotColorEnabled);
}
[Fact]

View File

@@ -0,0 +1,75 @@
using Age.Engine.Model;
namespace Age.Engine.Tests;
public class OneShotColorTests
{
private static GfxState Visible(long current)
{
var gfx = new GfxState();
gfx.SetSurface(1, 0x23, -1);
gfx.BindDraw(0x100, 1, 0, 0, 100, 100, 0, 0);
gfx.SetObjectColor(0x100, current);
return gfx;
}
[Fact]
public void DelayedColor_UsesCurrentTargetAndCommitsWithNativeIntegerLerp()
{
var gfx = Visible(GfxState.PackColor(0x00, 0x102030));
gfx.SetAnimatedObjectColorResolved(0x100, 100, 400, 0xff, 0x90a0b0);
var start = gfx.SnapshotVisibleObjects(1000).Single();
Assert.Equal(0, start.TintStrength);
Assert.Equal(0.0, start.ColorTransition!.Value.Progress);
Assert.Equal(0, gfx.SnapshotVisibleObjects(1100).Single().TintStrength);
var half = gfx.SnapshotVisibleObjects(1300).Single();
Assert.Equal(0x7f, half.TintStrength);
Assert.Equal(0x506070, half.Tint);
Assert.Equal(0.5, half.ColorTransition!.Value.Progress, 3);
var end = gfx.SnapshotVisibleObjects(1500).Single();
Assert.Equal(0xff, end.TintStrength);
Assert.Equal(0x90a0b0, end.Tint);
Assert.False(gfx.TryGet(0x100)!.OneShotColorEnabled);
Assert.Equal(-1, gfx.TryGet(0x100)!.OneShotColorTarget);
}
[Fact]
public void StaticWriteAfterOp202_BecomesInterpolationCurrent_NotAnEndpointOverwrite()
{
var gfx = Visible(GfxState.PackColor(0xff, 0x000000));
gfx.SetAnimatedObjectColorResolved(0x100, 0, 300, 0x00, 0xffffff);
gfx.SetStaticObjectColorResolved(0x100, 0, 0xff, 0xffffff);
var start = gfx.SnapshotVisibleObjects(2000).Single();
Assert.Equal(0xff, start.TintStrength);
Assert.Equal(0xffffff, start.Tint);
var half = gfx.SnapshotVisibleObjects(2150).Single();
Assert.Equal(0x7f, half.TintStrength);
Assert.Equal(0xffffff, half.Tint);
}
[Fact]
public void ColorAndScale_ShareOneShotStartClock()
{
var gfx = Visible(GfxState.PackColor(0x00, 0xffffff));
gfx.SetAnimatedObjectColorResolved(0x100, 0, 1000, 0xff, 0xffffff);
gfx.SetScaleChannel(0x100, 0, 1000, (200, 200, 100));
gfx.SnapshotVisibleObjects(5000);
var half = gfx.SnapshotVisibleObjects(5500).Single();
Assert.Equal(0x7f, half.TintStrength);
Assert.Equal(1.5, half.Transform.ScaleX, 3);
Assert.Equal(5000, half.ColorTransition!.Value.StartMs);
}
[Fact]
public void NegativeTargetComponents_PreserveCurrentStaticBytes()
{
var gfx = Visible(GfxState.PackColor(0x33, 0x123456));
gfx.SetAnimatedObjectColorResolved(0x100, 0, 100, -1, -1);
Assert.Equal(GfxState.PackColor(0x33, 0x123456), gfx.TryGet(0x100)!.OneShotColorTarget);
}
}

View File

@@ -64,6 +64,19 @@ public class RenderObjectBlendTests
Assert.Equal(BlendKind.Alpha, ro2.Blend);
}
[Fact]
public void Mode1_UsesArgbAlphaAsOpacityAndRgbAsMultiplicativeModulation()
{
var g = WithVisibleObject(0x100, resId: 5, colorKey: -1);
g.SetStaticObjectColorResolved(0x100, 1, 0x40, 0x80ff40);
var ro = g.SnapshotVisibleObjects().Single();
Assert.Equal(0x40, ro.Alpha);
Assert.Equal(0, ro.TintStrength);
Assert.Equal(0x80ff40, ro.Tint);
Assert.True(ro.MultiplyTint);
Assert.Equal(BlendKind.Alpha, ro.Blend);
}
[Fact]
public void ColorKey_IsCarriedThrough()
{

View File

@@ -27,4 +27,17 @@ public class SoftwareAffineRasterizerTests
Assert.InRange(colored, 3, 5);
Assert.Equal(0, dst[(1*5+1)*4+3]);
}
[Fact]
public void BlitRgba_MultiplyTintUsesD3dStyleRgbModulationAndOpacity()
{
byte[] src = { 200, 100, 50, 255 };
byte[] dst = new byte[4];
SoftwareAffineRasterizer.BlitRgba(dst, 1, 1, src, 1, 1, 0, 0, 1, 1,
new Affine2D(1, 0, 0, 1, 0, 0), 0x80ff40, 0, 0.5f, multiplyTint: true);
Assert.InRange(dst[0], 49, 50);
Assert.InRange(dst[1], 49, 50);
Assert.InRange(dst[2], 5, 7);
Assert.InRange(dst[3], 126, 127);
}
}

View File

@@ -20,19 +20,24 @@ public readonly record struct SurfaceTransitionState(long CommandKey, int Target
long RangeAStart, int RangeACount, long RangeBStart, int RangeBCount,
long DelayMs, long DurationMs, long StartMs, double Progress, bool Forced);
/// <summary>One synchronized sample of op 0x202's native one-shot packed-color channel.</summary>
public readonly record struct ColorTransitionState(long Current, long Target,
long DelayMs, long DurationMs, long StartMs, double Progress, bool Active);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= 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><paramref name="Alpha"/> is the object's OPACITY (0-255). <paramref name="TintStrength"/> is how
/// strongly <paramref name="Tint"/> (RGB) is blended into the texel (0=keep texel, 255=full tint) — this is the
/// op 0x202/0x203/0x232 "alpha" byte, which is a tint strength, NOT opacity (conflating them made opaque CGs
/// vanish — the grey-background bug).</summary>
/// <summary>The packed-color channel is mode-dependent. Mode 0 uses <paramref name="TintStrength"/> to blend
/// <paramref name="Tint"/> into the texel; mode 1 uses <paramref name="Alpha"/> as opacity and multiplies the
/// texel by <paramref name="Tint"/>. <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,
TransformState Transform, RotationCycleState Rotation,
int Alpha, long Tint, int TintStrength, BlendKind Blend,
SurfaceTransitionState? SurfaceTransition = null);
bool MultiplyTint,
SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null);
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
@@ -56,6 +61,10 @@ public sealed class GfxState
public long Color;
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,
// duration +0x4c. It shares obj+0x34's start timestamp with the one-shot matrix channels.
public long OneShotColorTarget = -1, ColorDelayMs, ColorDurationMs;
public bool OneShotColorEnabled;
// ---- src-rect / spritesheet-cell channel (ops 0x239 static cell, 0x231 animate). Interpolator
// SRC-RECT SCROLL channel: period obj+0x230, start obj+0x21c, grid obj+0x238/0x23c. ----
@@ -84,7 +93,7 @@ public sealed class GfxState
public long RotationDelayMs, RotationDurationMs;
public bool RotationChannelEnabled;
// Shared matrix-channel start timestamp obj+0x34, seeded from frame-time ctx+0xb550.
public long MatrixStartMs = -1;
public long OneShotStartMs = -1;
// Op 0x234 is a separate cyclic rotation channel (period obj+0x228, axis obj+0x244..0x24c).
public long RotationPeriodMs;
@@ -141,6 +150,8 @@ public sealed class GfxState
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
Field64 = s.Field64, Field68 = s.Field68, Field6c = s.Field6c,
Color = s.Color, HasColor = s.HasColor, StaticColorMode = s.StaticColorMode,
OneShotColorTarget = s.OneShotColorTarget, ColorDelayMs = s.ColorDelayMs,
ColorDurationMs = s.ColorDurationMs, OneShotColorEnabled = s.OneShotColorEnabled,
SrcGridW = s.SrcGridW, SrcGridH = s.SrcGridH, SrcCell = s.SrcCell,
SrcPeriod = s.SrcPeriod, SrcStart = s.SrcStart, SrcAnim = s.SrcAnim,
ColorPeriod = s.ColorPeriod, ColorStart = s.ColorStart, ColorTarget = s.ColorTarget,
@@ -152,7 +163,7 @@ public sealed class GfxState
TranslationEnabled = s.TranslationEnabled,
RotationCurrent = s.RotationCurrent, RotationTarget = s.RotationTarget,
RotationDelayMs = s.RotationDelayMs, RotationDurationMs = s.RotationDurationMs,
RotationChannelEnabled = s.RotationChannelEnabled, MatrixStartMs = s.MatrixStartMs,
RotationChannelEnabled = s.RotationChannelEnabled, OneShotStartMs = s.OneShotStartMs,
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
};
@@ -229,6 +240,25 @@ public sealed class GfxState
}
}
/// <summary>Op 0x202: arm the delayed one-shot packed-color interpolation without replacing the
/// current static color. Negative alpha/RGB operands resolve from current obj+0x60 independently.</summary>
public void SetAnimatedObjectColorResolved(long handle, long delayMs, long durationMs, long alpha, long rgb)
{
lock (_lock)
{
var o = GetOrCreate(handle);
long current = o.HasColor ? o.Color : 0xffffffff;
long resolvedAlpha = alpha < 0 ? (current >> 24) & 0xff : System.Math.Min(alpha, 0xff);
long resolvedRgb = rgb < 0 ? current & 0xffffff : rgb & 0xffffff;
if (!o.HasColor) { o.Color = current; o.HasColor = true; }
o.OneShotColorTarget = PackColor(resolvedAlpha, resolvedRgb);
o.ColorDelayMs = delayMs;
o.ColorDurationMs = durationMs;
o.OneShotColorEnabled = true;
o.OneShotStartMs = -1;
}
}
public void SetStaticObjectColorResolved(long handle, long mode, long alpha, long rgb)
{
SetObjectColorResolved(handle, alpha, rgb);
@@ -345,7 +375,7 @@ public sealed class GfxState
var o = GetOrCreate(handle);
o.ScaleDelayMs = delayMs; o.ScaleDurationMs = durationMs;
o.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
o.ScaleEnabled = durationMs > 0; o.MatrixStartMs = -1;
o.ScaleEnabled = durationMs > 0; o.OneShotStartMs = -1;
}
}
@@ -357,7 +387,7 @@ public sealed class GfxState
var o = GetOrCreate(handle);
o.TranslationDelayMs = delayMs; o.TranslationDurationMs = durationMs;
o.TranslationTarget = target;
o.TranslationEnabled = durationMs > 0; o.MatrixStartMs = -1;
o.TranslationEnabled = durationMs > 0; o.OneShotStartMs = -1;
}
}
@@ -370,7 +400,7 @@ public sealed class GfxState
var o = GetOrCreate(handle);
o.RotationDelayMs = delayMs; o.RotationDurationMs = durationMs;
o.RotationTarget = (axis.X, axis.Y, axis.Z, angleDegrees);
o.RotationChannelEnabled = durationMs > 0; o.MatrixStartMs = -1;
o.RotationChannelEnabled = durationMs > 0; o.OneShotStartMs = -1;
}
}
@@ -418,14 +448,28 @@ public sealed class GfxState
if (!o.Visible) continue;
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L);
// ---- color: object stays OPAQUE; the op-0x202/0x203 alpha is a TINT STRENGTH (0=keep texel,
// 255=full tint), NOT opacity. 0x232 ping-pongs the strength (+tint) toward the target (glow). ----
// ---- packed color: mode 0 treats alpha as tint/fill strength; mode 1 treats it as opacity and
// RGB as multiplicative source modulation. 0x232 ping-pongs the mode-0 strength/tint. ----
int alpha = 255; long tint = 0xFFFFFF; int strength = 0; var blend = BlendKind.Opaque;
bool multiplyTint = false;
long sampledColor = o.Color;
ColorTransitionState? colorTransition = null;
if (o.OneShotColorEnabled)
{
if (o.OneShotStartMs < 0) o.OneShotStartMs = nowMs;
(sampledColor, colorTransition) = SampleOneShotColor(o, nowMs);
}
if (o.HasColor)
{
var (a, r, g, b) = BlendMath.UnpackArgb(o.Color);
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
if (o.StaticColorMode == 2)
if (o.StaticColorMode == 1)
{
// Native mode 1 enables SRCALPHA/INVSRCALPHA and passes packed ARGB as D3D
// modulation. Its high byte is opacity, not mode-0 tint/fill strength.
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
}
else if (o.StaticColorMode == 2)
{
// Native transition-source mode: 0xffffffff is opaque identity modulation,
// not a request to replace every texel with white.
@@ -467,17 +511,19 @@ public sealed class GfxState
}
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.
if ((o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled) && o.MatrixStartMs < 0)
o.MatrixStartMs = nowMs;
if ((o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled)
&& o.OneShotStartMs < 0)
o.OneShotStartMs = nowMs;
var scale = SampleMatrixChannel(ref o.ScaleCurrent, o.ScaleTarget, o.ScaleDelayMs,
o.ScaleDurationMs, o.MatrixStartMs, ref o.ScaleEnabled, nowMs);
o.ScaleDurationMs, o.OneShotStartMs, ref o.ScaleEnabled, nowMs);
var rotation = SampleRotationChannel(ref o.RotationCurrent, o.RotationTarget,
o.RotationDelayMs, o.RotationDurationMs,
o.MatrixStartMs, ref o.RotationChannelEnabled, nowMs);
o.OneShotStartMs, ref o.RotationChannelEnabled, nowMs);
var translation = SampleMatrixChannel(ref o.TranslationCurrent, o.TranslationTarget,
o.TranslationDelayMs, o.TranslationDurationMs,
o.MatrixStartMs, ref o.TranslationEnabled, nowMs);
if (!o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled) o.MatrixStartMs = -1;
o.OneShotStartMs, ref o.TranslationEnabled, nowMs);
if (!o.OneShotColorEnabled && !o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled)
o.OneShotStartMs = -1;
double cycleAngle = 0;
if (o.RotationEnabled && o.RotationPeriodMs > 0)
@@ -498,12 +544,44 @@ public sealed class GfxState
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, transition));
alpha, tint, strength, blend, multiplyTint, transition, colorTransition));
}
return list;
}
}
private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs)
{
long current = o.Color & 0xffffffff;
long target = o.OneShotColorTarget & 0xffffffff;
long start = o.OneShotStartMs;
long elapsed = nowMs - start - o.ColorDelayMs;
if (o.ColorDurationMs <= 0 || elapsed >= o.ColorDurationMs)
{
long delay = o.ColorDelayMs, duration = o.ColorDurationMs;
o.Color = target;
o.ColorDelayMs = 0;
o.ColorDurationMs = 0;
o.OneShotColorTarget = -1;
o.OneShotColorEnabled = false;
return (target, new ColorTransitionState(current, target, delay, duration, start, 1.0, false));
}
if (elapsed <= 0)
return (current, new ColorTransitionState(current, target, o.ColorDelayMs,
o.ColorDurationMs, start, 0.0, true));
long packed = 0;
long remaining = o.ColorDurationMs - elapsed;
for (int shift = 0; shift <= 24; shift += 8)
{
long c = (current >> shift) & 0xff;
long t = (target >> shift) & 0xff;
packed |= ((c * remaining + t * elapsed) / o.ColorDurationMs & 0xff) << shift;
}
return (packed, new ColorTransitionState(current, target, o.ColorDelayMs,
o.ColorDurationMs, start, elapsed / (double)o.ColorDurationMs, true));
}
private static (double X, double Y, double Z) SampleMatrixChannel(
ref (double X, double Y, double Z) current,
(double X, double Y, double Z) target,

View File

@@ -5,7 +5,7 @@ public static class SoftwareAffineRasterizer
{
public static void BlitRgba(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcH,
int srcX, int srcY, int width, int height, Affine2D localToDest,
long tint, float tintStrength, float opacity)
long tint, float tintStrength, float opacity, bool multiplyTint = false)
{
if (width <= 0 || height <= 0 || !localToDest.TryInverse(out var inv)) return;
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
@@ -19,9 +19,9 @@ public static class SoftwareAffineRasterizer
if ((uint)u >= (uint)width || (uint)v >= (uint)height) continue;
int si=((srcY+v)*srcW+(srcX+u))*4, di=(y*dstW+x)*4;
int sa=src[si+3]*ia/255; if(sa==0) continue;
int sr=(src[si]*(255-istr)+tr*istr)/255;
int sg=(src[si+1]*(255-istr)+tg*istr)/255;
int sb=(src[si+2]*(255-istr)+tb*istr)/255;
int sr=multiplyTint ? src[si]*tr/255 : (src[si]*(255-istr)+tr*istr)/255;
int sg=multiplyTint ? src[si+1]*tg/255 : (src[si+1]*(255-istr)+tg*istr)/255;
int sb=multiplyTint ? src[si+2]*tb/255 : (src[si+2]*(255-istr)+tb*istr)/255;
Blend(dst,di,sr,sg,sb,sa);
}
}

View File

@@ -369,8 +369,9 @@ public sealed class VirtualMachine
Gfx.ClearSurface((int)Read(a[0])); return pc + 1;
case "clone-gfx-object": // 0x21d (source handle)(destination handle)
Gfx.CloneObject(Read(a[0]), Read(a[1])); return pc + 1;
case "gfx-blit-color": // 0x202 (handle)(x)(y)(alpha)(color) — static alpha/tint (anim interp deferred)
Gfx.SetObjectColorResolved(Read(a[0]), Read(a[3]), Read(a[4])); return pc + 1;
case "gfx-blit-color": // 0x202 (handle)(delay)(duration)(alpha)(color) — one-shot color
Gfx.SetAnimatedObjectColorResolved(Read(a[0]), Read(a[1]), Read(a[2]), Read(a[3]), Read(a[4]));
return pc + 1;
case "gfx-draw-color": // 0x203 (handle)(v)(alpha)(color) — static alpha/tint
Gfx.SetStaticObjectColorResolved(Read(a[0]), Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
// ---- sprite transform / animation cluster (docs/engine-re.md "0x21c-0x243 ... ANIMATION") ----