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 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 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 `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 menu/scene exit to black.
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 The port dispatches both opcodes through the existing blocking `LegacyScreenTransition` clock and terminal
mode-4 fallback snapshots the live retained frame. 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 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 `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 - **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. - **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) ### 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. - **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 - **grounding:** source=investigation, confidence=high
- **depends on:** 0x20c, 0x20d - **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. - **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) ### 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. - **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 - **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 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. 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 **Post-tranche rerank:** a fresh 481-script aggregate found exactly 18 effectful gaps totaling only
39 instructions. The widest are unknown `0x22` (eight sites across seven scripts), unknown `0x230` 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 has at most two (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 sites. CONFIG itself is now 1659/1665 instructions implemented, with four unrelated calls remaining
(`0x142` twice, `0xb7`, and `0xb8`). (`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, 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. 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 looping-channel start/period pairs without changing current/base transforms. Its six calls are five
DEBUGADV effect demonstrations plus one FIELD movement setup. 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 ## Later Phase B breadth

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
@@ -48,6 +49,31 @@ public class CoreScalarAndScreenTransitionOpsTests
Assert.InRange(vm.Globals[0x100], 0, 3); 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] [Fact]
public void CrossfadeSurfaces_ForwardsBothCapturedSlotsAndTimingArgument() 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<(long Resource, int Surface, long Flags)> ModalMovies = new();
public readonly List<int> ClearedRenderTargets = new(); public readonly List<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = 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<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
public readonly List<(long Resource, int Slot)> Textures = new(); public readonly List<(long Resource, int Slot)> Textures = new();
public readonly List<bool> MessageSkipChanges = new(); public readonly List<bool> MessageSkipChanges = new();
@@ -154,6 +155,9 @@ internal class RecordingHost : IHost
gfx.StartForegroundTransitions(100); gfx.StartForegroundTransitions(100);
gfx.CompleteForegroundTransitions(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) public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument)
=> SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument)); => SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument));
public void CreateTexture(int slot, int w, int h) { } 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 SourceSurface, int DestinationSurface, int SourceX, int SourceY,
int Width, int Height, int DestinationX, int DestinationY); int Width, int Height, int DestinationX, int DestinationY);
public enum SurfaceBlackFadeDirection
{
FromBlack,
ToBlack,
}
public interface IHost public interface IHost
{ {
/// <summary>Report a recoverable runtime discrepancy while allowing script execution to continue.</summary> /// <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. // read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately.
void WaitForForegroundTransition(GfxState gfx) { } void WaitForForegroundTransition(GfxState gfx) { }
void PresentFrame(GfxState gfx) { } void PresentFrame(GfxState gfx) { }
// Legacy SYS4 screen-transition family (op 0x25): scripts render two complete frames into // Legacy SYS4 screen-transition family (ops 0x21, 0x22, and 0x25): scripts render complete
// numbered surfaces, then block while the engine alpha-composites target over source. // 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 CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { }
void CreateTexture(int slot, int width, int height); void CreateTexture(int slot, int width, int height);
/// <summary>Return a stable RGBA snapshot of one numbered surface, or null when unavailable.</summary> /// <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; 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 class LegacyScreenTransitionTiming
{ {
public static long DurationMilliseconds(long argument) 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; 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 case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint
_host.PresentFrame(Gfx); return pc + 1; _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 "crossfade-surfaces": // 0x25: legacy full-frame surface alpha transition
case "u00418B40": case "u00418B40":
_host.CrossfadeSurfaces(Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2])); _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 public void FadeSurfaceWithBlack(
// operand as the timer interval and step alpha by sixteen; larger values divide the interval GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction)
// by sixteen and step alpha by one. This reproduces the resulting wall-clock duration. {
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) public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument)
{ {
IReadOnlyList<RenderObject> source; IReadOnlyList<RenderObject> source;
IReadOnlyList<RenderObject> target; IReadOnlyList<RenderObject> target;
long start = _clock.NowMs; long start = _clock.NowMs;
long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument);
lock (_screenTransitionLock) lock (_screenTransitionLock)
{ {
source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource) source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource)
? capturedSource : System.Array.Empty<RenderObject>(); ? capturedSource : System.Array.Empty<RenderObject>();
target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget) target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget)
? capturedTarget : gfx.SnapshotVisibleObjects(start); ? 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; _foregroundGfx = null;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start); System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1); System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
IsTransitionWaiting = true; IsTransitionWaiting = true;
_timeline?.State("screen-transition-start", new() timelineDetail["interval_argument"] = intervalArgument;
{ timelineDetail["duration_ms"] = duration;
["source"] = sourceSurface, ["target"] = targetSurface, timelineDetail["source_objects"] = source.Count;
["interval_argument"] = intervalArgument, ["duration_ms"] = duration, timelineDetail["target_objects"] = target.Count;
["source_objects"] = source.Count, ["target_objects"] = target.Count, _timeline?.State("screen-transition-start", timelineDetail);
});
bool scriptSuspended = SuspendScriptForPresentation(); bool scriptSuspended = SuspendScriptForPresentation();
try try
{ {

View File

@@ -202,6 +202,7 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "fade-surface-in-from-black" name = "fade-surface-in-from-black"
category = "draw" 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." 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 noop_headless = false
source = "investigation" source = "investigation"
@@ -228,6 +229,7 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "fade-surface-out-to-black" name = "fade-surface-out-to-black"
category = "draw" 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." summary = "(surface_slot)(timing_argument) — block while fading a captured full-frame surface to black. Black remains the terminal frame."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"