diff --git a/docs/engine-re.md b/docs/engine-re.md index 5916645..20fa245 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9de0ebf..6de5953 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 28b3157..e963cda 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -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 diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 61ebb00..c59f1b5 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -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()); + 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); + } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 9cc48af..ece03fe 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -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); /// The retained handle interval selected by an op-0x222 backbuffer publication. 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 } } + /// Op 0x233: cyclic identity-to-target scale, sampled with a triangular ping-pong phase. + 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; + } + } + /// Op 0x238: set its separate global animation-service duration and reset marker. 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; + } + /// Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the /// common (non-negative-sentinel) case. The alpha<0 / color<0 native-fetch path is deferred. public static long PackColor(long alpha, long color) diff --git a/engine/Age.Engine/Model/Transform2DMath.cs b/engine/Age.Engine/Model/Transform2DMath.cs index 79fbf4f..aa8b23f 100644 --- a/engine/Age.Engine/Model/Transform2DMath.cs +++ b/engine/Age.Engine/Model/Transform2DMath.cs @@ -48,25 +48,29 @@ public readonly record struct Affine2D(double XX, double XY, double YX, double Y } /// 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. +/// 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. 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 diff --git a/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs b/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs index 82926ff..a58a6e1 100644 --- a/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs +++ b/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs @@ -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, diff --git a/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs b/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs index 5242579..5fa148e 100644 --- a/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs +++ b/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs @@ -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); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 3e6c7cb..66053fe 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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)) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index fdb3819..e13f38b 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -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) diff --git a/godot/Main.cs b/godot/Main.cs index 65943e1..8035a71 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 9dd522c..68d509c 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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