diff --git a/docs/engine-re.md b/docs/engine-re.md index 6a492b2..4972075 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -3193,10 +3193,14 @@ black/white scratch alpha to 255. Re-entry finalizes the named endpoint before s Himegari uses only the black pair: three `0x21` calls and eight `0x22` calls, all `(1,30)` and therefore about 480 ms. The scripts first select surface 1, clear it, publish a complete retained frame through `0x20c`, restore the backbuffer, then fade. `0x21` is the menu-entry reveal; `0x22` is the corresponding -menu/scene exit to black. Together they close 11 of the 39 remaining skipped instructions across seven -distinct scripts. Implementation should extend the existing `LegacyScreenTransition` host path with an -explicit solid-color endpoint; treating a missing numbered surface as black is unsafe because the current -mode-4 fallback snapshots the live retained frame. +menu/scene exit to black. + +The port dispatches both opcodes through the existing blocking `LegacyScreenTransition` clock and terminal +publication path. Its compositor already clears the whole frame to opaque black, so an empty source list +models black-to-captured and an empty target list models captured-to-black without overloading the mode-4 +missing-surface fallback (which intentionally snapshots the live retained frame). This closes 11 formerly +skipped instructions across seven distinct scripts and reduces the effectful-gap inventory from 18 to 16 +distinct opcodes / 28 instructions. The same manual pass exposed a resource-addressing issue after `0x6c` was fixed: ROOM did execute `play-voice`, but `GodotAdvHost` attempted only inferred SC-section resolution. ROOM's voice operands diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 2d471f2..f618017 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -658,12 +658,16 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit - **depends on:** 0x20c, 0x20d - **evidence:** Ghidra /v2: dispatch slot 0x21 resolves to op_0x21_fade_surface_in_from_black@0x41cb00. First entry sets run-state bit 8, converts timing <=64 to interval=argument ms and alpha step=16 (otherwise interval=argument/16 and step=1), then starts screen_transition_begin@0x439da0 mode 0 with the operand surface. screen_transition_tick@0x43a7a0 draws that surface below an opaque-black scratch surface whose alpha falls 255->0; re-entry finalizes the operand surface. Corpus: 3 sites in EVOLVE, SELSTAGE, and STUDY, all (1,30), approximately 480 ms. +Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target; timing and terminal-frame publication share the op-0x25 lifecycle. + ### 0x22 `fade-surface-out-to-black` (u00418920, argc 2) - **summary:** (surface_slot)(timing_argument) — block while fading a captured full-frame surface to black. Black remains the terminal frame. - **grounding:** source=investigation, confidence=high - **depends on:** 0x20c, 0x20d - **evidence:** Ghidra /v2: dispatch slot 0x22 resolves to op_0x22_fade_surface_out_to_black@0x41cbc0. It uses the same timer/alpha-step conversion as 0x21 and 0x25, then starts screen_transition_begin@0x439da0 mode 1. screen_transition_tick@0x43a7a0 keeps the operand surface opaque and raises the black scratch-surface alpha 0->255; re-entry finalizes a black endpoint. Corpus: 8 sites across ALCHEMY, CHMENU, EVOLVE, FORT, SELSTAGE, STUDY, and SUMMON, all (1,30), approximately 480 ms. +Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint; timing and terminal-frame publication share the op-0x25 lifecycle. + ### 0x23 `fade-surface-in-from-white` (u004189D0, argc 2) - **summary:** (surface_slot)(timing_argument) — broader-AGE mode-2 sibling of 0x21: block while fading from white to a captured full-frame surface. - **grounding:** source=investigation, confidence=high diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 03f4e68..bd4884c 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -828,9 +828,9 @@ native defaults, category isolation, getter/setter round trips, idempotent route selectors, native and redirected path resolution, CP932 INI preservation, and persistence. All 476 engine tests, the warning-free Godot build, opcode lint, and the Himegari-targeted threaded selftest pass. -**Post-tranche rerank:** a fresh 481-script aggregate confirms exactly 18 effectful gaps totaling only -39 instructions. The widest are unknown `0x22` (eight sites across seven scripts), unknown `0x230` -(six sites across two scripts), then `0x21` and `0x1b2` (three sites each); every other gap has at most two +**Post-tranche rerank:** a fresh 481-script aggregate found exactly 18 effectful gaps totaling only +39 instructions. The widest were `0x22` (eight sites across seven scripts), `0x230` +(six sites across two scripts), then `0x21` and `0x1b2` (three sites each); every other gap had at most two sites. CONFIG itself is now 1659/1665 instructions implemented, with four unrelated calls remaining (`0x142` twice, `0xb7`, and `0xb8`). @@ -841,11 +841,19 @@ timer conversion and full-frame snapshot lifecycle. All 11 combined Himegari sit 480 ms, across menu entry/exit paths. The existing `LegacyScreenTransition` host seam can own the pair, but it needs an explicit black endpoint rather than its mode-4 missing-surface fallback. -The follow-up `0x230(handle)` is also decoded: it clears the retained object's cyclic active flag and five +**Black-fade pair implemented (2026-07-29):** `0x21` and `0x22` now dispatch through the shared blocking +full-frame transition path. The compositor represents the solid-black side explicitly as an empty captured +object list over its normal opaque-black clear: source-empty for black-to-surface and target-empty for +surface-to-black. This preserves mode-4 `0x25`'s separate missing-target fallback, shares the native timing +conversion and exact terminal-frame publication, and closes all 11 sites. The remaining effectful inventory +is 16 distinct opcodes / 28 instructions. All 477 engine tests, opcode build/lint, the zero-warning Godot +build, and the Himegari-targeted threaded selftest pass. + +The follow-up `0x230(handle)` is decoded: it clears the retained object's cyclic active flag and five looping-channel start/period pairs without changing current/base transforms. Its six calls are five DEBUGADV effect demonstrations plus one FIELD movement setup. -**NEXT:** implement `0x21` and `0x22` together as one black-fade slice, then implement `0x230`. +**NEXT:** implement `0x230`. ## Later Phase B breadth diff --git a/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs index db39fd0..170d9c3 100644 --- a/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs +++ b/engine/Age.Engine.Tests/CoreScalarAndScreenTransitionOpsTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Vm; @@ -48,6 +49,31 @@ public class CoreScalarAndScreenTransitionOpsTests Assert.InRange(vm.Globals[0x100], 0, 3); } + [Fact] + public void BlackSurfaceFades_ForwardCapturedSlotTimingAndDirection() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "SURFACE_BLACK_FADES", new List<(int, Operand[])> + { + (0x21, new[] { new Operand(Imm, 1), new Operand(Imm, 30) }), + (0x22, new[] { new Operand(Imm, 2), new Operand(Imm, 65) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal( + new[] + { + (1, 30L, SurfaceBlackFadeDirection.FromBlack), + (2, 65L, SurfaceBlackFadeDirection.ToBlack), + }, + host.SurfaceBlackFades); + Assert.Equal(480, LegacyScreenTransitionTiming.DurationMilliseconds(30)); + Assert.Equal(1024, LegacyScreenTransitionTiming.DurationMilliseconds(65)); + } + [Fact] public void CrossfadeSurfaces_ForwardsBothCapturedSlotsAndTimingArgument() { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 96810ef..d7ffa18 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -53,6 +53,7 @@ internal class RecordingHost : IHost public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new(); public readonly List ClearedRenderTargets = new(); public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); + public readonly List<(int Surface, long Interval, SurfaceBlackFadeDirection Direction)> SurfaceBlackFades = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); public readonly List<(long Resource, int Slot)> Textures = new(); public readonly List MessageSkipChanges = new(); @@ -154,6 +155,9 @@ internal class RecordingHost : IHost gfx.StartForegroundTransitions(100); gfx.CompleteForegroundTransitions(100); } + public void FadeSurfaceWithBlack( + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) + => SurfaceBlackFades.Add((surface, intervalArgument, direction)); public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); public void CreateTexture(int slot, int w, int h) { } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 332bded..f3bc3f9 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -28,6 +28,12 @@ public readonly record struct SurfaceRectCopy( int SourceSurface, int DestinationSurface, int SourceX, int SourceY, int Width, int Height, int DestinationX, int DestinationY); +public enum SurfaceBlackFadeDirection +{ + FromBlack, + ToBlack, +} + public interface IHost { /// Report a recoverable runtime discrepancy while allowing script execution to continue. @@ -103,8 +109,10 @@ public interface IHost // read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately. void WaitForForegroundTransition(GfxState gfx) { } void PresentFrame(GfxState gfx) { } - // Legacy SYS4 screen-transition family (op 0x25): scripts render two complete frames into - // numbered surfaces, then block while the engine alpha-composites target over source. + // Legacy SYS4 screen-transition family (ops 0x21, 0x22, and 0x25): scripts render complete + // frames into numbered surfaces, then block while the engine alpha-composites an endpoint. + void FadeSurfaceWithBlack( + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) { } void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { } void CreateTexture(int slot, int width, int height); /// Return a stable RGBA snapshot of one numbered surface, or null when unavailable. diff --git a/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs b/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs index 9206584..3e20175 100644 --- a/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs +++ b/engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs @@ -1,6 +1,6 @@ namespace Age.Engine.Model; -/// Native timing conversion shared by the legacy op-0x25 screen-transition family. +/// Native timing conversion shared by the legacy full-frame screen-transition family. public static class LegacyScreenTransitionTiming { public static long DurationMilliseconds(long argument) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index c791bd5..a05006e 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2657,6 +2657,16 @@ public sealed class VirtualMachine Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1; case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint _host.PresentFrame(Gfx); return pc + 1; + case "fade-surface-in-from-black": // 0x21: blocking black -> captured full-frame surface + case "u00418860": + _host.FadeSurfaceWithBlack( + Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.FromBlack); + return pc + 1; + case "fade-surface-out-to-black": // 0x22: blocking captured full-frame surface -> black + case "u00418920": + _host.FadeSurfaceWithBlack( + Gfx, (int)Read(a[0]), Read(a[1]), SurfaceBlackFadeDirection.ToBlack); + return pc + 1; case "crossfade-surfaces": // 0x25: legacy full-frame surface alpha transition case "u00418B40": _host.CrossfadeSurfaces(Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2])); diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index d939f46..cac1312 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -800,33 +800,68 @@ public sealed class GodotAdvHost : IHost } } - // Op 0x25's native mode-4 path advances an 8-bit alpha accumulator. Values <=64 use the - // operand as the timer interval and step alpha by sixteen; larger values divide the interval - // by sixteen and step alpha by one. This reproduces the resulting wall-clock duration. + public void FadeSurfaceWithBlack( + GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction) + { + long start = _clock.NowMs; + IReadOnlyList captured; + lock (_screenTransitionLock) + captured = _renderTargetSnapshots.TryGetValue(surface, out var snapshot) + ? snapshot : System.Array.Empty(); + + IReadOnlyList black = System.Array.Empty(); + IReadOnlyList source = + direction == SurfaceBlackFadeDirection.FromBlack ? black : captured; + IReadOnlyList target = + direction == SurfaceBlackFadeDirection.FromBlack ? captured : black; + RunLegacyScreenTransition(source, target, start, intervalArgument, new() + { + ["kind"] = "surface-black-fade", + ["surface"] = surface, + ["direction"] = direction == SurfaceBlackFadeDirection.FromBlack + ? "from-black" : "to-black", + }); + } + public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { IReadOnlyList source; IReadOnlyList target; long start = _clock.NowMs; - long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument); lock (_screenTransitionLock) { source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource) ? capturedSource : System.Array.Empty(); target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget) ? capturedTarget : gfx.SnapshotVisibleObjects(start); - _screenTransition = new LegacyScreenTransition(source, target, start, duration); } + RunLegacyScreenTransition(source, target, start, intervalArgument, new() + { + ["kind"] = "surface-crossfade", + ["source"] = sourceSurface, + ["target"] = targetSurface, + }); + } + + // The native 0x21/0x22/0x25 family advances an 8-bit alpha accumulator. Values <=64 use + // the operand as the timer interval and step alpha by sixteen; larger values divide the + // interval by sixteen and step alpha by one. This reproduces its blocking wall-clock duration. + private void RunLegacyScreenTransition( + IReadOnlyList source, IReadOnlyList target, + long start, long intervalArgument, Dictionary timelineDetail) + { + long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument); + lock (_screenTransitionLock) + _screenTransition = new LegacyScreenTransition(source, target, start, duration); _foregroundGfx = null; System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start); System.Threading.Interlocked.Exchange(ref _presentRequested, 1); IsTransitionWaiting = true; - _timeline?.State("screen-transition-start", new() - { - ["source"] = sourceSurface, ["target"] = targetSurface, - ["interval_argument"] = intervalArgument, ["duration_ms"] = duration, - ["source_objects"] = source.Count, ["target_objects"] = target.Count, - }); + timelineDetail["interval_argument"] = intervalArgument; + timelineDetail["duration_ms"] = duration; + timelineDetail["source_objects"] = source.Count; + timelineDetail["target_objects"] = target.Count; + _timeline?.State("screen-transition-start", timelineDetail); bool scriptSuspended = SuspendScriptForPresentation(); try { diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 2621355..0857959 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -202,6 +202,7 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "fade-surface-in-from-black" category = "draw" +details = "Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. An explicit empty/black source endpoint is composited beneath the captured target; timing and terminal-frame publication share the op-0x25 lifecycle." summary = "(surface_slot)(timing_argument) — block while fading from black to a captured full-frame surface. The captured surface remains the terminal frame." noop_headless = false source = "investigation" @@ -228,6 +229,7 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "fade-surface-out-to-black" category = "draw" +details = "Port status (2026-07-29): implemented through the blocking legacy full-frame transition host path. The captured source is composited beneath an explicit empty/black target endpoint; timing and terminal-frame publication share the op-0x25 lifecycle." summary = "(surface_slot)(timing_argument) — block while fading a captured full-frame surface to black. Black remains the terminal frame." noop_headless = false source = "investigation"