From fe61f7287cfe32b6ab594c5d5a90b0d608043dd7 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 8 Jul 2026 18:10:32 -0400 Subject: [PATCH] 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 --- docs/engine-re.md | 23 +- .../plans/2026-07-08-blend-transparency.md | 645 ++++++++++++++++++ 2 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-08-blend-transparency.md diff --git a/docs/engine-re.md b/docs/engine-re.md index 7733f9a..988a2e5 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -557,7 +557,28 @@ buffers (present). **Slot 0 is NOT special** — a normal slot; several objects a host per-frame compositor that draws visible objects **in ascending-handle order** from their live surface, interpolating animations by elapsed time. No VM/host lockstep (op `0x238` clock is non-blocking; animations play during the wait-for-input park). Open detail for implementation: the exact scale/transform math in -`gfx_object_composite` (`FUN_00472f00`/`FUN_00473ed0`) and the colorkey format. +`gfx_object_composite` (`FUN_00472f00`/`FUN_00473ed0`). + +### Blend & transparency — colorkey + `0x202`/`0x203` color/alpha (2026-07-08) + +Reversed for graphics slice A (spec `docs/superpowers/specs/2026-07-08-blend-transparency-design.md`; +Ghidra functions renamed + plate-commented, saved). + +- **Colorkey format** (`gfx_op_0x1f9_load_surface` `0x422360`): read op arg 3; if `(int)key < 0` → **no + colorkey** (opaque); else the operand is **`0xRRGGBB`**, converted to `0xFFRRGGBB` and passed to the + surface creator `FUN_00477c40` as the transparent key (so operand `0` = key **black**). Colorkey is baked + **at surface-load** (matching texels → transparent), NOT compared per-blit. *Port:* interpret arg 3 as + 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`). +- **`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). --- diff --git a/docs/superpowers/plans/2026-07-08-blend-transparency.md b/docs/superpowers/plans/2026-07-08-blend-transparency.md new file mode 100644 index 0000000..9873bca --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-blend-transparency.md @@ -0,0 +1,645 @@ +# 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 sweep` → `exit=284, STEP-LIMIT=13`; Godot `--selftest` → `SELFTEST 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 `< 0` → **no 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 ` + +--- + +## Task 0: Branch + +- [ ] **Step 1: Create the feature branch** (repo root = `age-reimpl/`, default branch `main`) + +```bash +cd "S:/Game Hacking/Eushully/Himegari/age-reimpl" +git checkout -b feat/blend-transparency +``` + +--- + +## File Structure + +- `engine/Age.Engine/Model/BlendMath.cs` — **new**. Pure colorkey/ARGB math. Task 1. +- `engine/Age.Engine/Model/BlendKind.cs` — **new**. `enum BlendKind { Opaque, Alpha, Additive }`. Task 2. +- `engine/Age.Engine/Model/GfxState.cs` — **modify**. `RenderObject` blend fields; `GfxObject.HasColor`; resolution in `SnapshotVisibleObjects`. Task 2. +- `engine/Age.Engine/Vm/VirtualMachine.cs` — **modify**. `0x202`/`0x203` set `HasColor`. Task 2. +- `engine/Age.Engine.Tests/BlendMathTests.cs` — **new**. Task 1. +- `engine/Age.Engine.Tests/RenderObjectBlendTests.cs` — **new**. Task 2. +- `godot/Main.cs` — **modify**. Colorkey-baked image cache; `BlitLayer` alpha/tint; surfaceless fill. Tasks 3–4. + +--- + +## 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`: + +```csharp +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`: + +```csharp +namespace Age.Engine.Model; + +/// 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 < 0 = no key; otherwise +/// the operand is 0xRRGGBB and matching (R,G,B) texels are transparent (operand 0 = key black). +public static class BlendMath +{ + /// A colorkey operand >= 0 is an active key; a negative operand means "no colorkey". + public static bool HasColorKey(long colorKey) => colorKey >= 0; + + /// True when (r,g,b) exactly equals the key's 0xRRGGBB bytes. Always false when there is no key. + 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); + } + + /// Split a 0xAARRGGBB packed color (see GfxState.PackColor) into its byte channels. + 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** + +```bash +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 " +``` + +--- + +## 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`: + +```csharp +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`: + +```csharp +namespace Age.Engine.Model; + +/// 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. +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): + +```csharp +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`): + +```csharp +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: + +```csharp + 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): + +```csharp + /// 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). + 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: + +```csharp + 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: + +```csharp + 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])); +``` +```csharp + 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: + +```csharp + 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]))); +``` +```csharp + 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** + +```bash +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 " +``` + +--- + +## 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): + +```csharp + private readonly System.Collections.Generic.Dictionary _imgCache = new(); +``` + +with: + +```csharp + 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: + +```csharp + 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: + +```csharp + 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: + +```csharp + // 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** + +```bash +git add godot/Main.cs +git commit -m "feat(godot): colorkey bake + per-object alpha/tint blit + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## 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: + +```csharp + 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: + +```csharp + 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`: + +```csharp + // 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** + +```bash +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 " +``` + +--- + +## 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** + +```bash +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 " +``` +(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.