Split GfxState presentation operations
This commit is contained in:
@@ -168,7 +168,9 @@ configuration, and surface lifecycle operations. `engine/Age.Engine/Model/GfxSta
|
|||||||
retained-object record and registry/index, range-transform state, object creation/query/clone/erase and draw
|
retained-object record and registry/index, range-transform state, object creation/query/clone/erase and draw
|
||||||
binding, plus numeric-glyph object generation. `engine/Age.Engine/Model/GfxState.Animation.cs` owns the shared
|
binding, plus numeric-glyph object generation. `engine/Age.Engine/Model/GfxState.Animation.cs` owns the shared
|
||||||
animation clock, object color/source-cell/matrix/cyclic channels, animation control and forced completion, and
|
animation clock, object color/source-cell/matrix/cyclic channels, animation control and forced completion, and
|
||||||
the interpolation helpers consumed by retained-scene sampling.
|
the interpolation helpers consumed by retained-scene sampling. `engine/Age.Engine/Model/GfxState.Presentation.cs`
|
||||||
|
owns surface/movie transition queues, presentation activity and click-skip logic, diagnostics and dirty-reason
|
||||||
|
accounting, and visible retained-scene snapshots.
|
||||||
|
|
||||||
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map
|
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map
|
||||||
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports
|
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports
|
||||||
|
|||||||
@@ -618,6 +618,12 @@ do not mix mechanical moves with semantic changes.
|
|||||||
queues, and visible-scene sampling consume the moved state through the sealed partial class; runtime validation
|
queues, and visible-scene sampling consume the moved state through the sealed partial class; runtime validation
|
||||||
remains green.
|
remains green.
|
||||||
|
|
||||||
|
The fifth bounded `GfxState` split moved surface/movie transition queues, presentation activity and click-skip
|
||||||
|
handling, presentation diagnostics and dirty-reason accounting, and visible retained-scene snapshots into
|
||||||
|
`engine/Age.Engine/Model/GfxState.Presentation.cs`. `GfxState.cs` now retains only cross-domain mutation
|
||||||
|
accounting, scene reset, persistence, and locking. The planned `GfxState` decomposition is complete; runtime
|
||||||
|
validation remains green.
|
||||||
|
|
||||||
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where
|
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where
|
||||||
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
|
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
|
||||||
each domain move.
|
each domain move.
|
||||||
@@ -988,8 +994,8 @@ layer's rendering diverges from ADV; save layout.
|
|||||||
|
|
||||||
## 8. Immediate next step
|
## 8. Immediate next step
|
||||||
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
|
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
|
||||||
by the tracked launcher and layered validation driver. With the planned `Main` and `GodotAdvHost` domains
|
by the tracked launcher and layered validation driver. With the planned `Main`, `GodotAdvHost`, and `GfxState`
|
||||||
isolated and the first four `GfxState` domains separated, move presentation ownership next, preserving public
|
domains isolated, begin thinning `VirtualMachine.Step` through existing domain handler methods without replacing
|
||||||
types, commands, and generated output.
|
the proven dispatcher or changing public types, commands, and generated output.
|
||||||
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does
|
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does
|
||||||
not replace Phase B gameplay validation or the open cross-platform gates.
|
not replace Phase B gameplay validation or the open cross-platform gates.
|
||||||
|
|||||||
517
engine/Age.Engine/Model/GfxState.Presentation.cs
Normal file
517
engine/Age.Engine/Model/GfxState.Presentation.cs
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Age.Engine.Hosting;
|
||||||
|
|
||||||
|
namespace Age.Engine.Model;
|
||||||
|
|
||||||
|
public sealed partial class GfxState
|
||||||
|
{
|
||||||
|
private sealed class SurfaceTransition
|
||||||
|
{
|
||||||
|
public long CommandKey, RangeAStart, RangeBStart, DelayMs, DurationMs;
|
||||||
|
public int TargetSlot, RangeACount, RangeBCount;
|
||||||
|
public long StartMs = -1;
|
||||||
|
public bool Forced;
|
||||||
|
}
|
||||||
|
private sealed class MovieMaskTransition
|
||||||
|
{
|
||||||
|
public required MovieMaskTransitionRequest Request;
|
||||||
|
public bool Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<int, SurfaceTransition> _surfaceTransitions = new();
|
||||||
|
private readonly Dictionary<int, MovieMaskTransition> _movieMaskTransitions = new();
|
||||||
|
|
||||||
|
/// <summary>Op 0x223: queue a type-0 timed alpha transition into a target surface slot.</summary>
|
||||||
|
public void QueueSurfaceAlphaTransition(long commandKey, int targetSlot,
|
||||||
|
long rangeAStart, int rangeACount, long rangeBStart, int rangeBCount,
|
||||||
|
long delayMs, long durationMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_surfaceTransitions[targetSlot] = new SurfaceTransition
|
||||||
|
{
|
||||||
|
CommandKey = commandKey, TargetSlot = targetSlot,
|
||||||
|
RangeAStart = rangeAStart, RangeACount = System.Math.Max(0, rangeACount),
|
||||||
|
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
|
||||||
|
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
|
||||||
|
};
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void QueueMovieMaskTransition(MovieMaskTransitionRequest request)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_movieMaskTransitions[request.SurfaceSlot] = new MovieMaskTransition
|
||||||
|
{
|
||||||
|
Request = request with
|
||||||
|
{
|
||||||
|
SourceRangeCount = System.Math.Max(0, request.SourceRangeCount),
|
||||||
|
Width = System.Math.Max(0, request.Width),
|
||||||
|
Height = System.Math.Max(0, request.Height),
|
||||||
|
StartDelayMs = System.Math.Max(0, request.StartDelayMs),
|
||||||
|
DurationMs = System.Math.Max(0, request.DurationMs),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CompleteMovieMaskTransition(int surfaceSlot)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_movieMaskTransitions.TryGetValue(surfaceSlot, out var transition)
|
||||||
|
|| transition.Completed)
|
||||||
|
return false;
|
||||||
|
transition.Completed = true;
|
||||||
|
MarkRetainedMutation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MovieMaskTransitionState> SnapshotMovieMaskTransitions()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return _movieMaskTransitions.Values
|
||||||
|
.Select(t => new MovieMaskTransitionState(t.Request, t.Completed)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Start every pending foreground transition at the native present boundary.</summary>
|
||||||
|
public int StartForegroundTransitions(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
int started = 0;
|
||||||
|
foreach (var t in _surfaceTransitions.Values)
|
||||||
|
if (t.StartMs < 0) { t.StartMs = nowMs; started++; }
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasActiveForegroundTransitions(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|
||||||
|
|| _movieMaskTransitions.Values.Any(t => !t.Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Native op 0x21c keeps presenting until both queued surface commands and finite one-shot
|
||||||
|
/// object channels have completed. Ambient cyclic/spritesheet/color pulses are deliberately excluded.</summary>
|
||||||
|
public bool HasActiveTimedPresentation(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
||||||
|
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
|
||||||
|
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
||||||
|
_rangeTransform.TranslationEnabled ||
|
||||||
|
_objects.Values.Any(o => o.Visible &&
|
||||||
|
(o.OneShotAnimationControlFlags & 1) == 0 &&
|
||||||
|
(o.OneShotColorEnabled || o.ScaleEnabled ||
|
||||||
|
o.RotationChannelEnabled || o.TranslationEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Observe-only state for a runtime stall capture. It identifies the exact finite channels
|
||||||
|
/// which can keep the host's op-0x21c presentation wait active.</summary>
|
||||||
|
public GfxDiagnosticSnapshot CaptureDiagnosticSnapshot(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
BlockingGfxObjectDiagnostic? range = HasBlockingChannels(_rangeTransform)
|
||||||
|
? DescribeBlockingObject(-1, _rangeTransform)
|
||||||
|
: null;
|
||||||
|
var objects = _objects
|
||||||
|
.Where(pair => pair.Value.Visible
|
||||||
|
&& (pair.Value.OneShotAnimationControlFlags & 1) == 0
|
||||||
|
&& HasBlockingChannels(pair.Value))
|
||||||
|
.OrderBy(pair => pair.Key)
|
||||||
|
.Select(pair => DescribeBlockingObject(pair.Key, pair.Value))
|
||||||
|
.ToArray();
|
||||||
|
return new GfxDiagnosticSnapshot(
|
||||||
|
nowMs,
|
||||||
|
_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|
||||||
|
|| _movieMaskTransitions.Values.Any(t => !t.Completed)
|
||||||
|
|| range != null || objects.Length != 0,
|
||||||
|
_objects.Count,
|
||||||
|
_objects.Values.Count(o => o.Visible),
|
||||||
|
_surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0)
|
||||||
|
+ _movieMaskTransitions.Values.Count(t => !t.Completed),
|
||||||
|
AnimationServiceFlags,
|
||||||
|
AnimClockDurationTicks,
|
||||||
|
AnimClockGeneration,
|
||||||
|
PreviousFrameTimeMilliseconds,
|
||||||
|
CurrentFrameTimeMilliseconds,
|
||||||
|
_rangeTransformFirst,
|
||||||
|
_rangeTransformCount,
|
||||||
|
range,
|
||||||
|
objects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasBlockingChannels(GfxObject o)
|
||||||
|
=> o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled;
|
||||||
|
|
||||||
|
private static BlockingGfxObjectDiagnostic DescribeBlockingObject(long handle, GfxObject o)
|
||||||
|
=> new(handle, o.SourceSlot, o.OneShotStartMs, o.OneShotAnimationControlFlags,
|
||||||
|
o.OneShotColorEnabled, o.ColorDelayMs, o.ColorDurationMs,
|
||||||
|
o.ScaleEnabled, o.ScaleDelayMs, o.ScaleDurationMs,
|
||||||
|
o.RotationChannelEnabled, o.RotationDelayMs, o.RotationDurationMs,
|
||||||
|
o.TranslationEnabled, o.TranslationDelayMs, o.TranslationDurationMs);
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>Whether sampling the retained scene at a later frame can change its pixels without another
|
||||||
|
/// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while
|
||||||
|
/// the interpreter is parked at an input wait. Static waits themselves are deliberately not animation.</summary>
|
||||||
|
public bool HasActiveVisualPresentation(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
||||||
|
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
|
||||||
|
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
||||||
|
_rangeTransform.TranslationEnabled ||
|
||||||
|
_objects.Values.Any(o => o.Visible &&
|
||||||
|
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
||||||
|
o.TranslationEnabled ||
|
||||||
|
(o.SrcAnim && o.SrcPeriod > 0) ||
|
||||||
|
(o.ColorAnim && o.ColorPeriod > 0) ||
|
||||||
|
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
||||||
|
(o.RotationEnabled && o.RotationPeriodMs > 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
completed = 0;
|
||||||
|
if ((AnimationServiceFlags & 1) != 0) return false;
|
||||||
|
|
||||||
|
if ((AnimationServiceFlags & 2) == 0)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (AnimClockDurationTicks != 0) completed++;
|
||||||
|
AnimClockDurationTicks = 0;
|
||||||
|
AnimClockGeneration++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed > 0) MarkRetainedMutation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOneShotChannels(GfxObject o)
|
||||||
|
=> (o.OneShotColorEnabled ? 1 : 0)
|
||||||
|
+ (o.ScaleEnabled ? 1 : 0)
|
||||||
|
+ (o.RotationChannelEnabled ? 1 : 0)
|
||||||
|
+ (o.TranslationEnabled ? 1 : 0);
|
||||||
|
|
||||||
|
/// <summary>Force only queued type-0 foreground transitions. PresentFrame uses this to publish a
|
||||||
|
/// command endpoint immediately; interactive run-state-0x400 skipping uses the broader method above.</summary>
|
||||||
|
public int CompleteForegroundTransitions(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
int completed = 0;
|
||||||
|
foreach (var t in _surfaceTransitions.Values)
|
||||||
|
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
|
||||||
|
if (completed > 0) MarkRetainedMutation();
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SurfaceTransitionState> SnapshotForegroundTransitions(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return _surfaceTransitions.Values.Select(t => SampleTransition(t, nowMs)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double TransitionProgress(SurfaceTransition t, long nowMs)
|
||||||
|
{
|
||||||
|
if (t.Forced) return 1.0;
|
||||||
|
if (t.StartMs < 0) return 0.0;
|
||||||
|
long elapsed = nowMs - t.StartMs - t.DelayMs;
|
||||||
|
if (elapsed <= 0) return 0.0;
|
||||||
|
if (t.DurationMs <= 0) return 1.0;
|
||||||
|
return System.Math.Clamp(elapsed / (double)t.DurationMs, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SurfaceTransitionState SampleTransition(SurfaceTransition t, long nowMs)
|
||||||
|
=> new(t.CommandKey, t.TargetSlot, t.RangeAStart, t.RangeACount, t.RangeBStart, t.RangeBCount,
|
||||||
|
t.DelayMs, t.DurationMs, t.StartMs, TransitionProgress(t, nowMs), t.Forced);
|
||||||
|
|
||||||
|
/// <summary>Sample the shared native frame clock and report why the retained scene needs publishing.
|
||||||
|
/// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared
|
||||||
|
/// previous/current samples select different cells. Retained VM writes are published exactly once.</summary>
|
||||||
|
public GfxPresentationReason ConsumePresentationReasons(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_previousFrameTimeMs = _currentFrameTimeMs;
|
||||||
|
_currentFrameTimeMs = nowMs;
|
||||||
|
PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs);
|
||||||
|
CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs);
|
||||||
|
|
||||||
|
GfxPresentationReason reasons = GfxPresentationReason.None;
|
||||||
|
if (_publishedMutationGeneration != _mutationGeneration)
|
||||||
|
{
|
||||||
|
_publishedMutationGeneration = _mutationGeneration;
|
||||||
|
reasons |= GfxPresentationReason.RetainedMutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
||||||
|
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
||||||
|
_rangeTransform.TranslationEnabled ||
|
||||||
|
_objects.Values.Any(o => o.Visible &&
|
||||||
|
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
||||||
|
o.TranslationEnabled ||
|
||||||
|
(o.ColorAnim && o.ColorPeriod > 0) ||
|
||||||
|
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
||||||
|
(o.RotationEnabled && o.RotationPeriodMs > 0))))
|
||||||
|
reasons |= GfxPresentationReason.ContinuousChannel;
|
||||||
|
|
||||||
|
foreach (var o in _objects.Values)
|
||||||
|
{
|
||||||
|
if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue;
|
||||||
|
if (o.SrcStart < 0)
|
||||||
|
{
|
||||||
|
// Prototype clones made before first publication all enter here in the same shared sample,
|
||||||
|
// reproducing FIELD's native phase lock.
|
||||||
|
o.SrcStart = nowMs;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs))
|
||||||
|
{
|
||||||
|
reasons |= GfxPresentationReason.DiscreteSourceCell;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
||||||
|
/// callers and existing tests.</summary>
|
||||||
|
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
||||||
|
|
||||||
|
/// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved
|
||||||
|
/// and its active channels interpolated at <paramref name="nowMs"/>. Position is the base V24 (a direct
|
||||||
|
/// transform (op 0x22f); scale and translation are independent one-shot matrix channels. Ops
|
||||||
|
/// 0x229-0x22e contribute a second sampled matrix only to their selected handle range. The
|
||||||
|
/// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the
|
||||||
|
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
|
||||||
|
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
|
||||||
|
{
|
||||||
|
var list = new List<RenderObject>();
|
||||||
|
SnapshotVisibleObjects(nowMs, list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing
|
||||||
|
/// array survives across frames; callers that need an independently retained snapshot should use the
|
||||||
|
/// returning overload.</summary>
|
||||||
|
public void SnapshotVisibleObjects(long nowMs, List<RenderObject> list)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(list);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
list.Clear();
|
||||||
|
Affine2D? rangeAffine = null;
|
||||||
|
bool rangeTimeVarying = false;
|
||||||
|
if (_rangeTransformCount > 0)
|
||||||
|
{
|
||||||
|
var r = _rangeTransform;
|
||||||
|
bool hadRangeOneShot = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
||||||
|
if (hadRangeOneShot && r.OneShotStartMs < 0) r.OneShotStartMs = nowMs;
|
||||||
|
var rangeScale = SampleMatrixChannel(ref r.ScaleCurrent, r.ScaleTarget, r.ScaleDelayMs,
|
||||||
|
r.ScaleDurationMs, r.OneShotStartMs, ref r.ScaleEnabled, nowMs);
|
||||||
|
var rangeRotation = SampleRotationChannel(ref r.RotationCurrent, r.RotationTarget,
|
||||||
|
r.RotationDelayMs, r.RotationDurationMs, r.OneShotStartMs,
|
||||||
|
ref r.RotationChannelEnabled, nowMs);
|
||||||
|
var rangeTranslation = SampleMatrixChannel(ref r.TranslationCurrent, r.TranslationTarget,
|
||||||
|
r.TranslationDelayMs, r.TranslationDurationMs, r.OneShotStartMs,
|
||||||
|
ref r.TranslationEnabled, nowMs);
|
||||||
|
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
|
||||||
|
r.OneShotStartMs = -1;
|
||||||
|
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
||||||
|
rangeAffine = Transform2DMath.Build(new TransformState(
|
||||||
|
rangeScale.X, rangeScale.Y, rangeScale.Z,
|
||||||
|
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
|
||||||
|
r.V18.X, r.V18.Y, r.V18.Z,
|
||||||
|
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
|
||||||
|
}
|
||||||
|
foreach (long handle in _orderedObjectHandles)
|
||||||
|
{
|
||||||
|
var o = _objects[handle];
|
||||||
|
if (!o.Visible) continue;
|
||||||
|
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
|
||||||
|
o.RotationChannelEnabled || o.TranslationEnabled;
|
||||||
|
// Created/mutable surfaces have no file resource id but remain a distinct surface class.
|
||||||
|
// Zero is an active "key black" value, so created and absent slots both default to -1.
|
||||||
|
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, -1L);
|
||||||
|
bool createdSurface = _createdSurfaces.Contains(o.SourceSlot);
|
||||||
|
|
||||||
|
// ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has
|
||||||
|
// armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style
|
||||||
|
// multiplicative RGB modulation (including after target commit). Mode 1 uses the same blend.
|
||||||
|
// Op 0x232 modifies this temporary packed color before the unchanged mode consumes it. ----
|
||||||
|
int alpha = 255; long tint = 0xFFFFFF; int strength = 0; var blend = BlendKind.Opaque;
|
||||||
|
bool multiplyTint = false;
|
||||||
|
long sampledColor = o.HasColor ? o.Color : 0xffffffff;
|
||||||
|
ColorTransitionState? colorTransition = null;
|
||||||
|
if (o.OneShotColorEnabled)
|
||||||
|
{
|
||||||
|
if (o.OneShotStartMs < 0) o.OneShotStartMs = nowMs;
|
||||||
|
(sampledColor, colorTransition) = SampleOneShotColor(o, nowMs);
|
||||||
|
}
|
||||||
|
if (o.ColorAnim)
|
||||||
|
{
|
||||||
|
if (o.ColorStart < 0) o.ColorStart = nowMs;
|
||||||
|
sampledColor = InterpolatePackedColor(sampledColor, o.ColorTarget,
|
||||||
|
PingPongWeight(nowMs, o.ColorStart, o.ColorPeriod));
|
||||||
|
}
|
||||||
|
if (o.HasColor || o.ColorAnim)
|
||||||
|
{
|
||||||
|
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
|
||||||
|
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
|
||||||
|
if (o.StaticColorMode == 1)
|
||||||
|
{
|
||||||
|
// Native mode 1 enables SRCALPHA/ONE additive blending and passes packed ARGB as
|
||||||
|
// D3D modulation. Black therefore contributes nothing (TITLE's SO022 flames),
|
||||||
|
// while the high byte scales the additive source contribution.
|
||||||
|
alpha = a; strength = 0; blend = BlendKind.Additive; multiplyTint = true;
|
||||||
|
}
|
||||||
|
else if (o.StaticColorMode == 0 && o.OneShotColorBlend)
|
||||||
|
{
|
||||||
|
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
||||||
|
}
|
||||||
|
else if (o.StaticColorMode == 0 && o.PostBindStaticColorBlend)
|
||||||
|
{
|
||||||
|
// FIELD suppresses a still-bound idle unit while its clone moves; CALLBACK_SETTING
|
||||||
|
// rebuilds the erased ADV backing and writes the configured opacity after binding it.
|
||||||
|
// Pre-bind static alpha-zero CG initialization remains the opaque mode-0 path.
|
||||||
|
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.
|
||||||
|
alpha = a; strength = 0; blend = BlendKind.Alpha;
|
||||||
|
}
|
||||||
|
else if (createdSurface)
|
||||||
|
{
|
||||||
|
// Native created/render-target surfaces use packed alpha as object opacity.
|
||||||
|
// SYSTEM4/BUNKI relies on this for translucent panels; FIELD uses the same surface
|
||||||
|
// at alpha 0x40 beneath the minimap so the paper chrome remains visible.
|
||||||
|
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
||||||
|
}
|
||||||
|
else if (resId != 0)
|
||||||
|
{
|
||||||
|
// Native mode 0 is the opaque textured path. RGB modulates the source and the
|
||||||
|
// packed alpha byte does not become tint strength; in particular 0xffffffff is
|
||||||
|
// identity after the foreground-transition cleanup write.
|
||||||
|
alpha = 255; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A surfaceless mode-0 object is a solid fill, whose high byte remains fill strength.
|
||||||
|
strength = a; blend = BlendKind.Alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- src-rect: preserve cell dimensions and offset it row-major through the sheet ----
|
||||||
|
int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
|
||||||
|
if (o.SrcAnim && o.SrcFrameCount >= 1)
|
||||||
|
{
|
||||||
|
long cell = o.SrcCell;
|
||||||
|
if (o.SrcPeriod > 0)
|
||||||
|
{
|
||||||
|
if (o.SrcStart < 0) o.SrcStart = nowMs;
|
||||||
|
cell = ((nowMs - o.SrcStart) / o.SrcPeriod) % o.SrcFrameCount;
|
||||||
|
}
|
||||||
|
srcX = o.SrcRect.X + (int)(cell % o.SrcColumns) * o.SrcRect.W;
|
||||||
|
srcY = o.SrcRect.Y + (int)(cell / o.SrcColumns) * o.SrcRect.H;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.
|
||||||
|
if (hadOneShot
|
||||||
|
&& o.OneShotStartMs < 0)
|
||||||
|
o.OneShotStartMs = nowMs;
|
||||||
|
var scale = SampleMatrixChannel(ref o.ScaleCurrent, o.ScaleTarget, o.ScaleDelayMs,
|
||||||
|
o.ScaleDurationMs, o.OneShotStartMs, ref o.ScaleEnabled, nowMs);
|
||||||
|
var rotation = SampleRotationChannel(ref o.RotationCurrent, o.RotationTarget,
|
||||||
|
o.RotationDelayMs, o.RotationDurationMs,
|
||||||
|
o.OneShotStartMs, ref o.RotationChannelEnabled, nowMs);
|
||||||
|
var translation = SampleMatrixChannel(ref o.TranslationCurrent, o.TranslationTarget,
|
||||||
|
o.TranslationDelayMs, o.TranslationDurationMs,
|
||||||
|
o.OneShotStartMs, ref o.TranslationEnabled, nowMs);
|
||||||
|
if (!o.OneShotColorEnabled && !o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled)
|
||||||
|
{
|
||||||
|
if (hadOneShot) o.OneShotAnimationControlFlags &= ~1L;
|
||||||
|
o.OneShotStartMs = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
double cycleAngle = 0;
|
||||||
|
double cycleScaleX = 1, cycleScaleY = 1, cycleScaleZ = 1;
|
||||||
|
if (o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0)
|
||||||
|
{
|
||||||
|
if (o.ScaleCycleStartMs < 0) o.ScaleCycleStartMs = nowMs;
|
||||||
|
double weight = ScaleCycleWeight(nowMs, o.ScaleCycleStartMs, o.ScaleCyclePeriodMs);
|
||||||
|
cycleScaleX += (o.ScaleCycleTarget.X - 1) * weight;
|
||||||
|
cycleScaleY += (o.ScaleCycleTarget.Y - 1) * weight;
|
||||||
|
cycleScaleZ += (o.ScaleCycleTarget.Z - 1) * weight;
|
||||||
|
}
|
||||||
|
if (o.RotationEnabled && o.RotationPeriodMs > 0)
|
||||||
|
{
|
||||||
|
if (o.RotationStartMs < 0) o.RotationStartMs = nowMs;
|
||||||
|
long elapsed = System.Math.Max(0, nowMs - o.RotationStartMs);
|
||||||
|
cycleAngle = ((elapsed % o.RotationPeriodMs) * 360) / o.RotationPeriodMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
|
||||||
|
? SampleTransition(st, nowMs) : null;
|
||||||
|
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
|
||||||
|
handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
|
||||||
|
? ra : null;
|
||||||
|
bool timeVarying =
|
||||||
|
o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
||||||
|
o.TranslationEnabled ||
|
||||||
|
(o.SrcAnim && o.SrcPeriod > 0) ||
|
||||||
|
(o.ColorAnim && o.ColorPeriod > 0) ||
|
||||||
|
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
||||||
|
(o.RotationEnabled && o.RotationPeriodMs > 0) ||
|
||||||
|
transition is { Progress: < 1.0 } ||
|
||||||
|
(objectRangeTransform != null && rangeTimeVarying);
|
||||||
|
list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h,
|
||||||
|
(int)o.V24.X, (int)o.V24.Y,
|
||||||
|
new TransformState(scale.X, scale.Y, scale.Z,
|
||||||
|
translation.X, translation.Y, translation.Z,
|
||||||
|
o.V18.X, o.V18.Y, o.V18.Z,
|
||||||
|
rotation.X, rotation.Y, rotation.Z, rotation.Angle),
|
||||||
|
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
|
||||||
|
o.RotationAxis.X, o.RotationAxis.Y,
|
||||||
|
o.RotationAxis.Z, cycleAngle),
|
||||||
|
alpha, tint, strength, blend, multiplyTint, transition,
|
||||||
|
colorTransition, objectRangeTransform, timeVarying,
|
||||||
|
new ScaleCycleState(o.ScaleCycleEnabled, o.ScaleCyclePeriodMs,
|
||||||
|
cycleScaleX, cycleScaleY, cycleScaleZ)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Age.Engine.Hosting;
|
|
||||||
|
|
||||||
namespace Age.Engine.Model;
|
namespace Age.Engine.Model;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
|
/// <summary>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
|
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
|
||||||
/// native retained-gfx owner+0x408 map (EngineCtx+0x46a1c) that op 0x215 queries and the geometry get/set ops share. Each object carries a
|
/// native retained-gfx owner+0x408 map (EngineCtx+0x46a1c) that op 0x215 queries and the geometry get/set ops share. Each object carries a
|
||||||
@@ -12,26 +10,11 @@ namespace Age.Engine.Model;
|
|||||||
/// the query ops read back, which is all the bytecode geometry math needs.</summary>
|
/// the query ops read back, which is all the bytecode geometry math needs.</summary>
|
||||||
public sealed partial class GfxState
|
public sealed partial class GfxState
|
||||||
{
|
{
|
||||||
private sealed class SurfaceTransition
|
|
||||||
{
|
|
||||||
public long CommandKey, RangeAStart, RangeBStart, DelayMs, DurationMs;
|
|
||||||
public int TargetSlot, RangeACount, RangeBCount;
|
|
||||||
public long StartMs = -1;
|
|
||||||
public bool Forced;
|
|
||||||
}
|
|
||||||
private sealed class MovieMaskTransition
|
|
||||||
{
|
|
||||||
public required MovieMaskTransitionRequest Request;
|
|
||||||
public bool Completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private long _mutationGeneration;
|
private long _mutationGeneration;
|
||||||
private long _publishedMutationGeneration;
|
private long _publishedMutationGeneration;
|
||||||
|
|
||||||
private void MarkRetainedMutation() => _mutationGeneration++;
|
private void MarkRetainedMutation() => _mutationGeneration++;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Native scene_context_init_reset ownership boundary used by opcode 0x9: discard
|
/// <summary>Native scene_context_init_reset ownership boundary used by opcode 0x9: discard
|
||||||
/// retained objects, command/query state, surfaces, transitions, render-target selection, and the
|
/// retained objects, command/query state, surfaces, transitions, render-target selection, and the
|
||||||
/// scene animation clock while leaving VM globals and decoded host assets outside this model.</summary>
|
/// scene animation clock while leaving VM globals and decoded host assets outside this model.</summary>
|
||||||
@@ -120,509 +103,5 @@ public sealed partial class GfxState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
private readonly Dictionary<int, SurfaceTransition> _surfaceTransitions = new();
|
|
||||||
private readonly Dictionary<int, MovieMaskTransition> _movieMaskTransitions = new();
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Op 0x223: queue a type-0 timed alpha transition into a target surface slot.</summary>
|
|
||||||
public void QueueSurfaceAlphaTransition(long commandKey, int targetSlot,
|
|
||||||
long rangeAStart, int rangeACount, long rangeBStart, int rangeBCount,
|
|
||||||
long delayMs, long durationMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_surfaceTransitions[targetSlot] = new SurfaceTransition
|
|
||||||
{
|
|
||||||
CommandKey = commandKey, TargetSlot = targetSlot,
|
|
||||||
RangeAStart = rangeAStart, RangeACount = System.Math.Max(0, rangeACount),
|
|
||||||
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
|
|
||||||
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
|
|
||||||
};
|
|
||||||
MarkRetainedMutation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void QueueMovieMaskTransition(MovieMaskTransitionRequest request)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_movieMaskTransitions[request.SurfaceSlot] = new MovieMaskTransition
|
|
||||||
{
|
|
||||||
Request = request with
|
|
||||||
{
|
|
||||||
SourceRangeCount = System.Math.Max(0, request.SourceRangeCount),
|
|
||||||
Width = System.Math.Max(0, request.Width),
|
|
||||||
Height = System.Math.Max(0, request.Height),
|
|
||||||
StartDelayMs = System.Math.Max(0, request.StartDelayMs),
|
|
||||||
DurationMs = System.Math.Max(0, request.DurationMs),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
MarkRetainedMutation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CompleteMovieMaskTransition(int surfaceSlot)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_movieMaskTransitions.TryGetValue(surfaceSlot, out var transition)
|
|
||||||
|| transition.Completed)
|
|
||||||
return false;
|
|
||||||
transition.Completed = true;
|
|
||||||
MarkRetainedMutation();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<MovieMaskTransitionState> SnapshotMovieMaskTransitions()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _movieMaskTransitions.Values
|
|
||||||
.Select(t => new MovieMaskTransitionState(t.Request, t.Completed)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Start every pending foreground transition at the native present boundary.</summary>
|
|
||||||
public int StartForegroundTransitions(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
int started = 0;
|
|
||||||
foreach (var t in _surfaceTransitions.Values)
|
|
||||||
if (t.StartMs < 0) { t.StartMs = nowMs; started++; }
|
|
||||||
return started;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasActiveForegroundTransitions(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|
|
||||||
|| _movieMaskTransitions.Values.Any(t => !t.Completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Native op 0x21c keeps presenting until both queued surface commands and finite one-shot
|
|
||||||
/// object channels have completed. Ambient cyclic/spritesheet/color pulses are deliberately excluded.</summary>
|
|
||||||
public bool HasActiveTimedPresentation(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
|
||||||
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
|
|
||||||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
|
||||||
_rangeTransform.TranslationEnabled ||
|
|
||||||
_objects.Values.Any(o => o.Visible &&
|
|
||||||
(o.OneShotAnimationControlFlags & 1) == 0 &&
|
|
||||||
(o.OneShotColorEnabled || o.ScaleEnabled ||
|
|
||||||
o.RotationChannelEnabled || o.TranslationEnabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Observe-only state for a runtime stall capture. It identifies the exact finite channels
|
|
||||||
/// which can keep the host's op-0x21c presentation wait active.</summary>
|
|
||||||
public GfxDiagnosticSnapshot CaptureDiagnosticSnapshot(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
BlockingGfxObjectDiagnostic? range = HasBlockingChannels(_rangeTransform)
|
|
||||||
? DescribeBlockingObject(-1, _rangeTransform)
|
|
||||||
: null;
|
|
||||||
var objects = _objects
|
|
||||||
.Where(pair => pair.Value.Visible
|
|
||||||
&& (pair.Value.OneShotAnimationControlFlags & 1) == 0
|
|
||||||
&& HasBlockingChannels(pair.Value))
|
|
||||||
.OrderBy(pair => pair.Key)
|
|
||||||
.Select(pair => DescribeBlockingObject(pair.Key, pair.Value))
|
|
||||||
.ToArray();
|
|
||||||
return new GfxDiagnosticSnapshot(
|
|
||||||
nowMs,
|
|
||||||
_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|
|
||||||
|| _movieMaskTransitions.Values.Any(t => !t.Completed)
|
|
||||||
|| range != null || objects.Length != 0,
|
|
||||||
_objects.Count,
|
|
||||||
_objects.Values.Count(o => o.Visible),
|
|
||||||
_surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0)
|
|
||||||
+ _movieMaskTransitions.Values.Count(t => !t.Completed),
|
|
||||||
AnimationServiceFlags,
|
|
||||||
AnimClockDurationTicks,
|
|
||||||
AnimClockGeneration,
|
|
||||||
PreviousFrameTimeMilliseconds,
|
|
||||||
CurrentFrameTimeMilliseconds,
|
|
||||||
_rangeTransformFirst,
|
|
||||||
_rangeTransformCount,
|
|
||||||
range,
|
|
||||||
objects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasBlockingChannels(GfxObject o)
|
|
||||||
=> o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled;
|
|
||||||
|
|
||||||
private static BlockingGfxObjectDiagnostic DescribeBlockingObject(long handle, GfxObject o)
|
|
||||||
=> new(handle, o.SourceSlot, o.OneShotStartMs, o.OneShotAnimationControlFlags,
|
|
||||||
o.OneShotColorEnabled, o.ColorDelayMs, o.ColorDurationMs,
|
|
||||||
o.ScaleEnabled, o.ScaleDelayMs, o.ScaleDurationMs,
|
|
||||||
o.RotationChannelEnabled, o.RotationDelayMs, o.RotationDurationMs,
|
|
||||||
o.TranslationEnabled, o.TranslationDelayMs, o.TranslationDurationMs);
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Whether sampling the retained scene at a later frame can change its pixels without another
|
|
||||||
/// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while
|
|
||||||
/// the interpreter is parked at an input wait. Static waits themselves are deliberately not animation.</summary>
|
|
||||||
public bool HasActiveVisualPresentation(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
|
||||||
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
|
|
||||||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
|
||||||
_rangeTransform.TranslationEnabled ||
|
|
||||||
_objects.Values.Any(o => o.Visible &&
|
|
||||||
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
|
||||||
o.TranslationEnabled ||
|
|
||||||
(o.SrcAnim && o.SrcPeriod > 0) ||
|
|
||||||
(o.ColorAnim && o.ColorPeriod > 0) ||
|
|
||||||
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
|
||||||
(o.RotationEnabled && o.RotationPeriodMs > 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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)
|
|
||||||
{
|
|
||||||
completed = 0;
|
|
||||||
if ((AnimationServiceFlags & 1) != 0) return false;
|
|
||||||
|
|
||||||
if ((AnimationServiceFlags & 2) == 0)
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (AnimClockDurationTicks != 0) completed++;
|
|
||||||
AnimClockDurationTicks = 0;
|
|
||||||
AnimClockGeneration++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completed > 0) MarkRetainedMutation();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountOneShotChannels(GfxObject o)
|
|
||||||
=> (o.OneShotColorEnabled ? 1 : 0)
|
|
||||||
+ (o.ScaleEnabled ? 1 : 0)
|
|
||||||
+ (o.RotationChannelEnabled ? 1 : 0)
|
|
||||||
+ (o.TranslationEnabled ? 1 : 0);
|
|
||||||
|
|
||||||
/// <summary>Force only queued type-0 foreground transitions. PresentFrame uses this to publish a
|
|
||||||
/// command endpoint immediately; interactive run-state-0x400 skipping uses the broader method above.</summary>
|
|
||||||
public int CompleteForegroundTransitions(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
int completed = 0;
|
|
||||||
foreach (var t in _surfaceTransitions.Values)
|
|
||||||
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
|
|
||||||
if (completed > 0) MarkRetainedMutation();
|
|
||||||
return completed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<SurfaceTransitionState> SnapshotForegroundTransitions(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _surfaceTransitions.Values.Select(t => SampleTransition(t, nowMs)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double TransitionProgress(SurfaceTransition t, long nowMs)
|
|
||||||
{
|
|
||||||
if (t.Forced) return 1.0;
|
|
||||||
if (t.StartMs < 0) return 0.0;
|
|
||||||
long elapsed = nowMs - t.StartMs - t.DelayMs;
|
|
||||||
if (elapsed <= 0) return 0.0;
|
|
||||||
if (t.DurationMs <= 0) return 1.0;
|
|
||||||
return System.Math.Clamp(elapsed / (double)t.DurationMs, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SurfaceTransitionState SampleTransition(SurfaceTransition t, long nowMs)
|
|
||||||
=> new(t.CommandKey, t.TargetSlot, t.RangeAStart, t.RangeACount, t.RangeBStart, t.RangeBCount,
|
|
||||||
t.DelayMs, t.DurationMs, t.StartMs, TransitionProgress(t, nowMs), t.Forced);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Sample the shared native frame clock and report why the retained scene needs publishing.
|
|
||||||
/// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared
|
|
||||||
/// previous/current samples select different cells. Retained VM writes are published exactly once.</summary>
|
|
||||||
public GfxPresentationReason ConsumePresentationReasons(long nowMs)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_previousFrameTimeMs = _currentFrameTimeMs;
|
|
||||||
_currentFrameTimeMs = nowMs;
|
|
||||||
PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs);
|
|
||||||
CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs);
|
|
||||||
|
|
||||||
GfxPresentationReason reasons = GfxPresentationReason.None;
|
|
||||||
if (_publishedMutationGeneration != _mutationGeneration)
|
|
||||||
{
|
|
||||||
_publishedMutationGeneration = _mutationGeneration;
|
|
||||||
reasons |= GfxPresentationReason.RetainedMutation;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
|
||||||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
|
||||||
_rangeTransform.TranslationEnabled ||
|
|
||||||
_objects.Values.Any(o => o.Visible &&
|
|
||||||
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
|
||||||
o.TranslationEnabled ||
|
|
||||||
(o.ColorAnim && o.ColorPeriod > 0) ||
|
|
||||||
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
|
||||||
(o.RotationEnabled && o.RotationPeriodMs > 0))))
|
|
||||||
reasons |= GfxPresentationReason.ContinuousChannel;
|
|
||||||
|
|
||||||
foreach (var o in _objects.Values)
|
|
||||||
{
|
|
||||||
if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue;
|
|
||||||
if (o.SrcStart < 0)
|
|
||||||
{
|
|
||||||
// Prototype clones made before first publication all enter here in the same shared sample,
|
|
||||||
// reproducing FIELD's native phase lock.
|
|
||||||
o.SrcStart = nowMs;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs))
|
|
||||||
{
|
|
||||||
reasons |= GfxPresentationReason.DiscreteSourceCell;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return reasons;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
|
||||||
/// callers and existing tests.</summary>
|
|
||||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
|
||||||
|
|
||||||
/// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved
|
|
||||||
/// and its active channels interpolated at <paramref name="nowMs"/>. Position is the base V24 (a direct
|
|
||||||
/// transform (op 0x22f); scale and translation are independent one-shot matrix channels. Ops
|
|
||||||
/// 0x229-0x22e contribute a second sampled matrix only to their selected handle range. The
|
|
||||||
/// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the
|
|
||||||
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
|
|
||||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
|
|
||||||
{
|
|
||||||
var list = new List<RenderObject>();
|
|
||||||
SnapshotVisibleObjects(nowMs, list);
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing
|
|
||||||
/// array survives across frames; callers that need an independently retained snapshot should use the
|
|
||||||
/// returning overload.</summary>
|
|
||||||
public void SnapshotVisibleObjects(long nowMs, List<RenderObject> list)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(list);
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
list.Clear();
|
|
||||||
Affine2D? rangeAffine = null;
|
|
||||||
bool rangeTimeVarying = false;
|
|
||||||
if (_rangeTransformCount > 0)
|
|
||||||
{
|
|
||||||
var r = _rangeTransform;
|
|
||||||
bool hadRangeOneShot = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
|
||||||
if (hadRangeOneShot && r.OneShotStartMs < 0) r.OneShotStartMs = nowMs;
|
|
||||||
var rangeScale = SampleMatrixChannel(ref r.ScaleCurrent, r.ScaleTarget, r.ScaleDelayMs,
|
|
||||||
r.ScaleDurationMs, r.OneShotStartMs, ref r.ScaleEnabled, nowMs);
|
|
||||||
var rangeRotation = SampleRotationChannel(ref r.RotationCurrent, r.RotationTarget,
|
|
||||||
r.RotationDelayMs, r.RotationDurationMs, r.OneShotStartMs,
|
|
||||||
ref r.RotationChannelEnabled, nowMs);
|
|
||||||
var rangeTranslation = SampleMatrixChannel(ref r.TranslationCurrent, r.TranslationTarget,
|
|
||||||
r.TranslationDelayMs, r.TranslationDurationMs, r.OneShotStartMs,
|
|
||||||
ref r.TranslationEnabled, nowMs);
|
|
||||||
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
|
|
||||||
r.OneShotStartMs = -1;
|
|
||||||
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
|
||||||
rangeAffine = Transform2DMath.Build(new TransformState(
|
|
||||||
rangeScale.X, rangeScale.Y, rangeScale.Z,
|
|
||||||
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
|
|
||||||
r.V18.X, r.V18.Y, r.V18.Z,
|
|
||||||
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
|
|
||||||
}
|
|
||||||
foreach (long handle in _orderedObjectHandles)
|
|
||||||
{
|
|
||||||
var o = _objects[handle];
|
|
||||||
if (!o.Visible) continue;
|
|
||||||
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
|
|
||||||
o.RotationChannelEnabled || o.TranslationEnabled;
|
|
||||||
// Created/mutable surfaces have no file resource id but remain a distinct surface class.
|
|
||||||
// Zero is an active "key black" value, so created and absent slots both default to -1.
|
|
||||||
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, -1L);
|
|
||||||
bool createdSurface = _createdSurfaces.Contains(o.SourceSlot);
|
|
||||||
|
|
||||||
// ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has
|
|
||||||
// armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style
|
|
||||||
// multiplicative RGB modulation (including after target commit). Mode 1 uses the same blend.
|
|
||||||
// Op 0x232 modifies this temporary packed color before the unchanged mode consumes it. ----
|
|
||||||
int alpha = 255; long tint = 0xFFFFFF; int strength = 0; var blend = BlendKind.Opaque;
|
|
||||||
bool multiplyTint = false;
|
|
||||||
long sampledColor = o.HasColor ? o.Color : 0xffffffff;
|
|
||||||
ColorTransitionState? colorTransition = null;
|
|
||||||
if (o.OneShotColorEnabled)
|
|
||||||
{
|
|
||||||
if (o.OneShotStartMs < 0) o.OneShotStartMs = nowMs;
|
|
||||||
(sampledColor, colorTransition) = SampleOneShotColor(o, nowMs);
|
|
||||||
}
|
|
||||||
if (o.ColorAnim)
|
|
||||||
{
|
|
||||||
if (o.ColorStart < 0) o.ColorStart = nowMs;
|
|
||||||
sampledColor = InterpolatePackedColor(sampledColor, o.ColorTarget,
|
|
||||||
PingPongWeight(nowMs, o.ColorStart, o.ColorPeriod));
|
|
||||||
}
|
|
||||||
if (o.HasColor || o.ColorAnim)
|
|
||||||
{
|
|
||||||
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
|
|
||||||
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
|
|
||||||
if (o.StaticColorMode == 1)
|
|
||||||
{
|
|
||||||
// Native mode 1 enables SRCALPHA/ONE additive blending and passes packed ARGB as
|
|
||||||
// D3D modulation. Black therefore contributes nothing (TITLE's SO022 flames),
|
|
||||||
// while the high byte scales the additive source contribution.
|
|
||||||
alpha = a; strength = 0; blend = BlendKind.Additive; multiplyTint = true;
|
|
||||||
}
|
|
||||||
else if (o.StaticColorMode == 0 && o.OneShotColorBlend)
|
|
||||||
{
|
|
||||||
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
|
||||||
}
|
|
||||||
else if (o.StaticColorMode == 0 && o.PostBindStaticColorBlend)
|
|
||||||
{
|
|
||||||
// FIELD suppresses a still-bound idle unit while its clone moves; CALLBACK_SETTING
|
|
||||||
// rebuilds the erased ADV backing and writes the configured opacity after binding it.
|
|
||||||
// Pre-bind static alpha-zero CG initialization remains the opaque mode-0 path.
|
|
||||||
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.
|
|
||||||
alpha = a; strength = 0; blend = BlendKind.Alpha;
|
|
||||||
}
|
|
||||||
else if (createdSurface)
|
|
||||||
{
|
|
||||||
// Native created/render-target surfaces use packed alpha as object opacity.
|
|
||||||
// SYSTEM4/BUNKI relies on this for translucent panels; FIELD uses the same surface
|
|
||||||
// at alpha 0x40 beneath the minimap so the paper chrome remains visible.
|
|
||||||
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
|
||||||
}
|
|
||||||
else if (resId != 0)
|
|
||||||
{
|
|
||||||
// Native mode 0 is the opaque textured path. RGB modulates the source and the
|
|
||||||
// packed alpha byte does not become tint strength; in particular 0xffffffff is
|
|
||||||
// identity after the foreground-transition cleanup write.
|
|
||||||
alpha = 255; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// A surfaceless mode-0 object is a solid fill, whose high byte remains fill strength.
|
|
||||||
strength = a; blend = BlendKind.Alpha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ---- src-rect: preserve cell dimensions and offset it row-major through the sheet ----
|
|
||||||
int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
|
|
||||||
if (o.SrcAnim && o.SrcFrameCount >= 1)
|
|
||||||
{
|
|
||||||
long cell = o.SrcCell;
|
|
||||||
if (o.SrcPeriod > 0)
|
|
||||||
{
|
|
||||||
if (o.SrcStart < 0) o.SrcStart = nowMs;
|
|
||||||
cell = ((nowMs - o.SrcStart) / o.SrcPeriod) % o.SrcFrameCount;
|
|
||||||
}
|
|
||||||
srcX = o.SrcRect.X + (int)(cell % o.SrcColumns) * o.SrcRect.W;
|
|
||||||
srcY = o.SrcRect.Y + (int)(cell / o.SrcColumns) * o.SrcRect.H;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.
|
|
||||||
if (hadOneShot
|
|
||||||
&& o.OneShotStartMs < 0)
|
|
||||||
o.OneShotStartMs = nowMs;
|
|
||||||
var scale = SampleMatrixChannel(ref o.ScaleCurrent, o.ScaleTarget, o.ScaleDelayMs,
|
|
||||||
o.ScaleDurationMs, o.OneShotStartMs, ref o.ScaleEnabled, nowMs);
|
|
||||||
var rotation = SampleRotationChannel(ref o.RotationCurrent, o.RotationTarget,
|
|
||||||
o.RotationDelayMs, o.RotationDurationMs,
|
|
||||||
o.OneShotStartMs, ref o.RotationChannelEnabled, nowMs);
|
|
||||||
var translation = SampleMatrixChannel(ref o.TranslationCurrent, o.TranslationTarget,
|
|
||||||
o.TranslationDelayMs, o.TranslationDurationMs,
|
|
||||||
o.OneShotStartMs, ref o.TranslationEnabled, nowMs);
|
|
||||||
if (!o.OneShotColorEnabled && !o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled)
|
|
||||||
{
|
|
||||||
if (hadOneShot) o.OneShotAnimationControlFlags &= ~1L;
|
|
||||||
o.OneShotStartMs = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
double cycleAngle = 0;
|
|
||||||
double cycleScaleX = 1, cycleScaleY = 1, cycleScaleZ = 1;
|
|
||||||
if (o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0)
|
|
||||||
{
|
|
||||||
if (o.ScaleCycleStartMs < 0) o.ScaleCycleStartMs = nowMs;
|
|
||||||
double weight = ScaleCycleWeight(nowMs, o.ScaleCycleStartMs, o.ScaleCyclePeriodMs);
|
|
||||||
cycleScaleX += (o.ScaleCycleTarget.X - 1) * weight;
|
|
||||||
cycleScaleY += (o.ScaleCycleTarget.Y - 1) * weight;
|
|
||||||
cycleScaleZ += (o.ScaleCycleTarget.Z - 1) * weight;
|
|
||||||
}
|
|
||||||
if (o.RotationEnabled && o.RotationPeriodMs > 0)
|
|
||||||
{
|
|
||||||
if (o.RotationStartMs < 0) o.RotationStartMs = nowMs;
|
|
||||||
long elapsed = System.Math.Max(0, nowMs - o.RotationStartMs);
|
|
||||||
cycleAngle = ((elapsed % o.RotationPeriodMs) * 360) / o.RotationPeriodMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
|
|
||||||
? SampleTransition(st, nowMs) : null;
|
|
||||||
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
|
|
||||||
handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
|
|
||||||
? ra : null;
|
|
||||||
bool timeVarying =
|
|
||||||
o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
|
||||||
o.TranslationEnabled ||
|
|
||||||
(o.SrcAnim && o.SrcPeriod > 0) ||
|
|
||||||
(o.ColorAnim && o.ColorPeriod > 0) ||
|
|
||||||
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
|
|
||||||
(o.RotationEnabled && o.RotationPeriodMs > 0) ||
|
|
||||||
transition is { Progress: < 1.0 } ||
|
|
||||||
(objectRangeTransform != null && rangeTimeVarying);
|
|
||||||
list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h,
|
|
||||||
(int)o.V24.X, (int)o.V24.Y,
|
|
||||||
new TransformState(scale.X, scale.Y, scale.Z,
|
|
||||||
translation.X, translation.Y, translation.Z,
|
|
||||||
o.V18.X, o.V18.Y, o.V18.Z,
|
|
||||||
rotation.X, rotation.Y, rotation.Z, rotation.Angle),
|
|
||||||
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
|
|
||||||
o.RotationAxis.X, o.RotationAxis.Y,
|
|
||||||
o.RotationAxis.Z, cycleAngle),
|
|
||||||
alpha, tint, strength, blend, multiplyTint, transition,
|
|
||||||
colorTransition, objectRangeTransform, timeVarying,
|
|
||||||
new ScaleCycleState(o.ScaleCycleEnabled, o.ScaleCyclePeriodMs,
|
|
||||||
cycleScaleX, cycleScaleY, cycleScaleZ)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user