Implement legacy black surface fades

This commit is contained in:
gamer147
2026-07-29 10:40:38 -04:00
parent e70b506ec4
commit 2777da435b
10 changed files with 124 additions and 23 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Operand>()),
}, Array.Empty<string>());
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()
{

View File

@@ -53,6 +53,7 @@ internal class RecordingHost : IHost
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
public readonly List<int> 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<bool> 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) { }

View File

@@ -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
{
/// <summary>Report a recoverable runtime discrepancy while allowing script execution to continue.</summary>
@@ -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);
/// <summary>Return a stable RGBA snapshot of one numbered surface, or null when unavailable.</summary>

View File

@@ -1,6 +1,6 @@
namespace Age.Engine.Model;
/// <summary>Native timing conversion shared by the legacy op-0x25 screen-transition family.</summary>
/// <summary>Native timing conversion shared by the legacy full-frame screen-transition family.</summary>
public static class LegacyScreenTransitionTiming
{
public static long DurationMilliseconds(long argument)

View File

@@ -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]));

View File

@@ -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<RenderObject> captured;
lock (_screenTransitionLock)
captured = _renderTargetSnapshots.TryGetValue(surface, out var snapshot)
? snapshot : System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> black = System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> source =
direction == SurfaceBlackFadeDirection.FromBlack ? black : captured;
IReadOnlyList<RenderObject> 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<RenderObject> source;
IReadOnlyList<RenderObject> target;
long start = _clock.NowMs;
long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument);
lock (_screenTransitionLock)
{
source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource)
? capturedSource : System.Array.Empty<RenderObject>();
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<RenderObject> source, IReadOnlyList<RenderObject> target,
long start, long intervalArgument, Dictionary<string, object?> 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
{

View File

@@ -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"