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

@@ -756,14 +756,15 @@ Ghidra functions renamed + plate-commented, saved).
RGB888; `<0` = none; else texels whose `(R,G,B)` equal the key become transparent when the surface image
is loaded/cached.
- **`0x202` (`gfx_op_0x202_worker_set_color_anim` `0x47ea00`)**: sets an **animated** color/alpha target
`obj+0x64 = packedARGB`, the color-anim active bit, resets progress `obj+0x34=0`. Animates over the global
clock (`0x238`).
`obj+0x64 = packedARGB`, the color-anim active bit, and resets shared start `obj+0x34=0`. Operands 2/3
are delay/duration at `obj+0x38/+0x4c`; sampling uses frame clock `ctx+0xb550`, not op `0x238`.
- **`0x203` (`gfx_op_0x203_worker_set_color` `0x47e9b0`)**: sets a **static** color/alpha `obj+0x60`, no anim
bit. Immediate per-object modulation.
- **Blit** (`gfx_object_blit_d3d9` `0x4774c0`): selects a **blend mode** (`local_2c`: 0 opaque, 1 alpha
`SRCALPHA/INVSRCALPHA`, 2/3 additive/special for glow/flash) and passes a modulation color/alpha to the
device draw. Slice A ports the **alpha** path (fades); additive (glow) is deferred (its `local_2c` source
field is not yet pinned).
field is `obj+0x30`, the value written by op `0x203`. Mode 1 uses packed ARGB alpha as opacity and RGB
as multiplicative D3D modulation. Modes 2/3 remain separately scoped beyond the completed mode-1 path.
**Interpolation RE pass (2026-07-08, stalled → both deferrals confirmed).** Attempted to pin how a `0x202`
fade animates so smooth ramping could join slice A. Findings (Ghidra `gfx_object_anim_interpolate`
@@ -778,6 +779,20 @@ one-shot fade's exact source→target→easing is still unresolved and would tak
stays deferred**; slice A ships the static end-state (which reaches the correct final alpha/tint and fixes
the stuck-opaque bug), with interpolation as a scoped follow-up.
**Resolution (2026-07-10 — supersedes the deferral above).** The missing consumer was the bit-1 branch in
`gfx_object_apply_transform_channels` (`0x472f00`), before its matrix channels. It seeds shared start
`obj+0x34` from `ctx+0xb550`; holds current packed ARGB `obj+0x60` through delay `+0x38`; then performs an
integer, bytewise linear interpolation to target `+0x64` for duration `+0x4c`. At natural completion—or
when `ctx+0xb55c == 1` requests forced completion—the target commits to current, delay/duration clear,
target becomes `0xffffffff`, and the one-shot active bit clears when no color/matrix/src-rect sibling remains.
The object-local override bit at `+0x2d0` suppresses the global force. Negative alpha/RGB target operands
independently preserve their bytes from current `+0x60`.
The port now carries current and target separately and samples them from the unified `FrameClock`; an op
`0x203` static write after `0x202` therefore becomes the ramp's current value rather than overwriting its
target. Mode 0 retains the established CG/tint/fill behavior; mode 1 now uses native alpha opacity plus RGB
modulation. `draw-string 0x204`/`0x7a` remains a separate dependency.
### SC0000 anim/transform/spritesheet cluster — op→field map (2026-07-08)
Reversed for the animation cluster slice (spec `docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md`).

View File

@@ -132,14 +132,14 @@ SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read
- **evidence:** Ghidra handler 0x4227b0 (dispatch ctx[0x26c93+0x1ff]); FUN_0047e800(op1,(float)op2,(float)op3,(float)op4).
### 0x202 `gfx-blit-color` (gfx-blit-color, argc 5)
- **summary:** 0x202 (handle)(x)(y)(alpha)(color) — gfx cmd-type 0xb. Handler gfx_op_0x202_blit_color @0x4228d0: worker gfx_op_0x202_worker_set_color_anim @0x47ea00 sets an ANIMATED color/alpha target (obj+0x64) + anim bit; packs ARGB from alpha(op4, ≥0x100→0xff, <0→FUN_0047f3e0) and color(op5, <0→FUN_0047f3e0). C# VM (2026-07-08 blend slice): routes through GfxState.SetObjectColor → the compositor applies STATIC alpha/tint (BlendKind.Alpha); smooth color-anim interpolation deferred. See docs/engine-re.md §Blend & transparency.
- **summary:** 0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on frame clock ctx+0xb550, commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4228d0 (dispatch ctx[0x26c93+0x202]); packs (alpha<<24|rgb) from operands 4/5, FUN_0047ea00(op1,op2,op3,packed).
- **evidence:** Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock ctx+0xb550; bytewise integer LERP; natural or ctx+0xb55c forced completion. /v2 annotated and saved 2026-07-10.
### 0x203 `gfx-draw-color` (gfx-draw-color, argc 4)
- **summary:** 0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores mode at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes through FUN_0047f3e0. Mode 2 is the 0x223 transition-source alpha/identity path (0xffffffff = opaque identity, not solid white); mode 0 retains the established tint-strength behavior.
- **summary:** 0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes. Mode 0 retains established opaque CG/tint/fill behavior; mode 1 is SRCALPHA/INVSRCALPHA with ARGB alpha opacity and multiplicative RGB modulation; mode 2 is the 0x223 transition-source identity path.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); negative operands read current obj+0x60 via FUN_0047f3e0, then gfx_op_0x203_worker_set_color stores op2 at obj+0x30 and packed ARGB at obj+0x60. SC0000 transition setup uses mode 2 with -1/-1 before 0x21d/0x223; synchronized window pixels prove treating 0xffffffff as generic white tint is wrong.
- **evidence:** Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9; mode 1 sets D3DRS SRCALPHA/INVSRCALPHA and the packed color is the device draw modulation. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white.
### 0x208 `get-texture-size` (get-texture-size, argc 3)
- **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649)

View File

@@ -939,3 +939,44 @@ apart from the pre-existing nullable warning and threaded `SELFTEST OK`; all six
opcode/ctx lint, 481-script decode validation, RECOVER, and Windows CR-aware `git diff --check` clean.
The plain check reports only the generated reference's CRLF on its newly added row. SC0000 coverage is
**85/129 handled (65.9%)**, 44 GAP ops / 598 GAP instructions. No commit was made.
### A2b — op 0x202 one-shot color/presentation ✅ DONE (2026-07-10)
**Native contract.** `gfx_op_0x202_worker_set_color_anim@0x47ea00` writes target packed ARGB at
`obj+0x64`, delay/duration at `+0x38/+0x4c`, and resets the one-shot family's shared start `+0x34`.
The missing consumer is the bit-1 path in `gfx_object_apply_transform_channels@0x472f00`: it seeds start
from frame time `ctx+0xb550`, performs an integer bytewise current-`+0x60` to target-`+0x64` LERP, then
commits target, clears timing, writes target `-1`, and clears the active bit once all sibling one-shot
channels finish. This is ordinary presentation-clock coupling, not op `0x238`'s separate service clock.
`ctx+0xb55c == 1` forces completion unless the object-local `+0x2d0` override bit is set. `/v2` comments
were corrected and saved.
**Port and endpoint diagnosis.** `GfxState` now retains current/target/delay/duration separately, shares
the start timestamp with scale/rotation/translation, and exposes synchronized `color=current->target` plus
progress in the compositor/timeline log. This fixes the initial black-to-EV049AA cut: windowed frames sample
the full-screen `0xcf08` black owner through 11 monotonically decreasing states from `1.00` to `0.00` instead
of installing the transparent endpoint immediately. The ADV textbox backing `0xd2f0` is a real 800×227 black
fill at `(0,373)`; repeated show/hide passages now produce intermediate samples (for example
`0.00, 0.26, 0.54, 0.82, 1.00` and the reverse), synchronized to the executing `0x202` sites around
`0x12283..0x12342`.
The remaining full-white runs are not a stuck color clock or d2f0 endpoint: pixel windows place them after
d2f0 is transparent, while object timelines show `AE*` handles first becoming visible in mode 0 with default
`0xffffffff` before their later mode-1 initializer. Native call-site disassembly proves op `0x203`'s
`obj+0x30` is passed directly as the D3D blend selector and mode 1 uses `SRCALPHA/INVSRCALPHA`; the port now
uses ARGB alpha as opacity and RGB as multiplicative modulation for that mode. The long white intervals did
not disappear, which localizes the residual to when intermediate retained state is presented/batched, not to
0x202 interpolation. Altering that scheduler boundary without a matching native present trace would exceed
this bounded slice. `draw-string 0x204`/`0x7a` was not pulled in.
Windowed evidence: 340 PNGs, 107 distinct pixel states; black-to-first-CG and d2f0 ramps agree with synchronized
object progress. Focused tests cover exact integer midpoint math, delay, natural completion field cleanup,
negative sentinel resolution, the 0x202-then-0x203 current/target pattern, shared color/matrix clock start,
mode-1 opacity/modulation, and the software rasterizer.
**Validation:** engine **108/108**; full sweep unchanged at **284 exit / 13 STEP-LIMIT**; Godot build clean
apart from the pre-existing nullable warning and threaded `SELFTEST OK`; all seven Python test scripts,
opcode/ctx lint, 481-script decode validation, and RECOVER clean. SC0000 coverage remains **85/129 handled
(65.9%)**, 44 GAP ops / 598 GAP instructions. The differential oracle retains its prior branch-state
divergence after `0x12031`; it agrees through the executed `0x202`/`0x203` sequence and does not implicate
this slice. Windows CR-aware whitespace validation is clean.

View File

@@ -138,7 +138,9 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
- `--speed <f>` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.058 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, sampled `scale`/`trans`, and
one-shot-plus-cyclic `rot`ation angles. Parent directories are created automatically.
one-shot-plus-cyclic `rot`ation angles. Active op-`0x202` outcomes include packed `color=current->target`
and `colorProgress`, synchronized with the same frame/clock in `--timeline-log`. Parent directories are
created automatically.
- `--transition-click-ms <n>` — diagnostic-only input injector: after a foreground transition has been active for `n` virtual milliseconds, send one click through the real input lifecycle. The click completes/consumes the transition and does not advance a stable page. Use with `--timeline-log`, `--gfx-log`, and windowed `--shot-sequence`; omit for normal play.
- `--timeline-log <jsonl>` — diagnostic-only synchronized event stream for a real Godot run. Records every
executed script byte offset/opcode, virtual time/frame, VM state changes (`running`, `sleep`, `input-wait`,

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") ----

View File

@@ -297,7 +297,8 @@ public partial class Main : Godot.Control
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}";
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
}
else outcome = "SKIP(no-resId, opaque render-target)";
}
@@ -308,14 +309,15 @@ public partial class Main : Godot.Control
else
{
BlitLayer(bmp, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity);
localToDest, opacity, v.MultiplyTint);
var raw = _vm.Gfx.TryGet(v.Handle);
outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"op={opacity:0.00} tintStr={strength:0.00}";
$"mode={raw?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
}
}
decisions?.Add(v.Handle, $"z{z} {outcome}");
@@ -325,6 +327,11 @@ public partial class Main : Godot.Control
if (decisions != null) LogGfxDecisionChanges(decisions);
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
: "";
// Native type-0 surface commands first leave range A in normal z-order, then alpha-composite range B
// into the target surface. SC0000 binds that target to handle+2, above both source handles, so drawing
// range B here with progress produces old*(1-progress)+new*progress without disturbing ambient channels.
@@ -349,7 +356,7 @@ public partial class Main : Godot.Control
var bmp = _host.ResolveResIdTexture(source.SurfaceResId);
if (bmp == null) continue;
BlitLayer(bmp, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity);
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint);
}
drawn++;
}
@@ -388,10 +395,10 @@ public partial class Main : Godot.Control
// Blit one object's surface rect. The source Image is cached per (path, colorKey): on first load, texels
// matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend).
// tintStrength (0..1, the op 0x202/0x203 alpha) LERPs the texel RGB toward tint (0=keep texel, 1=full tint;
// fade-to-black uses tint=black, strength=1); alpha is the object's OPACITY (independent of the tint).
// Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 sets multiplyTint and uses packed RGB as
// multiplicative modulation while alpha is object opacity.
private void BlitLayer(string bmpPath, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h,
Age.Engine.Model.Affine2D localToDest, float alpha = 1f)
Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false)
{
var cacheKey = (bmpPath, colorKey);
if (!_imgCache.TryGetValue(cacheKey, out var src))
@@ -416,7 +423,7 @@ public partial class Main : Godot.Control
byte[] dst = _screen.GetData(); byte[] ss = src.GetData();
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
dst, _screen.GetWidth(), _screen.GetHeight(), ss, src.GetWidth(), src.GetHeight(),
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha);
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint);
_screen.SetData(_screen.GetWidth(), _screen.GetHeight(), false, _screen.GetFormat(), dst);
}

View File

@@ -4791,36 +4791,36 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "gfx-blit-color"
category = "draw"
summary = "0x202 (handle)(x)(y)(alpha)(color) — gfx cmd-type 0xb. Handler gfx_op_0x202_blit_color @0x4228d0: worker gfx_op_0x202_worker_set_color_anim @0x47ea00 sets an ANIMATED color/alpha target (obj+0x64) + anim bit; packs ARGB from alpha(op4, ≥0x100→0xff, <0→FUN_0047f3e0) and color(op5, <0→FUN_0047f3e0). C# VM (2026-07-08 blend slice): routes through GfxState.SetObjectColor → the compositor applies STATIC alpha/tint (BlendKind.Alpha); smooth color-anim interpolation deferred. See docs/engine-re.md §Blend & transparency."
summary = "0x202 (handle)(delay_ms)(duration_ms)(alpha)(color) — arm the one-shot packed-ARGB channel. Worker gfx_op_0x202_worker_set_color_anim @0x47ea00 resets shared start obj+0x34, writes delay +0x38, duration +0x4c, and target +0x64. gfx_object_apply_transform_channels @0x472f00 linearly interpolates each byte from current +0x60 on frame clock ctx+0xb550, commits the target, clears timing, writes target -1, and clears the one-shot active bit when all sibling channels finish. Negative alpha/RGB independently preserve current bytes. Implemented in GfxState with synchronized current/target timeline evidence; draw-string 0x204/0x7a remains separate."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra handler 0x4228d0 (dispatch ctx[0x26c93+0x202]); packs (alpha<<24|rgb) from operands 4/5, FUN_0047ea00(op1,op2,op3,packed)."
evidence = "Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock ctx+0xb550; bytewise integer LERP; natural or ctx+0xb55c forced completion. /v2 annotated and saved 2026-07-10."
[[opcode.semantics.args]]
i = 1
role = ""
role = "retained object handle"
observed_types = ["imm", "g-int", "l-int", "l-ptr"]
[[opcode.semantics.args]]
i = 2
role = ""
role = "delay in frame-clock milliseconds"
observed_types = ["imm", "g-int", "l-int"]
[[opcode.semantics.args]]
i = 3
role = ""
role = "duration in frame-clock milliseconds"
observed_types = ["imm", "g-int", "l-int"]
[[opcode.semantics.args]]
i = 4
role = ""
role = "target tint-strength/ARGB high byte; negative preserves current"
observed_types = ["imm", "l-int"]
[[opcode.semantics.args]]
i = 5
role = ""
role = "target RGB; negative preserves current"
observed_types = ["imm", "g-int", "l-int"]
[[opcode]]
@@ -4832,12 +4832,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "gfx-draw-color"
category = "draw"
summary = "0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores mode at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes through FUN_0047f3e0. Mode 2 is the 0x223 transition-source alpha/identity path (0xffffffff = opaque identity, not solid white); mode 0 retains the established tint-strength behavior."
summary = "0x203 (handle)(mode)(alpha)(color) — gfx cmd-type 9. Worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60. Negative alpha/RGB preserve current static bytes. Mode 0 retains established opaque CG/tint/fill behavior; mode 1 is SRCALPHA/INVSRCALPHA with ARGB alpha opacity and multiplicative RGB modulation; mode 2 is the 0x223 transition-source identity path."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra handler 0x4229a0 (dispatch ctx[0x26c93+0x203]); negative operands read current obj+0x60 via FUN_0047f3e0, then gfx_op_0x203_worker_set_color stores op2 at obj+0x30 and packed ARGB at obj+0x60. SC0000 transition setup uses mode 2 with -1/-1 before 0x21d/0x223; synchronized window pixels prove treating 0xffffffff as generic white tint is wrong."
evidence = "Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9; mode 1 sets D3DRS SRCALPHA/INVSRCALPHA and the packed color is the device draw modulation. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white."
[[opcode.semantics.args]]
i = 1