diff --git a/docs/engine-re.md b/docs/engine-re.md index fb1befe..d4746f7 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -792,6 +792,16 @@ The port now carries current and target separately and samples them from the uni 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. +**ADV chrome correction (2026-07-11).** Mode 0 cannot be classified from the final packed color alone. +Static `0x203(mode=0, alpha=0, rgb=white)` remains the established opaque/no-tint CG initializer, but a +mode-0 object whose current/target channel was armed by `0x202` consumes that sampled ARGB as opacity plus +multiplicative RGB modulation, including after target commit. SC0000 proves the distinction with SYSTEM4's +SO001 surface: backing object `0xd2f0` ramps `0x00000000 <-> 0xff000000`, while control-strip object +`0xd2f1` ramps `0x00ffffff <-> 0xffffffff`. Treating alpha as tint strength made the visible controls solid +white and the hidden backing expose SO001's raw white crop. Preserving one-shot provenance makes white an +identity modulation for the yellow controls and alpha zero fully transparent. A matching windowed capture +and user manual check confirmed both endpoints. + ### 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`). diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 9e9dfb2..f76388d 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1239,3 +1239,20 @@ also pass with all of `extracted/` physically moved aside. The real-scene trace the existing test bootstrap still finds root script fixtures through `Paths.Scripts()` under `extracted/`, and changing that unrelated bootstrap was outside this movie slice. Final validation: engine **132/132**, Godot build with zero warnings, threaded `SELFTEST OK`, and opcode-map lint clean. + +### Phase A — SC0000 textbox/control-strip one-shot blend correction DONE (2026-07-11) + +The lower white panel noted after movie publication and the white-outline controls had one shared cause, +not a movie or AGF-alpha fault. SYSTEM4's inherited SO001 surface is cropped by ADV backing object `0xd2f0` +and control-strip object `0xd2f1`; both use a mode-0 `0x202` one-shot color transition. The port discarded +that one-shot provenance after sampling and interpreted the packed alpha as static tint strength. As a +result, full-strength white replaced the controls' yellow pixels, while zero strength exposed the raw +mostly-white backing crop instead of hiding it. + +`GfxState` now distinguishes static mode-0 `0x203` state from mode-0 color state which has passed through +`0x202`. The latter retains ARGB-opacity plus multiplicative-RGB semantics after target commit. This keeps +the established `0x00ffffff` static CG initializer opaque, preserves yellow under white identity modulation, +and makes a committed alpha-zero ADV crop transparent. Focused model/raster tests cover the committed +endpoint, identity modulation, and zero-opacity output. Validation: engine **134/134**, Godot build with +zero warnings, matching windowed capture, and user manual confirmation that the box disappears fully and +the controls retain their normal color while visible. diff --git a/engine/Age.Engine.Tests/OneShotColorTests.cs b/engine/Age.Engine.Tests/OneShotColorTests.cs index 4e0d68f..d9c62f1 100644 --- a/engine/Age.Engine.Tests/OneShotColorTests.cs +++ b/engine/Age.Engine.Tests/OneShotColorTests.cs @@ -35,17 +35,19 @@ public class OneShotColorTests gfx.SetAnimatedObjectColorResolved(0x100, 100, 400, 0xff, 0x90a0b0); var start = gfx.SnapshotVisibleObjects(1000).Single(); - Assert.Equal(0, start.TintStrength); + Assert.Equal(0, start.Alpha); + Assert.True(start.MultiplyTint); Assert.Equal(0.0, start.ColorTransition!.Value.Progress); - Assert.Equal(0, gfx.SnapshotVisibleObjects(1100).Single().TintStrength); + Assert.Equal(0, gfx.SnapshotVisibleObjects(1100).Single().Alpha); var half = gfx.SnapshotVisibleObjects(1300).Single(); - Assert.Equal(0x7f, half.TintStrength); + Assert.Equal(0x7f, half.Alpha); 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(0xff, end.Alpha); + Assert.True(end.MultiplyTint); // committed endpoints keep the op-0x202 blend contract Assert.Equal(0x90a0b0, end.Tint); Assert.False(gfx.TryGet(0x100)!.OneShotColorEnabled); Assert.Equal(-1, gfx.TryGet(0x100)!.OneShotColorTarget); @@ -59,10 +61,10 @@ public class OneShotColorTests gfx.SetStaticObjectColorResolved(0x100, 0, 0xff, 0xffffff); var start = gfx.SnapshotVisibleObjects(2000).Single(); - Assert.Equal(0xff, start.TintStrength); + Assert.Equal(0xff, start.Alpha); Assert.Equal(0xffffff, start.Tint); var half = gfx.SnapshotVisibleObjects(2150).Single(); - Assert.Equal(0x7f, half.TintStrength); + Assert.Equal(0x7f, half.Alpha); Assert.Equal(0xffffff, half.Tint); } @@ -75,7 +77,7 @@ public class OneShotColorTests gfx.SnapshotVisibleObjects(5000); var half = gfx.SnapshotVisibleObjects(5500).Single(); - Assert.Equal(0x7f, half.TintStrength); + Assert.Equal(0x7f, half.Alpha); Assert.Equal(1.5, half.Transform.ScaleX, 3); Assert.Equal(5000, half.ColorTransition!.Value.StartMs); } diff --git a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs index a53c503..62bab7c 100644 --- a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs +++ b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs @@ -77,6 +77,21 @@ public class RenderObjectBlendTests Assert.Equal(BlendKind.Alpha, ro.Blend); } + [Fact] + public void Mode0_OneShotColor_UsesOpacityAndMultiplicativeRgbAfterCommit() + { + var g = WithVisibleObject(0x100, resId: 5, colorKey: -1); + g.SetAnimatedObjectColorResolved(0x100, 0, 100, 0x00, 0xffffff); + g.SetStaticObjectColorResolved(0x100, 0, 0xff, 0xffffff); + + g.SnapshotVisibleObjects(1000); // seed + var hidden = g.SnapshotVisibleObjects(1100).Single(); + Assert.Equal(0, hidden.Alpha); + Assert.Equal(0, hidden.TintStrength); + Assert.True(hidden.MultiplyTint); + Assert.False(g.TryGet(0x100)!.OneShotColorEnabled); + } + [Fact] public void ColorKey_IsCarriedThrough() { diff --git a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs index a9598e8..9f1c4a0 100644 --- a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs +++ b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs @@ -40,4 +40,20 @@ public class SoftwareAffineRasterizerTests Assert.InRange(dst[2], 5, 7); Assert.InRange(dst[3], 126, 127); } + + [Fact] + public void BlitRgba_WhiteModulationPreservesYellowAndZeroOpacityHidesIt() + { + byte[] src = { 240, 192, 16, 255 }; + byte[] visible = new byte[4]; + var identity = new Affine2D(1, 0, 0, 1, 0, 0); + SoftwareAffineRasterizer.BlitRgba(visible, 1, 1, src, 1, 1, 0, 0, 1, 1, + identity, 0xffffff, 0, 1, multiplyTint: true); + Assert.Equal(new byte[] { 240, 192, 16, 255 }, visible); + + byte[] hidden = new byte[4]; + SoftwareAffineRasterizer.BlitRgba(hidden, 1, 1, src, 1, 1, 0, 0, 1, 1, + identity, 0xffffff, 0, 0, multiplyTint: true); + Assert.Equal(new byte[4], hidden); + } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 1e99297..5565630 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -28,9 +28,10 @@ public readonly record struct ColorTransitionState(long Current, long Target, /// (= the engine's z-order) each frame. Built by ; 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"). -/// The packed-color channel is mode-dependent. Mode 0 uses to blend -/// into the texel; mode 1 uses as opacity and multiplies the -/// texel by . selects the latter compositor path. +/// The packed-color channel is mode-dependent. Static mode 0 uses +/// to blend into the texel. A mode-0 color which has passed through op 0x202, and +/// mode 1, use as opacity and multiply the texel by . +/// selects the latter compositor path. 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, @@ -65,6 +66,10 @@ public sealed class GfxState // duration +0x4c. It shares obj+0x34's start timestamp with the one-shot matrix channels. public long OneShotColorTarget = -1, ColorDelayMs, ColorDurationMs; public bool OneShotColorEnabled; + // Once op 0x202 arms this object's color channel, its sampled/current ARGB is consumed as D3D + // opacity + multiplicative modulation even after the target commits. This distinguishes ADV chrome + // fades from a static mode-0 0x203 such as a CG initialized with 0x00ffffff (opaque identity). + public bool OneShotColorBlend; // ---- 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. ---- @@ -152,6 +157,7 @@ public sealed class GfxState Color = s.Color, HasColor = s.HasColor, StaticColorMode = s.StaticColorMode, OneShotColorTarget = s.OneShotColorTarget, ColorDelayMs = s.ColorDelayMs, ColorDurationMs = s.ColorDurationMs, OneShotColorEnabled = s.OneShotColorEnabled, + OneShotColorBlend = s.OneShotColorBlend, 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, @@ -265,6 +271,7 @@ public sealed class GfxState o.ColorDelayMs = delayMs; o.ColorDurationMs = durationMs; o.OneShotColorEnabled = true; + o.OneShotColorBlend = true; o.OneShotStartMs = -1; } } @@ -469,8 +476,10 @@ public sealed class GfxState if (!o.Visible) continue; var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L); - // ---- 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. ---- + // ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has + // armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style + // multiplicative RGB modulation (including after target commit). Mode 1 uses the same blend. + // 0x232 remains the separate ping-pong mode-0 strength/tint channel. ---- int alpha = 255; long tint = 0xFFFFFF; int strength = 0; var blend = BlendKind.Opaque; bool multiplyTint = false; long sampledColor = o.Color; @@ -484,7 +493,7 @@ public sealed class GfxState { var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor); tint = ((long)r << 16) | ((long)g << 8) | (long)b; - if (o.StaticColorMode == 1) + if (o.StaticColorMode == 1 || (o.StaticColorMode == 0 && o.OneShotColorBlend)) { // Native mode 1 enables SRCALPHA/INVSRCALPHA and passes packed ARGB as D3D // modulation. Its high byte is opacity, not mode-0 tint/fill strength. diff --git a/godot/Main.cs b/godot/Main.cs index 1abe25a..0a087fe 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -335,7 +335,9 @@ public partial class Main : Godot.Control if (v.Blend != Age.Engine.Model.BlendKind.Opaque) { int baseW = v.W > 0 ? v.W : 800, baseH = v.H > 0 ? v.H : 600; - float fillA = opacity * strength; + // One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain + // the tint-strength convention used by the existing effect objects. + float fillA = v.MultiplyTint ? opacity : opacity * strength; FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA); 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}) " +