From 992215cc48c5e57d2971fc4d52caa36e0a805a24 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 10 Jul 2026 20:49:03 -0400 Subject: [PATCH] Implement native 0x202 color interpolation --- docs/engine-re.md | 21 ++- docs/opcode-reference.md | 8 +- docs/phase-a-slice-plan.md | 41 ++++++ docs/tools-reference.md | 4 +- .../Age.Engine.Tests/GfxCommandBufferTests.cs | 4 +- engine/Age.Engine.Tests/OneShotColorTests.cs | 75 +++++++++++ .../RenderObjectBlendTests.cs | 13 ++ .../SoftwareAffineRasterizerTests.cs | 13 ++ engine/Age.Engine/Model/GfxState.cs | 120 +++++++++++++++--- .../Model/SoftwareAffineRasterizer.cs | 8 +- engine/Age.Engine/Vm/VirtualMachine.cs | 5 +- godot/Main.cs | 23 ++-- vm-map/opcodes.toml | 18 +-- 13 files changed, 300 insertions(+), 53 deletions(-) create mode 100644 engine/Age.Engine.Tests/OneShotColorTests.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index ee893f8..1f23516 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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`). diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 52dc1b9..1c4720c 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 02b5b09..e1c1482 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index ebdce29..26c4850 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -138,7 +138,9 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after - `--speed ` — scale the unified runtime clock (VM cadence, sleeps, and retained animation) without auto-advancing input waits. Values 0.05–8 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback. - `--gfx-log ` — **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 ` — 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 ` — 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`, diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index edf647f..e69fa43 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -73,7 +73,9 @@ public class GfxCommandBufferTests }, System.Array.Empty()); 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] diff --git a/engine/Age.Engine.Tests/OneShotColorTests.cs b/engine/Age.Engine.Tests/OneShotColorTests.cs new file mode 100644 index 0000000..0693391 --- /dev/null +++ b/engine/Age.Engine.Tests/OneShotColorTests.cs @@ -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); + } +} diff --git a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs index 43f56dc..a53c503 100644 --- a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs +++ b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs @@ -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() { diff --git a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs index 268f9d8..a9598e8 100644 --- a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs +++ b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs @@ -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); + } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 5e5a7b3..f738968 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -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); +/// One synchronized sample of op 0x202's native one-shot packed-color channel. +public readonly record struct ColorTransitionState(long Current, long Target, + long DelayMs, long DurationMs, long StartMs, double Progress, bool Active); + /// 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 ; 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"). -/// is the object's OPACITY (0-255). is how -/// strongly (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). +/// 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. 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); /// 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 } } + /// 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. + 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, diff --git a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs index a809aa9..0b20c7a 100644 --- a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs +++ b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs @@ -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); } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 44ec649..247fc76 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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") ---- diff --git a/godot/Main.cs b/godot/Main.cs index 2597aed..520d76d 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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); } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 465dd2a..d8cad0f 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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