Files
OpenMaidEngine/docs/superpowers/plans/2026-07-08-blend-transparency.md
gamer147 fe61f7287c docs(plan): blend & transparency slice A implementation plan + colorkey RE
TDD plan (BlendMath -> RenderObject resolution -> host colorkey/alpha/tint blit
-> surfaceless fade fill -> docs). Records the reversed colorkey format and the
0x202/0x203 color workers in engine-re.md (Ghidra annotated+saved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:10:32 -04:00

30 KiB
Raw Blame History

Blend & Transparency (Slice A) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the Godot compositor honor per-surface colorkey transparency and per-object alpha/tint, so keyed sprites stop rendering as opaque boxes and the SC0000 opening's fade layer stops covering the scene as an opaque grey.

Architecture: Hybrid (approach 3). The engine resolves each visible object into a fully-specified RenderObject blend plan (Alpha, Tint, Blend, existing ColorKey); the Godot host executes a colorkey/alpha/tint blit. A small pure BlendMath helper in the engine holds the colorkey/unpack math so it is unit-tested; the per-pixel loop stays in the host where Godot's Image lives.

Tech Stack: C# / .NET 8 (engine/AgeEngine.sln), xUnit; Godot 4.7 mono (godot/Himegari.csproj).

Spec: docs/superpowers/specs/2026-07-08-blend-transparency-design.md. Native RE (colorkey format, 0x202/0x203 color workers, blit blend modes) is done and recorded in docs/engine-re.md §"Blend & transparency" — read it; the Ghidra image is annotated (gfx_op_0x1f9_load_surface, gfx_op_0x202_worker_set_color_anim, gfx_op_0x203_worker_set_color, gfx_object_blit_d3d9).

Global Constraints

  • Parity is sacred. Non-Godot output stays byte-identical. Gates that MUST stay green: dotnet test engine/AgeEngine.sln; Age.Cli sweepexit=284, STEP-LIMIT=13; Godot --selftestSELFTEST OK. This slice adds model fields + host rendering only; the headless dialogue path is untouched.
  • Seam rule: Age.Engine/Vm may reference only Model, Hosting, Diagnostics — never Sys4. BlendMath/BlendKind live in Age.Engine/Model.
  • Colorkey semantics (from RE, verbatim): op arg 3 < 0no colorkey; else the operand is 0xRRGGBB and texels whose (R,G,B) equal it become transparent (operand 0 = key black).
  • Packed color (existing GfxState.PackColor(alpha,color)): 0xAARRGGBB = (alpha<<24) | (color & 0xFFFFFF).
  • In scope: colorkey transparency, per-object static alpha + RGB tint (modulate), surfaceless color-fill quads. Deferred (documented, not built): smooth color-animation interpolation over the 0x238 clock, and additive/glow blend (local_2c mode 2/3). Leave the BlendKind.Additive enum member as an unused seam.
  • Screen size: the primary surface is 800x600 (see GodotAdvHost._slotDims[0]).
  • Build/run: dotnet build engine/AgeEngine.sln -c Debug; dotnet test engine/AgeEngine.sln. Godot: dotnet build godot/Himegari.csproj -c Debug then S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe.
  • TDD, one deliverable per task, commit at the end of each task. End every commit message with: Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Task 0: Branch

  • Step 1: Create the feature branch (repo root = age-reimpl/, default branch main)
cd "S:/Game Hacking/Eushully/Himegari/age-reimpl"
git checkout -b feat/blend-transparency

File Structure

  • engine/Age.Engine/Model/BlendMath.csnew. Pure colorkey/ARGB math. Task 1.
  • engine/Age.Engine/Model/BlendKind.csnew. enum BlendKind { Opaque, Alpha, Additive }. Task 2.
  • engine/Age.Engine/Model/GfxState.csmodify. RenderObject blend fields; GfxObject.HasColor; resolution in SnapshotVisibleObjects. Task 2.
  • engine/Age.Engine/Vm/VirtualMachine.csmodify. 0x202/0x203 set HasColor. Task 2.
  • engine/Age.Engine.Tests/BlendMathTests.csnew. Task 1.
  • engine/Age.Engine.Tests/RenderObjectBlendTests.csnew. Task 2.
  • godot/Main.csmodify. Colorkey-baked image cache; BlitLayer alpha/tint; surfaceless fill. Tasks 34.

Task 1: BlendMath — pure colorkey + ARGB helpers

Files:

  • Create: engine/Age.Engine/Model/BlendMath.cs
  • Test: engine/Age.Engine.Tests/BlendMathTests.cs

Interfaces:

  • Produces:

    • static bool Age.Engine.Model.BlendMath.HasColorKey(long colorKey)colorKey >= 0.
    • static bool BlendMath.ColorKeyMatches(byte r, byte g, byte b, long colorKey) — false when !HasColorKey; else true when (r,g,b) equals the key's 0xRRGGBB bytes.
    • static (int A, int R, int G, int B) BlendMath.UnpackArgb(long packed) — from 0xAARRGGBB.
  • Step 1: Write the failing test

Create engine/Age.Engine.Tests/BlendMathTests.cs:

using Age.Engine.Model;
using Xunit;

public class BlendMathTests
{
    [Fact]
    public void NegativeColorKey_IsNoKey()
    {
        Assert.False(BlendMath.HasColorKey(-1));
        Assert.False(BlendMath.ColorKeyMatches(0, 0, 0, -1));   // never matches when no key
    }

    [Fact]
    public void ZeroColorKey_KeysBlack()
    {
        Assert.True(BlendMath.HasColorKey(0));
        Assert.True(BlendMath.ColorKeyMatches(0, 0, 0, 0));      // (0,0,0) == key black
        Assert.False(BlendMath.ColorKeyMatches(1, 0, 0, 0));    // near-black is NOT keyed (exact match)
    }

    [Fact]
    public void ColorKey_MatchesRgb888Bytes()
    {
        long key = 0xFF00FF;                                    // magenta 0xRRGGBB
        Assert.True(BlendMath.ColorKeyMatches(0xFF, 0x00, 0xFF, key));
        Assert.False(BlendMath.ColorKeyMatches(0xFE, 0x00, 0xFF, key));
    }

    [Fact]
    public void UnpackArgb_SplitsBytes()
    {
        Assert.Equal((0x80, 0x12, 0x34, 0x56), BlendMath.UnpackArgb(0x80123456));
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test engine/AgeEngine.sln --filter BlendMathTests Expected: FAIL — BlendMath does not exist (compile error).

  • Step 3: Write minimal implementation

Create engine/Age.Engine/Model/BlendMath.cs:

namespace Age.Engine.Model;

/// <summary>Pure blend helpers shared by the engine resolution and the host blit. Colorkey semantics come
/// from the native loader (docs/engine-re.md §"Blend & transparency"): op arg 3 &lt; 0 = no key; otherwise
/// the operand is 0xRRGGBB and matching (R,G,B) texels are transparent (operand 0 = key black).</summary>
public static class BlendMath
{
    /// <summary>A colorkey operand &gt;= 0 is an active key; a negative operand means "no colorkey".</summary>
    public static bool HasColorKey(long colorKey) => colorKey >= 0;

    /// <summary>True when (r,g,b) exactly equals the key's 0xRRGGBB bytes. Always false when there is no key.</summary>
    public static bool ColorKeyMatches(byte r, byte g, byte b, long colorKey)
    {
        if (!HasColorKey(colorKey)) return false;
        return r == ((colorKey >> 16) & 0xff) && g == ((colorKey >> 8) & 0xff) && b == (colorKey & 0xff);
    }

    /// <summary>Split a 0xAARRGGBB packed color (see GfxState.PackColor) into its byte channels.</summary>
    public static (int A, int R, int G, int B) UnpackArgb(long packed)
        => ((int)((packed >> 24) & 0xff), (int)((packed >> 16) & 0xff),
            (int)((packed >> 8) & 0xff), (int)(packed & 0xff));
}
  • Step 4: Run test to verify it passes

Run: dotnet test engine/AgeEngine.sln --filter BlendMathTests Expected: PASS (4 tests).

  • Step 5: Commit
git add engine/Age.Engine/Model/BlendMath.cs engine/Age.Engine.Tests/BlendMathTests.cs
git commit -m "feat: add BlendMath (colorkey match + ARGB unpack)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: RenderObject blend fields + resolution

Files:

  • Create: engine/Age.Engine/Model/BlendKind.cs
  • Modify: engine/Age.Engine/Model/GfxState.cs (RenderObject record ~lines 17-19; GfxObject ~lines 29-49; SnapshotVisibleObjects ~lines 181-199)
  • Modify: engine/Age.Engine/Vm/VirtualMachine.cs (gfx-blit-color / gfx-draw-color handlers ~lines 273-277)
  • Test: engine/Age.Engine.Tests/RenderObjectBlendTests.cs

Interfaces:

  • Consumes: BlendMath.UnpackArgb (Task 1).

  • Produces:

    • enum Age.Engine.Model.BlendKind { Opaque, Alpha, Additive }.
    • RenderObject gains: int Alpha (0-255), long Tint (0xRRGGBB), BlendKind Blend. (Existing long ColorKey is retained.)
    • GfxState.GfxObject.HasColor (bool) — set true by ops 0x202/0x203.
    • Resolution: an object with HasColor resolves to Alpha/Tint from its packed Color and Blend = Alpha; otherwise Alpha = 255, Tint = 0xFFFFFF, Blend = Opaque.
  • Step 1: Write the failing test

Create engine/Age.Engine.Tests/RenderObjectBlendTests.cs:

using System.Linq;
using Age.Engine.Model;
using Xunit;

public class RenderObjectBlendTests
{
    // Make a visible textured object bound to a slot that has a surface, so it appears in the snapshot.
    private static GfxState WithVisibleObject(long handle, long resId, long colorKey)
    {
        var g = new GfxState();
        g.SetSurface(1, resId, colorKey);
        g.BindDraw(handle, 1, 0, 0, 10, 10, 0, 0);   // draw-texture: slot 1, 10x10 at (0,0), visible
        return g;
    }

    [Fact]
    public void ObjectWithoutColor_ResolvesOpaqueWhiteTint()
    {
        var g = WithVisibleObject(0x100, resId: 5, colorKey: -1);
        var ro = g.SnapshotVisibleObjects().Single();
        Assert.Equal(255, ro.Alpha);
        Assert.Equal(0xFFFFFF, ro.Tint);
        Assert.Equal(BlendKind.Opaque, ro.Blend);
    }

    [Fact]
    public void DrawColor_0x203_SetsAlphaTintAndBlend()
    {
        var g = WithVisibleObject(0x100, resId: 5, colorKey: -1);
        // emulate op 0x203: pack (alpha=0x80, color=0x102030) and mark HasColor
        g.SetObjectColor(0x100, GfxState.PackColor(0x80, 0x102030));
        var ro = g.SnapshotVisibleObjects().Single();
        Assert.Equal(0x80, ro.Alpha);
        Assert.Equal(0x102030, ro.Tint);
        Assert.Equal(BlendKind.Alpha, ro.Blend);
    }

    [Fact]
    public void ColorKey_IsCarriedThrough()
    {
        var g = WithVisibleObject(0x100, resId: 5, colorKey: 0x000000);   // key black
        var ro = g.SnapshotVisibleObjects().Single();
        Assert.Equal(0x000000, ro.ColorKey);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test engine/AgeEngine.sln --filter RenderObjectBlendTests Expected: FAIL — BlendKind, RenderObject.Alpha/Tint/Blend, and GfxState.SetObjectColor do not exist (compile errors).

  • Step 3: Add the BlendKind enum

Create engine/Age.Engine/Model/BlendKind.cs:

namespace Age.Engine.Model;

/// <summary>How an object's surface composites onto the canvas. Opaque = straight copy; Alpha = source-alpha
/// blend (fades). Additive (glow/flash, native blit mode 2/3) is a documented seam — NOT implemented in the
/// blend/transparency slice; see docs/superpowers/specs/2026-07-08-blend-transparency-design.md.</summary>
public enum BlendKind { Opaque, Alpha, Additive }
  • Step 4: Add Alpha/Tint/Blend to RenderObject

In engine/Age.Engine/Model/GfxState.cs, change the RenderObject record (currently):

public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey,
                                           int SrcX, int SrcY, int W, int H, int DstX, int DstY,
                                           AnimState Anim);

to (append the three blend fields after Anim):

public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey,
                                           int SrcX, int SrcY, int W, int H, int DstX, int DstY,
                                           AnimState Anim, int Alpha, long Tint, BlendKind Blend);
  • Step 5: Add HasColor + SetObjectColor, and set HasColor in BindDraw-adjacent color path

In GfxState.GfxObject (near the Color field ~line 33), add:

        public bool HasColor;   // true once op 0x202/0x203 set a color/alpha modulation on this object

Add a method on GfxState (near SetSurface, so tests and the VM share one entry point):

    /// <summary>Ops 0x202/0x203: record a packed 0xAARRGGBB color/alpha modulation on the object and mark it
    /// HasColor so the compositor applies alpha+tint (vs the opaque default).</summary>
    public void SetObjectColor(long handle, long packed)
    {
        lock (_lock) { var o = GetOrCreate(handle); o.Color = packed; o.HasColor = true; }
    }
  • Step 6: Resolve the blend fields in SnapshotVisibleObjects

In SnapshotVisibleObjects (~lines 186-196), replace the loop body's list.Add(...) with a version that computes the blend plan first:

            foreach (var kv in _objects.OrderBy(k => k.Key))
            {
                var o = kv.Value;
                if (!o.Visible) continue;
                var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L);

                int alpha = 255; long tint = 0xFFFFFF; var blend = BlendKind.Opaque;
                if (o.HasColor)
                {
                    var (a, r, g, b) = BlendMath.UnpackArgb(o.Color);
                    alpha = a; tint = ((long)r << 16) | ((long)g << 8) | (long)b; blend = BlendKind.Alpha;
                }

                list.Add(new RenderObject(kv.Key, resId, ck, o.SrcRect.X, o.SrcRect.Y, o.SrcRect.W, o.SrcRect.H,
                                          (int)o.V24.X, (int)o.V24.Y,
                                          new AnimState(o.AnimEnabled, o.AnimNormalized,
                                                        o.AnimTarget.X, o.AnimTarget.Y, o.AnimTarget.Z,
                                                        o.AnimDurationTicks, o.AnimGeneration),
                                          alpha, tint, blend));
            }
  • Step 7: Route the VM color ops through SetObjectColor

In engine/Age.Engine/Vm/VirtualMachine.cs, the gfx-blit-color / gfx-draw-color handlers currently read:

            case "gfx-blit-color":      // 0x202 (handle)(x)(y)(alpha)(color) — blend deferred
                Gfx.GetOrCreate(Read(a[0])).Color = GfxState.PackColor(Read(a[3]), Read(a[4]));
            case "gfx-draw-color":      // 0x203 (handle)(v)(alpha)(color) — blend deferred
                Gfx.GetOrCreate(Read(a[0])).Color = GfxState.PackColor(Read(a[2]), Read(a[3]));

Change each assignment to SetObjectColor (so HasColor is set). Keep the surrounding return pc + 1; and any trace/stub lines unchanged:

            case "gfx-blit-color":      // 0x202 (handle)(x)(y)(alpha)(color) — static alpha/tint (anim interp deferred)
                Gfx.SetObjectColor(Read(a[0]), GfxState.PackColor(Read(a[3]), Read(a[4])));
            case "gfx-draw-color":      // 0x203 (handle)(v)(alpha)(color) — static alpha/tint
                Gfx.SetObjectColor(Read(a[0]), GfxState.PackColor(Read(a[2]), Read(a[3])));

NOTE: keep the exact operand indices shown above (a[3]/a[4] for 0x202, a[2]/a[3] for 0x203) — they match the existing code.

  • Step 8: Run the new test — verify it passes

Run: dotnet test engine/AgeEngine.sln --filter RenderObjectBlendTests Expected: PASS (3 tests).

  • Step 9: Run the FULL suite — verify parity

Run: dotnet test engine/AgeEngine.sln Expected: PASS — all pre-existing tests still green (existing RenderObject consumers get the new fields; the VM change only adds HasColor, which no headless host reads).

  • Step 10: Verify the corpus oracle is unchanged

Run: dotnet run --project engine/Age.Cli -c Debug -- sweep Expected: exit=284, STEP-LIMIT=13 (unchanged).

  • Step 11: Commit
git add engine/Age.Engine/Model/BlendKind.cs engine/Age.Engine/Model/GfxState.cs engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/RenderObjectBlendTests.cs
git commit -m "feat: resolve per-object alpha/tint/blend into RenderObject (colorkey retained)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Host — colorkey bake + alpha/tint blit

Files:

  • Modify: godot/Main.cs (_imgCache ~line 234; Recomposite ~lines 251-266; BlitLayer ~lines 290-328)

Interfaces:

  • Consumes: RenderObject.{ColorKey, Alpha, Tint, Blend} (Task 2); BlendMath.ColorKeyMatches, BlendMath.HasColorKey (Task 1).
  • Produces: a compositor that (a) bakes colorkey → transparent on load, keyed by (path, colorKey), and (b) blits each object with its Alpha and Tint.

This task changes only the Godot project (built separately; verified by --selftest + --shot, not xUnit).

  • Step 1: Key the image cache by colorkey and bake transparency

In godot/Main.cs, change the image cache key from string to a (string, long) tuple. Replace the field (~line 234):

    private readonly System.Collections.Generic.Dictionary<string, Image?> _imgCache = new();

with:

    private readonly System.Collections.Generic.Dictionary<(string Path, long Key), Image?> _imgCache = new();
  • Step 2: Thread ColorKey/Alpha/Tint through Recomposite into BlitLayer

In Recomposite (~line 263), the current call is:

            BlitLayer(bmp, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, AlphaFor(v, clockReset, clockDur));

Change it to pass the blend plan:

            float a = AlphaFor(v, clockReset, clockDur) * (v.Alpha / 255f);
            BlitLayer(bmp, v.ColorKey, v.Tint, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, a);
  • Step 3: Rewrite BlitLayer to bake colorkey + apply tint/alpha

Replace the whole BlitLayer method (~lines 290-328) with:

    // 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).
    // tint (0xRRGGBB) modulates the texel RGB (fade-to-black uses tint=black); alpha is the object's opacity.
    private void BlitLayer(string bmpPath, long colorKey, long tint, int srcX, int srcY, int w, int h,
                           int dstX, int dstY, float alpha = 1f)
    {
        var cacheKey = (bmpPath, colorKey);
        if (!_imgCache.TryGetValue(cacheKey, out var src))
        {
            src = new Image();
            if (src.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)) != Error.Ok)
            { GD.Print($"BMP load failed {bmpPath}"); src = null; }
            else
            {
                if (src.GetFormat() != Image.Format.Rgba8) src.Convert(Image.Format.Rgba8);
                if (Age.Engine.Model.BlendMath.HasColorKey(colorKey)) BakeColorKey(src, colorKey);
            }
            _imgCache[cacheKey] = src;
        }
        if (src == null) return;

        int sw = w > 0 ? w : src.GetWidth();
        int sh = h > 0 ? h : src.GetHeight();
        sw = System.Math.Min(sw, src.GetWidth() - srcX);
        sh = System.Math.Min(sh, src.GetHeight() - srcY);
        if (sw <= 0 || sh <= 0) return;

        bool plainOpaque = alpha >= 0.999f && tint == 0xFFFFFF && !Age.Engine.Model.BlendMath.HasColorKey(colorKey);
        if (plainOpaque)   // fast path: unchanged behaviour for opaque, un-keyed, un-tinted layers
        {
            _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY));
            return;
        }

        int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
        byte[] dst = _screen.GetData(); byte[] ss = src.GetData();
        int dw = _screen.GetWidth(), dh = _screen.GetHeight(), sfw = src.GetWidth();
        int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
        for (int y = 0; y < sh; y++)
            for (int x = 0; x < sw; x++)
            {
                int dxp = dstX + x, dyp = dstY + y;
                if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
                int di = (dyp * dw + dxp) * 4;
                int si = ((srcY + y) * sfw + (srcX + x)) * 4;
                int sa = ss[si + 3] * ia / 255;                 // texel alpha (colorkey already 0) × object alpha
                if (sa == 0) continue;
                int sr = ss[si] * tr / 255, sg = ss[si + 1] * tg / 255, sb = ss[si + 2] * tb / 255;   // tint modulate
                dst[di]     = (byte)((sr * sa + dst[di]     * (255 - sa)) / 255);
                dst[di + 1] = (byte)((sg * sa + dst[di + 1] * (255 - sa)) / 255);
                dst[di + 2] = (byte)((sb * sa + dst[di + 2] * (255 - sa)) / 255);
                dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + sa);
            }
        _screen.SetData(dw, dh, false, _screen.GetFormat(), dst);
    }

    // Make colorkey-matching texels transparent (native colorkey is baked at surface load).
    private static void BakeColorKey(Image img, long colorKey)
    {
        byte[] px = img.GetData();
        int w = img.GetWidth(), h = img.GetHeight();
        for (int i = 0; i < px.Length; i += 4)
            if (Age.Engine.Model.BlendMath.ColorKeyMatches(px[i], px[i + 1], px[i + 2], colorKey))
                px[i + 3] = 0;
        img.SetData(w, h, false, img.GetFormat(), px);
    }
  • Step 4: Build the Godot project

Run: dotnet build godot/Himegari.csproj -c Debug --nologo -v q Expected: Build succeeded. 0 Error(s).

  • Step 5: Verify parity via the headless self-test

Run: & "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --headless --path godot -- --selftest Expected: ends with SELFTEST OK.

  • Step 6: Visual check — keyed sprites + alpha

Run: & "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --path godot -- --boot --shot "..\scratch\blend_p1.png" --shot-page 1 Then Read the PNG. Expected: the event-CG composites without opaque boxes around keyed layers (compare against the pre-change opening). Full validation of the fade is Task 4.

  • Step 7: Commit
git add godot/Main.cs
git commit -m "feat(godot): colorkey bake + per-object alpha/tint blit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Host — surfaceless color-fill (fades) + acceptance

Files:

  • Modify: godot/Main.cs (Recomposite ~lines 258-264)

Interfaces:

  • Consumes: RenderObject.{SurfaceResId, Alpha, Tint, W, H, DstX, DstY, Blend} (Task 2).

  • Produces: Recomposite fills a Tint×Alpha quad for a colored, surfaceless object (a fade/flash with no bound image) instead of skipping it.

  • Step 1: Fill surfaceless colored objects instead of skipping them

In Recomposite (~lines 258-264), the loop currently skips any object with no surface:

        foreach (var v in _vm.Gfx.SnapshotVisibleObjects())   // already ascending-handle = z-order
        {
            if (v.SurfaceResId == 0) continue;                 // render-target/blank surface (no file) — later phase
            var bmp = _host.ResolveResIdTexture(v.SurfaceResId);
            if (bmp == null) continue;
            float a = AlphaFor(v, clockReset, clockDur) * (v.Alpha / 255f);
            BlitLayer(bmp, v.ColorKey, v.Tint, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, a);
        }

Replace it with a version that fills colored surfaceless objects:

        foreach (var v in _vm.Gfx.SnapshotVisibleObjects())   // already ascending-handle = z-order
        {
            float a = AlphaFor(v, clockReset, clockDur) * (v.Alpha / 255f);
            if (v.SurfaceResId == 0)
            {
                // A colored object with no bound surface = a fade/flash fill (e.g. fade-to-black). Fill its
                // rect (full-screen when it has no size, the opening's case) with the tint at alpha. Uncolored
                // surfaceless objects are render targets — still skipped (slice C).
                if (v.Blend != Age.Engine.Model.BlendKind.Opaque)
                {
                    int fw = v.W > 0 ? v.W : 800, fh = v.H > 0 ? v.H : 600;
                    FillQuad(v.DstX, v.DstY, fw, fh, v.Tint, a);
                }
                continue;
            }
            var bmp = _host.ResolveResIdTexture(v.SurfaceResId);
            if (bmp == null) continue;
            BlitLayer(bmp, v.ColorKey, v.Tint, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, a);
        }
  • Step 2: Add the FillQuad helper

Add next to BlitLayer in godot/Main.cs:

    // Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
    private void FillQuad(int dstX, int dstY, int w, int h, long tint, float alpha)
    {
        int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
        if (ia == 0) return;
        int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
        byte[] dst = _screen.GetData();
        int dw = _screen.GetWidth(), dh = _screen.GetHeight();
        for (int y = 0; y < h; y++)
            for (int x = 0; x < w; x++)
            {
                int dxp = dstX + x, dyp = dstY + y;
                if (dxp < 0 || dyp < 0 || dxp >= dw || dyp >= dh) continue;
                int di = (dyp * dw + dxp) * 4;
                dst[di]     = (byte)((tr * ia + dst[di]     * (255 - ia)) / 255);
                dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);
                dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
                dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
            }
        _screen.SetData(dw, dh, false, _screen.GetFormat(), dst);
    }
  • Step 3: Build the Godot project

Run: dotnet build godot/Himegari.csproj -c Debug --nologo -v q Expected: Build succeeded. 0 Error(s).

  • Step 4: Verify parity via the headless self-test

Run: & "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --headless --path godot -- --selftest Expected: ends with SELFTEST OK.

  • Step 5: Acceptance — the opening fade no longer covers the scene

Run a windowed shot-sequence with boot state: & "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --path godot -- --boot --shot-sequence "..\scratch\blend_seq" --frames 120 Then Read several frames (e.g. frame_0015.png, frame_0045.png, frame_0060.png). Expected: the event-CG stays visible and the fade renders as a translucent/settling layer instead of a full-screen opaque grey that eats the scene. This is the eyeball acceptance the user validates; if a fade still looks wrong, note it as the deferred smooth color-anim interpolation follow-up (do not expand this slice).

  • Step 6: Commit
git add godot/Main.cs
git commit -m "feat(godot): surfaceless color-fill quads for AE* fades (opening no longer opaque-grey)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Docs + memory

Files:

  • Modify: docs/phase-a-slice-plan.md (append a slice-A-done section)

  • Modify: vm-map/opcodes.toml (0x202/0x203 summaries: now resolved to per-object color/alpha, not "blend deferred"), then rebuild

  • Modify: memory himegari-port-status.md + MEMORY.md

  • Step 1: Refresh the opcode notes for 0x202/0x203

Edit vm-map/opcodes.toml — in the 0x202 and 0x203 entries, update the summary/notes to record that the VM now sets a per-object color/alpha modulation consumed by the compositor (static alpha/tint), citing docs/engine-re.md §"Blend & transparency". Do NOT hand-edit generated files.

  • Step 2: Rebuild the opcode artifacts

Run: py -3.11 -X utf8 tools/opcodes_build.py --build Expected: regenerates tools/age_opcodes_himegari.py, build/opcodes.json, docs/opcode-reference.md, build/opcode-coverage.md with no errors.

  • Step 3: Record the slice in docs/phase-a-slice-plan.md

Append a short "### A2b — Blend & transparency (slice A) DONE" section: what landed (colorkey bake, per-object alpha/tint, surfaceless fade fill), the acceptance result, and the two explicit deferrals (smooth color-anim interpolation; additive/glow). Cross-link the spec.

  • Step 4: Update the status memory

Update himegari-port-status.md (and its one-line entry in MEMORY.md) with the slice-A result and the deferrals. Convert relative dates to absolute (2026-07-08).

  • Step 5: Commit
git add vm-map/opcodes.toml tools/age_opcodes_himegari.py build/opcodes.json docs/opcode-reference.md build/opcode-coverage.md docs/phase-a-slice-plan.md
git commit -m "docs: record blend/transparency slice A (colorkey + alpha/tint + fade fill)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

(The memory files live outside the repo; they do not need to be committed.)


Notes for the executor

  • Do NOT build smooth color-animation interpolation or additive/glow blend — both are deferred by design (see Global Constraints). If the opening fade snaps instead of ramping, that is the expected limit of this slice, not a bug.
  • Colorkey is baked once per (path, colorKey) — if the same BMP is used both keyed and un-keyed, the cache correctly holds two entries.
  • FillQuad full-screen default (800×600) is correct for the opening's full-screen fades; per-object fill geometry is refined in slice B (geometry/anchors).

Self-review (done while writing)

  • Spec coverage: Unit 1 (RenderObject blend fields + resolution) → Task 2; Unit 2 (colorkey bake + alpha/tint blit) → Task 3; Unit 3 (alpha unification) → Task 3 Step 2 (AlphaFor(...) * v.Alpha/255); BlendMath helper → Task 1; surfaceless fill → Task 4; RE Task-0 → done pre-plan (recorded in engine-re.md), so the plan starts at branch. Deferrals (smooth interp, additive) are stated in the spec's Non-goals and this plan's Global Constraints. All covered.
  • Placeholder scan: every code step shows complete code; the only prose-only steps are Task 5 doc edits (inherently prose) with exact files + rebuild command.
  • Type consistency: BlendKind{Opaque,Alpha,Additive}, RenderObject.{Alpha:int, Tint:long, Blend:BlendKind}, GfxState.SetObjectColor(long,long), GfxObject.HasColor:bool, BlendMath.{HasColorKey(long):bool, ColorKeyMatches(byte,byte,byte,long):bool, UnpackArgb(long):(int,int,int,int)}, BlitLayer(string,long,long,int,int,int,int,int,int,float), FillQuad(int,int,int,int,long,float) — used identically across tasks.