Fix ADV transition and nested input lifecycles

This commit is contained in:
gamer147
2026-07-29 18:16:09 -04:00
parent 09d765d89d
commit da7e0e25ce
13 changed files with 251 additions and 78 deletions

View File

@@ -111,7 +111,8 @@ public class ForegroundTransitionTests
gfx.QueueMovieMaskTransition(new MovieMaskTransitionRequest(
11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000));
Assert.Equal(3, gfx.CompleteClickSkippableTimedPresentation(200));
Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(200, out int completed));
Assert.Equal(3, completed);
Assert.Equal(1.0, gfx.SnapshotForegroundTransitions(200).Single().Progress);
Assert.False(gfx.TryGet(100)!.OneShotColorEnabled);
@@ -123,9 +124,8 @@ public class ForegroundTransitionTests
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void AnimationServiceFlagsSuppressClickCompletion(long flags)
public void AnimationServiceFlagBit0RejectsClickCompletion(long flags)
{
var gfx = new GfxState();
gfx.SetSurface(4, 0x25, -1);
@@ -133,7 +133,35 @@ public class ForegroundTransitionTests
gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff);
gfx.SetAnimationServiceFlags(flags);
Assert.Equal(0, gfx.CompleteClickSkippableTimedPresentation(100));
Assert.False(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed));
Assert.Equal(0, completed);
Assert.True(gfx.TryGet(100)!.OneShotColorEnabled);
}
[Fact]
public void AnimationServiceFlagBit1AcceptsWaitBypassWithoutForcingEndpoint()
{
var gfx = new GfxState();
gfx.SetSurface(4, 0x25, -1);
gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0);
gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff);
gfx.SetAnimationServiceFlags(2);
Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed));
Assert.Equal(0, completed);
Assert.True(gfx.TryGet(100)!.OneShotColorEnabled);
}
[Fact]
public void MovieMaskAcceptsWaitBypassAndContinuesAsynchronously()
{
var gfx = new GfxState();
gfx.QueueMovieMaskTransition(new MovieMaskTransitionRequest(
11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000));
Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed));
Assert.Equal(0, completed);
Assert.True(gfx.HasActiveTimedPresentation(100));
Assert.False(gfx.SnapshotMovieMaskTransitions().Single().Completed);
}
}

View File

@@ -85,7 +85,7 @@ public class InputBindingTests
Sys4ScriptProvider.Load(Table).RequireByName("SYSTEM4.BIN"), vm.InputBindings);
vm.UpdateKeyboardVirtualKeyState(virtualKey, true);
Assert.False(host.PhysicalMessageSkip); // ADV lifecycle is not active yet.
Assert.True(host.PhysicalMessageSkip);
vm.Run();
@@ -98,6 +98,29 @@ public class InputBindingTests
Assert.False(host.MessageSkip);
}
[Fact]
public void HeldLogicalActionSixSurvivesPersistentSkipLifecycleSuspension()
{
var script = ScriptAssembler.Assemble(Table, "HELD_SKIP_LIFECYCLE", new List<(int, Operand[])>
{
(0x19c, Array.Empty<Operand>()),
(0x19b, Array.Empty<Operand>()),
(0x1c7, new[] { G(0x140) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, Table, host);
InputBindingBootstrap.Apply(
Sys4ScriptProvider.Load(Table).RequireByName("SYSTEM4.BIN"), vm.InputBindings);
vm.UpdateKeyboardVirtualKeyState(0x11, true);
vm.Run();
Assert.True(host.PhysicalMessageSkip);
Assert.True(host.MessageSkip);
Assert.Equal(1, vm.Globals.GetValueOrDefault(0x140));
}
[Fact]
public void ReleasingPhysicalFastForwardDoesNotClearPersistentSkip()
{

View File

@@ -6,6 +6,28 @@ using Xunit;
public class WaitForInputTests
{
private sealed class NestedWaitHost : RecordingHost
{
public VirtualMachine Vm = null!;
public bool SawDormantParentCallback;
public bool SawParentCallbackRestored;
public override void WaitForInput(int layoutSlot, System.Func<bool> serviceInputCallback)
{
Waits++;
SawDormantParentCallback =
Vm.RawInputCallbackScriptName == "PARENT"
&& !Vm.IsRawInputCallbackActive;
}
public override void Sleep(long duration)
{
SawParentCallbackRestored =
Vm.RawInputCallbackScriptName == "PARENT"
&& Vm.IsRawInputCallbackActive;
}
}
// wait-for-input (0x72) fires per page. Synthesize a two-page scene and assert it fires exactly
// twice — full handling, no dependency on a real scene's (stubbed) line count.
[Fact]
@@ -28,4 +50,35 @@ public class WaitForInputTests
Assert.Equal(new[] { "page one", "page two" }, vm.Emitted.Select(e => e.Text).ToArray());
Assert.Equal("exit", vm.HaltReason);
}
[Fact]
public void DormantParentRawInputCallbackDoesNotOwnNestedDialogueWait()
{
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var child = ScriptAssembler.Assemble(t, "CHILD", new List<(int, Operand[])>
{
(0x6e, new[] { new Operand(2, 0), new Operand(0, 0) }),
(0x72, new[] { new Operand(0, 0) }),
(0x2, System.Array.Empty<Operand>()),
}, new[] { "nested dialogue" });
var parent = ScriptAssembler.Assemble(t, "PARENT", new List<(int, Operand[])>
{
// FIELD-style timed mouse callback remains registered while call-script enters an ADV scene.
(0xcc, new[] { new Operand(0, 50), new Operand(0, 0xffff_ffff) }),
(0x3, new[] { new Operand(0, 5) }),
(0xc8, new[] { new Operand(0, 0) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var host = new NestedWaitHost();
var vm = new VirtualMachine(parent, t, host, provider: new MapProvider(new() { [5] = child }));
host.Vm = vm;
vm.Run();
Assert.True(host.SawDormantParentCallback);
Assert.True(host.SawParentCallbackRestored);
Assert.Equal(1, host.Waits);
Assert.False(vm.IsRawInputCallbackActive);
Assert.Equal("exit", vm.HaltReason);
}
}

View File

@@ -934,38 +934,41 @@ public sealed class GfxState
(o.RotationEnabled && o.RotationPeriodMs > 0)));
}
/// <summary>Force the finite retained presentation serviced by native run-state bit 0x400 to its
/// endpoint. This is the EffectSkipOnClick path: type-0 surface commands and ordinary finite object
/// channels complete together, while movie masks, ambient cycles, and op-0x242-detached channels remain
/// active. Op 0x24e bit 0 disables click skipping; bit 1 suppresses the shared force-complete request.</summary>
public int CompleteClickSkippableTimedPresentation(long nowMs)
/// <summary>Consume the native run-state-0x400 EffectSkipOnClick action and, when permitted, force its
/// finite retained presentation to the endpoint. Bit 0 of op 0x24e rejects the action entirely. Bit 1
/// still allows the service wait to end but suppresses the shared force-complete request. Movie masks,
/// ambient cycles, and op-0x242-detached channels continue asynchronously after the service resumes.</summary>
public bool TryCompleteClickSkippableTimedPresentation(long nowMs, out int completed)
{
lock (_lock)
{
if ((AnimationServiceFlags & 3) != 0) return 0;
completed = 0;
if ((AnimationServiceFlags & 1) != 0) return false;
int completed = 0;
foreach (var t in _surfaceTransitions.Values)
if ((AnimationServiceFlags & 2) == 0)
{
if (t.Forced || TransitionProgress(t, nowMs) >= 1.0) continue;
t.Forced = true;
completed++;
}
foreach (var t in _surfaceTransitions.Values)
{
if (t.Forced || TransitionProgress(t, nowMs) >= 1.0) continue;
t.Forced = true;
completed++;
}
completed += CountOneShotChannels(_rangeTransform);
foreach (var o in _objects.Values)
{
if ((o.OneShotAnimationControlFlags & 1) != 0) continue;
completed += CountOneShotChannels(o);
}
ForceCompleteOneShotChannels();
completed += CountOneShotChannels(_rangeTransform);
foreach (var o in _objects.Values)
{
if ((o.OneShotAnimationControlFlags & 1) != 0) continue;
completed += CountOneShotChannels(o);
}
ForceCompleteOneShotChannels();
if (AnimClockDurationTicks != 0) completed++;
AnimClockDurationTicks = 0;
AnimClockGeneration++;
if (AnimClockDurationTicks != 0) completed++;
AnimClockDurationTicks = 0;
AnimClockGeneration++;
}
if (completed > 0) MarkRetainedMutation();
return completed;
return true;
}
}

View File

@@ -83,7 +83,6 @@ public sealed class VirtualMachine
private bool _sharedProfileShutdownHandled;
private volatile bool _messageSkipEnabled;
private volatile bool _messageSkipServiceActive;
private volatile bool _advSkipServiceEnabled;
private bool _advReadSkipState;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
private int _messageWindowAlphaSetting;
@@ -138,10 +137,18 @@ public sealed class VirtualMachine
}
}
}
/// <summary>True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input.</summary>
/// <summary>True while the currently executing script frame owns a timed raw mouse/input callback
/// loop (HISTORY/HIDEWIN/FIELD family). A registered callback in a suspended parent frame is dormant
/// while a nested ADV scene owns the VM and must not suppress that child's ordinary page input.</summary>
public bool IsRawInputCallbackActive
{
get { lock (_interactiveLock) return _rawInputFrame != null; }
get
{
ExecFrame? rawInputFrame;
lock (_interactiveLock) rawInputFrame = _rawInputFrame;
if (rawInputFrame == null) return false;
lock (_debugControlLock) return ReferenceEquals(rawInputFrame, _debugActiveFrame);
}
}
public string? RawInputCallbackScriptName
{
@@ -357,13 +364,13 @@ public sealed class VirtualMachine
} while (Interlocked.CompareExchange(ref field, after, before) != before);
}
/// <summary>Mirror adv_interpreter_tick's bit-0x40 path. The bit is logical action 6 from the
/// <summary>Mirror adv_interpreter_tick's live bit-0x40 poll. The bit is logical action 6 from the
/// process-owned binding map, not a hardcoded Ctrl test; Himegari also binds C and retains the
/// engine's default Backspace binding. The presentation lifecycle gate prevents a held action
/// from leaking into non-ADV script execution.</summary>
/// engine's default Backspace binding. Native polls this physical channel independently of the
/// op-0x19b/0x19c lifecycle used by persistent/read-message Skip.</summary>
private void RefreshPhysicalMessageSkipState()
{
bool active = _advSkipServiceEnabled && (InputBindings.PollActionMask() & 0x40) != 0;
bool active = (InputBindings.PollActionMask() & 0x40) != 0;
_host.SetPhysicalMessageSkipActive(active);
}
@@ -790,7 +797,6 @@ public sealed class VirtualMachine
_autoVoicePending = false;
_messageSkipEnabled = false;
_messageSkipServiceActive = false;
_advSkipServiceEnabled = false;
_advReadSkipState = false;
_advTextStyle = AdvTextStyle.Default;
_systemMenuActionsEnabled = 1;
@@ -799,6 +805,7 @@ public sealed class VirtualMachine
_host.SetMessageSkipActive(false);
_host.SetPhysicalMessageSkipActive(false);
_host.ResetSceneContext();
RefreshPhysicalMessageSkipState();
}
private static Stack<int>[] CreateIntegerStacks()
@@ -2276,22 +2283,19 @@ public sealed class VirtualMachine
Write(a[0], _messageSkipEnabled ? 1 : 0); return pc + 1;
case "u00414E80":
case "suspend-adv-skip-service": // 0x19b: preserve the toggle while leaving ADV presentation
_advSkipServiceEnabled = false;
_messageSkipServiceActive = false;
_host.SetMessageSkipActive(false);
_host.SetPhysicalMessageSkipActive(false);
return pc + 1;
case "u00414EC0":
case "resume-adv-skip-service": // 0x19c: recompute active fast-forward on ADV entry
_advSkipServiceEnabled = true;
_messageSkipServiceActive =
_messageSkipEnabled || _advReadSkipState || _host.IsAdvReadSkipActive;
_host.SetMessageSkipActive(_messageSkipServiceActive);
RefreshPhysicalMessageSkipState();
return pc + 1;
case "get-message-skip": // 0x1c7: persistent Skip or host-supplied Ctrl fast-forward
// The native per-op tick continually re-arms the transient run-state bit while the
// ADV service is enabled. The host channel also carries physical fast-forward input.
// Native persistent state and the independently polled physical action-6 channel both
// re-arm the transient run-state bit consumed by this query.
Write(a[0], _messageSkipServiceActive || _host.IsMessageSkipActive ? 1 : 0); return pc + 1;
case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state
case "get-adv-service-state": // compatibility with pre-recovery generated tables