Split GfxState presentation operations

This commit is contained in:
gamer147
2026-08-02 21:57:01 -04:00
parent cd20ae57d6
commit 96a878e6b3
4 changed files with 529 additions and 525 deletions

View File

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

View File

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

View 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)));
}
}
}
}

View File

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