Implement cyclic scale opcode

This commit is contained in:
gamer147
2026-07-29 00:06:56 -04:00
parent f03c9481bf
commit 9b3fc89b72
12 changed files with 179 additions and 22 deletions

View File

@@ -1348,8 +1348,11 @@ stores period `obj+0x224`, builds the target scale matrix at `obj+0x250`, and ra
matrix to the target. The first half-cycle therefore grows/shrinks identity to target and the second returns
target to identity. This matrix lives in the separately anchored cyclic-animation product, independent of
the immediate/current and delayed one-shot scale matrices. Himegari has 31 sites in nine scripts: nineteen
in DEBUGADV and twelve across eight ordinary ADV scenes. The port still skips the opcode; its existing
cyclic-channel sampling and affine compositor make this the smallest high-reach next implementation.
in DEBUGADV and twelve across eight ordinary ADV scenes. The port now models this as its own retained
channel, samples the exact triangular phase on the shared frame clock, and composes
`T(-anchor) * one-shot-scale * one-shot-rotation * translation * cyclic-scale * cyclic-rotation *
T(anchor)`. It remains frame-driven while visible and survives native-style object cloning independently
of the immediate and delayed one-shot scale fields.
**Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with
affine rasterization. `0x223` is **not affine**: `gfx_queue_surface_alpha_transition` (`0x47f440`) inserts

View File

@@ -916,6 +916,8 @@ Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same nat
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x233_set_scale_cycle@0x423cf0 divides operands 3..5 by 100 and calls gfx_object_set_scale_cycle@0x47efd0. The worker stores start=0 at obj+0x210, period at +0x224, and the target scale matrix at +0x250. gfx_object_anim_interpolate@0x473ed0 samples a triangular phase 2*min(elapsed%period,period-elapsed%period)/period, linearly blends identity to target, and multiplies the result into the separately anchored cyclic-animation product. Corpus: 31 sites in nine scripts, including eight ordinary ADV scenes.
Port status (2026-07-28): implemented as an independent retained-object scale cycle. The compositor samples the exact triangular phase, keeps the channel frame-driven during waits, and composes it after one-shot translation but before the sibling cyclic rotation.
### 0x234 `anim-start` (anim-start, argc 5)
- **summary:** (handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure cyclic rotation. Worker stores period obj+0x228, start obj+0x214=0, and float axis obj+0x244; each frame uses integer degrees floor(((now-start)%period)*360/period). gfx_object_composite right-multiplies this separately anchored transform after the one-shot scale/rotation/translation product, so cyclic rotation also rotates the translation vector.
- **grounding:** source=investigation, confidence=high

View File

@@ -805,13 +805,16 @@ fallthrough inventory from 25 to 23 distinct opcodes. Focused regressions distin
starts and prove immediate rotation does not arm the delayed `0x21f` channel. The opcode registry and
generated references are current, and the native `/v2` image is annotated and saved.
**Next-opcode reconnaissance:** `0x233` is the recommended next implementation. Native proves a
self-contained cyclic scale channel: identity→target over the first half-period and target→identity over
the second. Its 31 calls cover eight ordinary ADV scenes plus DEBUGADV, and the retained model already has
the sibling color/rotation cycles and affine sampling machinery. It therefore has broader runtime reach and
lower ownership risk than the next alternative.
**Cyclic scale `0x233` implemented (2026-07-28).** The retained model now owns the native channel
independently of immediate and delayed one-shot scale. It samples the exact triangular
identity-to-target-to-identity phase, remains frame-driven during visible waits, survives object cloning,
and enters the separately anchored cyclic product after one-shot translation and before cyclic rotation.
The VM handler closes all 31 skipped sites across DEBUGADV and eight ordinary ADV scenes, reducing the
effectful fallthrough inventory from 23 to 22 distinct opcodes. Focused tests cover dispatch, percentages,
odd/even period timing, continuous-presentation classification, and matrix order; the full engine suite,
warning-free Godot build, and Himegari-targeted threaded selftest pass (468 engine tests).
After `0x233`, implement CONFIG's mixer ABI as one four-opcode tranche rather than piecemeal:
**NEXT:** implement CONFIG's mixer ABI as one four-opcode tranche rather than piecemeal:
`0xc5` gets master/music/SFX/voice/movie volume, `0xc6` sets and applies it, `0x1ba` enables/disables the
four non-master routes, and `0xc7` queries route state. Those 37 CONFIG sites require shared persistent
settings plus live Godot-bus application; implementing only a setter or getter would leave the menu

View File

@@ -129,6 +129,11 @@ public class GfxAnimationTests
rotation.SetRotationCycle(7, 1000, (0, 0, 1));
Assert.True(rotation.HasActiveVisualPresentation(1000));
Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying);
var scale = VisibleObject();
scale.SetScaleCycle(7, 1000, (150, 75, 100));
Assert.True(scale.HasActiveVisualPresentation(1000));
Assert.True(scale.SnapshotVisibleObjects(1000).Single().TimeVarying);
}
[Fact]
@@ -367,6 +372,27 @@ public class GfxAnimationTests
Assert.Equal(1, vm.Gfx.AnimClockGeneration);
}
[Fact]
public void Op0x233_ConfiguresAnIndependentCyclicScaleChannel()
{
var t = T();
var scene = ScriptAssembler.Assemble(t, "SCALE_CYCLE", new List<(int, Operand[])>
{
MovGI(1, 0x1000), MovGI(2, 1200), MovGI(3, 80), MovGI(4, 120), MovGI(5, 100),
(0x233, new[] { G(1), G(2), G(3), G(4), G(5) }),
Exit(),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
vm.Run();
var o = vm.Gfx.TryGet(0x1000)!;
Assert.Equal(1200, o.ScaleCyclePeriodMs);
Assert.Equal((0.8, 1.2, 1.0), o.ScaleCycleTarget);
Assert.True(o.ScaleCycleEnabled);
Assert.Equal((1.0, 1.0, 1.0), o.ScaleCurrent);
}
[Fact]
public void CurrentTranslationSetter_ReplacesTheLiveMatrixImmediately()
{
@@ -456,6 +482,50 @@ public class GfxAnimationTests
Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque
}
[Fact]
public void SnapshotSamplesScaleCycleWithNativeTriangularPhase()
{
var g = new GfxState();
g.SetSurface(1, 5, -1);
g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0);
g.SetScaleCycle(7, 1200, (200, 50, 100));
var start = g.SnapshotVisibleObjects(1000).Single().ScaleCycle;
var quarter = g.SnapshotVisibleObjects(1300).Single().ScaleCycle;
var midpoint = g.SnapshotVisibleObjects(1600).Single().ScaleCycle;
var threeQuarter = g.SnapshotVisibleObjects(1900).Single().ScaleCycle;
var wrapped = g.SnapshotVisibleObjects(2200).Single().ScaleCycle;
Assert.Equal((1.0, 1.0, 1.0), (start.ScaleX, start.ScaleY, start.ScaleZ));
Assert.Equal((1.5, 0.75, 1.0), (quarter.ScaleX, quarter.ScaleY, quarter.ScaleZ));
Assert.Equal((2.0, 0.5, 1.0), (midpoint.ScaleX, midpoint.ScaleY, midpoint.ScaleZ));
Assert.Equal((1.5, 0.75, 1.0), (threeQuarter.ScaleX, threeQuarter.ScaleY, threeQuarter.ScaleZ));
Assert.Equal((1.0, 1.0, 1.0), (wrapped.ScaleX, wrapped.ScaleY, wrapped.ScaleZ));
g.SetScaleCycle(7, 5, (200, 100, 100));
_ = g.SnapshotVisibleObjects(3000);
var oddPeriod = g.SnapshotVisibleObjects(3002).Single().ScaleCycle;
Assert.Equal(1.8, oddPeriod.ScaleX, 10);
}
[Fact]
public void ScaleCycleClonePreservesTargetAndSharedPhase()
{
var g = new GfxState();
g.SetSurface(1, 5, -1);
g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0);
g.SetScaleCycle(7, 1000, (160, 80, 100));
_ = g.SnapshotVisibleObjects(2000);
Assert.True(g.CloneObject(7, 8));
var samples = g.SnapshotVisibleObjects(2250);
var source = samples.Single(x => x.Handle == 7).ScaleCycle;
var clone = samples.Single(x => x.Handle == 8).ScaleCycle;
Assert.Equal(source, clone);
Assert.Equal((1.3, 0.9, 1.0), (clone.ScaleX, clone.ScaleY, clone.ScaleZ));
}
[Fact]
public void Transform2D_UsesNativeAnchoredRowVectorOrder_AndDirectProjection()
{
@@ -491,4 +561,17 @@ public class GfxAnimationTests
Assert.Equal(100.0, p.X, 10);
Assert.Equal(100.0, p.Y, 10);
}
[Fact]
public void Transform2D_CyclicScaleOccursAfterTranslationAndBeforeCyclicRotation()
{
var t = new TransformState(2, 1, 1, 10, 0, 0, 100, 50, 0);
var scale = new ScaleCycleState(true, 1000, 3, 1, 1);
var rotation = new RotationCycleState(true, 1000, 0, 0, 1, 90);
var p = Transform2DMath.Apply(120, 50, t, rotation, scale);
Assert.Equal(100.0, p.X, 10);
Assert.Equal(200.0, p.Y, 10);
}
}

View File

@@ -14,6 +14,9 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
double AxisX, double AxisY, double AxisZ,
double AngleDegrees = 0);
public readonly record struct ScaleCycleState(bool Enabled, long PeriodMs,
double ScaleX, double ScaleY, double ScaleZ);
[System.Flags]
public enum GfxPresentationReason
{
@@ -82,7 +85,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null,
Affine2D? RangeTransform = null,
bool TimeVarying = false);
bool TimeVarying = false,
ScaleCycleState ScaleCycle = default);
/// <summary>The retained handle interval selected by an op-0x222 backbuffer publication.</summary>
public readonly record struct GfxHandleRange(long First, long Count)
@@ -172,6 +176,12 @@ public sealed class GfxState
public (long X, long Y, long Z) RotationAxis;
public bool RotationEnabled;
public long RotationStartMs = -1;
// Op 0x233 is a separate cyclic scale channel (period obj+0x224, target matrix obj+0x250).
public long ScaleCyclePeriodMs;
public (double X, double Y, double Z) ScaleCycleTarget = (1, 1, 1);
public bool ScaleCycleEnabled;
public long ScaleCycleStartMs = -1;
}
// ---- geometry/draw object store (V18/V24/draw bind, the compositor's input) ----
@@ -487,6 +497,8 @@ public sealed class GfxState
OneShotStartMs = s.OneShotStartMs,
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
ScaleCyclePeriodMs = s.ScaleCyclePeriodMs, ScaleCycleTarget = s.ScaleCycleTarget,
ScaleCycleEnabled = s.ScaleCycleEnabled, ScaleCycleStartMs = s.ScaleCycleStartMs,
};
private readonly object _lock = new();
@@ -851,6 +863,7 @@ public sealed class GfxState
o.TranslationEnabled ||
(o.SrcAnim && o.SrcPeriod > 0) ||
(o.ColorAnim && o.ColorPeriod > 0) ||
(o.ScaleCycleEnabled && o.ScaleCyclePeriodMs > 0) ||
(o.RotationEnabled && o.RotationPeriodMs > 0)));
}
@@ -1071,6 +1084,19 @@ public sealed class GfxState
}
}
/// <summary>Op 0x233: cyclic identity-to-target scale, sampled with a triangular ping-pong phase.</summary>
public void SetScaleCycle(long handle, long periodMs, (long X, long Y, long Z) percent)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.ScaleCyclePeriodMs = periodMs;
o.ScaleCycleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
o.ScaleCycleEnabled = periodMs > 0;
o.ScaleCycleStartMs = -1;
}
}
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
public void SetAnimClock(long durationTicks)
{
@@ -1138,6 +1164,7 @@ public sealed class GfxState
(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;
@@ -1328,6 +1355,15 @@ public sealed class GfxState
}
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;
@@ -1345,6 +1381,7 @@ public sealed class GfxState
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);
@@ -1358,7 +1395,9 @@ public sealed class GfxState
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, multiplyTint, transition,
colorTransition, objectRangeTransform, timeVarying));
colorTransition, objectRangeTransform, timeVarying,
new ScaleCycleState(o.ScaleCycleEnabled, o.ScaleCyclePeriodMs,
cycleScaleX, cycleScaleY, cycleScaleZ)));
}
}
}
@@ -1458,6 +1497,14 @@ public sealed class GfxState
return (double)BlendMath.PingPong(now, start, period) / half;
}
private static double ScaleCycleWeight(long now, long start, long period)
{
if (period <= 0) return 0;
long elapsed = System.Math.Max(0, now - start);
long position = elapsed % period;
return 2.0 * System.Math.Min(position, period - position) / period;
}
/// <summary>Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the
/// common (non-negative-sentinel) case. The alpha&lt;0 / color&lt;0 native-fetch path is deferred.</summary>
public static long PackColor(long alpha, long color)

View File

@@ -48,25 +48,29 @@ public readonly record struct Affine2D(double XX, double XY, double YX, double Y
}
/// <summary>Exact 2D projection of AGE's row-vector retained-object matrix. Native call order is anchored
/// scale, one-shot axis-angle rotation, translation, then separately anchored cyclic rotation. The adjacent
/// anchor translations cancel, yielding T(-a)*S*R1*T*Rcycle*T(+a). Z is projected away only afterward.</summary>
/// one-shot scale/axis-angle rotation/translation, then separately anchored cyclic scale and cyclic rotation.
/// The adjacent anchor translations cancel, yielding T(-a)*S*R1*T*Scycle*Rcycle*T(+a). Z is projected away
/// only afterward.</summary>
public static class Transform2DMath
{
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
public static Affine2D Build(TransformState t, RotationCycleState cycle = default,
ScaleCycleState scaleCycle = default)
{
Matrix3D m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (scaleCycle.Enabled) m = Mul(m, Scale(scaleCycle.ScaleX, scaleCycle.ScaleY, scaleCycle.ScaleZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m.M11, m.M12, m.M21, m.M22, m.TX, m.TY);
}
public static (double X, double Y) Apply(double x, double y, TransformState transform,
RotationCycleState cycle = default)
=> Build(transform, cycle).Apply(x, y);
RotationCycleState cycle = default,
ScaleCycleState scaleCycle = default)
=> Build(transform, cycle, scaleCycle).Apply(x, y);
// AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only
// the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated

View File

@@ -73,7 +73,7 @@ internal static class NativeGfxPersistenceCodec
|| value.RotationChannelEnabled || value.TranslationEnabled
? flags | 2
: flags & ~2;
flags = value.RotationEnabled || value.SrcAnim || value.ColorAnim
flags = value.ScaleCycleEnabled || value.RotationEnabled || value.SrcAnim || value.ColorAnim
? flags | 4
: flags & ~4;
WriteInt(raw, 0, flags);
@@ -111,8 +111,10 @@ internal static class NativeGfxPersistenceCodec
WriteFloat(raw, 0x204, value.RotationCurrent.Angle);
WriteFloat(raw, 0x208, value.RotationTarget.Angle);
WriteInt(raw, 0x20c, EncodeNativeStart(value.ColorStart));
WriteInt(raw, 0x210, EncodeNativeStart(value.ScaleCycleStartMs));
WriteInt(raw, 0x214, EncodeNativeStart(value.RotationStartMs));
WriteInt(raw, 0x220, unchecked((int)value.ColorPeriod));
WriteInt(raw, 0x224, unchecked((int)value.ScaleCyclePeriodMs));
WriteInt(raw, 0x228, unchecked((int)value.RotationPeriodMs));
WriteInt(raw, 0x230, unchecked((int)value.SrcPeriod));
WriteInt(raw, 0x234, unchecked((int)value.SrcCell));
@@ -121,6 +123,7 @@ internal static class NativeGfxPersistenceCodec
if (value.ColorAnim)
WriteInt(raw, 0x240, unchecked((int)value.ColorTarget));
WriteVector(raw, 0x244, value.RotationAxis);
WriteScaleMatrix(raw, 0x250, value.ScaleCycleTarget);
WriteInt(raw, 0x2d0, unchecked((int)value.OneShotAnimationControlFlags));
return raw;
}
@@ -167,8 +170,10 @@ internal static class NativeGfxPersistenceCodec
ReadFloat(raw, 0x1f8), ReadFloat(raw, 0x1fc), ReadFloat(raw, 0x200),
ReadFloat(raw, 0x208)),
ColorStart = DecodeNativeStart(ReadInt(raw, 0x20c)),
ScaleCycleStartMs = DecodeNativeStart(ReadInt(raw, 0x210)),
RotationStartMs = DecodeNativeStart(ReadInt(raw, 0x214)),
ColorPeriod = ReadInt(raw, 0x220),
ScaleCyclePeriodMs = ReadInt(raw, 0x224),
RotationPeriodMs = ReadInt(raw, 0x228),
SrcPeriod = ReadInt(raw, 0x230),
SrcCell = ReadInt(raw, 0x234),
@@ -176,7 +181,9 @@ internal static class NativeGfxPersistenceCodec
SrcColumns = Math.Max(1, ReadInt(raw, 0x23c)),
ColorTarget = unchecked((uint)ReadInt(raw, 0x240)),
RotationAxis = ReadLongVector(raw, 0x244),
ScaleCycleTarget = ReadScale(raw, 0x250),
OneShotAnimationControlFlags = unchecked((uint)ReadInt(raw, 0x2d0)),
ScaleCycleEnabled = (flags & 4) != 0 && ReadInt(raw, 0x224) > 0,
RotationEnabled = (flags & 4) != 0 && ReadInt(raw, 0x228) > 0,
SrcAnim = (flags & 4) != 0 && ReadInt(raw, 0x238) > 1,
ColorAnim = (flags & 4) != 0 && ReadInt(raw, 0x220) > 0,

View File

@@ -28,7 +28,8 @@ public static class RetainedSurfaceRasterizer
TransformState transform = item.Transform;
Affine2D localToDestination =
Transform2DMath.Build(transform, item.Rotation).FromLocalOrigin(item.DstX, item.DstY);
Transform2DMath.Build(transform, item.Rotation, item.ScaleCycle)
.FromLocalOrigin(item.DstX, item.DstY);
if (item.RangeTransform is { } rangeTransform)
localToDestination = localToDestination.Then(rangeTransform);

View File

@@ -2517,6 +2517,9 @@ public sealed class VirtualMachine
Gfx.SetSrcRect(Read(a[0]), Read(a[2]), Read(a[3]), 0, Read(a[1])); return pc + 1;
case "u00421EF0": // 0x232 cyclic packed ARGB; negative alpha/RGB preserve static obj color
Gfx.SetColorAnimResolved(Read(a[0]), Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
case "set-scale-cycle": // 0x233 (handle)(period ms)(target scale x/y/z percent)
Gfx.SetScaleCycle(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
return pc + 1;
case "u00421940": // 0x228: (succ)(handle)(outX)(outY)(outZ) <- target translation matrix
{
if (Gfx.TryQueryTranslationTarget(Read(a[1]), out var v))

View File

@@ -1438,7 +1438,8 @@ public sealed class GodotAdvHost : IHost
if (source == null) continue;
Affine2D localToTarget =
Transform2DMath.Build(item.Transform, item.Rotation).FromLocalOrigin(item.DstX, item.DstY);
Transform2DMath.Build(item.Transform, item.Rotation, item.ScaleCycle)
.FromLocalOrigin(item.DstX, item.DstY);
if (item.RangeTransform is { } rangeTransform)
localToTarget = localToTarget.Then(rangeTransform);
foreach (SurfaceTextDraw draw in source)

View File

@@ -1009,7 +1009,8 @@ public partial class Main : Godot.Control
foreach (var v in _visibleSnapshot)
{
_perf?.RecordObject(v.TimeVarying);
var affine = Transform2DMath.Build(v.Transform, v.Rotation).FromLocalOrigin(v.DstX, v.DstY);
var affine = Transform2DMath.Build(v.Transform, v.Rotation, v.ScaleCycle)
.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = v.Alpha / 255f;
var rawObject = _vm.Gfx.TryGet(v.Handle);
@@ -1102,7 +1103,7 @@ public partial class Main : Godot.Control
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation)
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
@@ -1226,7 +1227,7 @@ public partial class Main : Godot.Control
{
_perf?.RecordObject(v.TimeVarying);
var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation, v.ScaleCycle);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform)
localToDest = localToDest.Then(rangeTransform);
@@ -1602,7 +1603,8 @@ public partial class Main : Godot.Control
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform)
affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;

View File

@@ -6169,6 +6169,7 @@ source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra /v2: op_0x233_set_scale_cycle@0x423cf0 divides operands 3..5 by 100 and calls gfx_object_set_scale_cycle@0x47efd0. The worker stores start=0 at obj+0x210, period at +0x224, and the target scale matrix at +0x250. gfx_object_anim_interpolate@0x473ed0 samples a triangular phase 2*min(elapsed%period,period-elapsed%period)/period, linearly blends identity to target, and multiplies the result into the separately anchored cyclic-animation product. Corpus: 31 sites in nine scripts, including eight ordinary ADV scenes."
details = "Port status (2026-07-28): implemented as an independent retained-object scale cycle. The compositor samples the exact triangular phase, keeps the channel frame-driven during waits, and composes it after one-shot translation but before the sibling cyclic rotation."
[[opcode.semantics.args]]
i = 1