diff --git a/docs/engine-re.md b/docs/engine-re.md index ac2ef45..243e0a1 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2330,9 +2330,11 @@ non-overlapping name/dialogue bands at y=447–468, 478–500, and 507–530. A #### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11) The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN` -loads universal raw asset `0x337c` (`SO000.AGF`) into surface slot 12. SO000 is a 390x27 strip of thirteen -30x27 bat frames. It then executes op `0x73` with +loads universal raw asset `0x337c` (`SO000.AGF`) into surface slot 12. SO000 decodes to a 360x27 strip of +twelve 30x27 bat frames. It then executes op `0x73` with `(layout=1, x=385, y=140, surface=12, src=0,0, cell=30x27, terminalFrame=12, period=48ms)`. +The terminal value is exclusive/a frame count: native publishes frames `0..11` and wraps before frame +`12`. Layout 1 begins at screen y=430, so the marker lands at `(385,570)`, matching the original screenshot. Native `op_0x73_configure_wait_indicator@0x41e900` writes this descriptor through @@ -2345,10 +2347,12 @@ the following click releases the wait. **Port implementation:** the single-scene harness now injects SYSTEM4's exact SO000 surface-12 and op-`0x73` configuration alongside its existing SO001 shortcut. The VM forwards all ten operands through the host ABI; `GodotAdvHost` retains configurations by layout and resolves slot 0 to the active Phase-A layout. A separate -Godot atlas overlay advances the inclusive frames `0..12` on the 48 ms host clock only while `IsWaiting`, -then hides immediately when input releases the wait. Keeping this 30x27 overlay outside the software -backbuffer preserves the static-wait compositor optimization. Full SYSTEM4 replay remains Phase-B work; -the shortcut carries the same state in the meantime. +Godot atlas overlay advances on the 48 ms host clock only while `IsWaiting`, then hides immediately when +input releases the wait. A 2026-07-27 polish fix removed the port's erroneous `TerminalFrame + 1` +interpretation, which had selected nonexistent frame `12` once per cycle and made Godot render one empty +atlas cell. Frame selection now treats the terminal value as the exclusive frame count and loops `0..11`. +Keeping this 30x27 overlay outside the software backbuffer preserves the static-wait compositor optimization. +Full SYSTEM4 replay remains Phase-B work; the shortcut carries the same state in the meantime. ### SC0000 native SFX / BGM-fade family — `0xb4`/`0xb5`/`0xb6`/`0xc2`/`0xd9` (2026-07-11) @@ -2997,6 +3001,28 @@ embedded-NUL termination, all observed comparison operand classes, and the exact case. The full 281-test engine suite, zero-warning Godot build, and threaded frontend selftest pass; manual DEBUGMAP and developer-menu visual rechecks remain. +#### BUNKI popup publication and BUNKIMOVE lifecycle (2026-07-27) + +BUNKI's opening/closing effect is an offscreen capture, not an independent decoration behind a stable +popup. BUNKI builds the complete panel under handles `60000..60099`, including surface-200 text, and calls +`BUNKIMOVE.BIN`. BUNKIMOVE selects 800x600 surface 2, publishes that retained range into it, restores the +backbuffer, binds the capture at handle `59999`, and scales it horizontally and then vertically through +timed callbacks. Each transition frame publishes `[0,60000)`, deliberately including the animated capture +while excluding BUNKI's stable objects. On open, BUNKI publishes the stable menu only after BUNKIMOVE +returns; on close, it removes the stable range after the reverse animation. + +The port previously treated a backbuffer `0x222` as a generic repaint request and reconstructed every +visible retained object. It therefore drew handles `60000+` at full size above handle `59999`, making the +real popup appear immediately while its opening animation ran behind it; closing had the symmetric error. +Godot now retains zero-based backbuffer publication ranges and filters reconstruction to the active range. +Nonzero ranges remain incremental overlays over a full reconstruction because the port does not preserve +native D3D backbuffer pixels between publications. + +Surface text also remains modeled metadata rather than pixels. Selected-target publication now projects +that metadata into the destination surface, and the final Godot labels inherit the retained object's +projected scale and rotation. Consequently BUNKIMOVE's captured labels follow the same transform as its +panel instead of escaping as full-size Control overlays. + ### Formatted integers on text surfaces — opcode `0x205` (2026-07-21) The deployment picker opened from an empty DEBUGMAP deployment slot uses a third numeric path. Its red diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 54462d8..1e1cb30 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -29,7 +29,7 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que ### 0x73 `configure-adv-wait-indicator` (configure-adv-wait-indicator, argc 10) - **summary:** (layout_slot)(dst_x)(dst_y)(surface_slot)(src_x)(src_y)(cell_w)(cell_h)(terminal_frame)(frame_period_ms) - configure the animated marker shown while the selected ADV layout waits for input. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x73_configure_wait_indicator@0x41e900 passes operands 1..9 to adv_text_configure_wait_indicator@0x44ff60 and operand 10 to adv_indicator_set_frame_period@0x44d060. The worker writes surface/source rect, layout-relative destination, enabled=1, and terminal/column values to the selected layout at +0x3c..+0x60. SYSTEM4 executes `set-texture 0x337c 0xc 0xff00` (raw id 0x337c = SO000.AGF, a 390x27 strip of thirteen 30x27 bat frames), then `0x73 1 385 140 12 0 0 30 27 12 48`; layout 1 begins at y=430, placing the 30x27 marker at screen (385,570), matching the original. Op 0x72 activates this descriptor only after reveal completion. +- **evidence:** Ghidra /v2: op_0x73_configure_wait_indicator@0x41e900 passes operands 1..9 to adv_text_configure_wait_indicator@0x44ff60 and operand 10 to adv_indicator_set_frame_period@0x44d060. The worker writes surface/source rect, layout-relative destination, enabled=1, and terminal/column values to the selected layout at +0x3c..+0x60. SYSTEM4 executes `set-texture 0x337c 0xc 0xff00` (raw id 0x337c = SO000.AGF, a decoded 360x27 strip of twelve 30x27 bat frames), then `0x73 1 385 140 12 0 0 30 27 12 48`; the value 12 is the exclusive terminal/frame count, so animation publishes frames 0..11 and wraps before 12. Layout 1 begins at y=430, placing the 30x27 marker at screen (385,570), matching the original. Op 0x72 activates this descriptor only after reveal completion. ### 0x75 `set-font-size` (set-font-size, argc 1) - **summary:** (pixels) - set the primary text font height and rebuild its native rasterization state. diff --git a/engine/Age.Engine.Tests/AdvTextOpsTests.cs b/engine/Age.Engine.Tests/AdvTextOpsTests.cs index 62e74f9..443cac9 100644 --- a/engine/Age.Engine.Tests/AdvTextOpsTests.cs +++ b/engine/Age.Engine.Tests/AdvTextOpsTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Vm; @@ -106,6 +108,22 @@ public class AdvTextOpsTests Assert.Single(host.WaitIndicators)); } + [Fact] + public void WaitIndicatorTerminalFrameIsExclusive() + { + var config = new AdvWaitIndicatorConfig( + 1, 385, 140, 12, 0, 0, 30, 27, 12, 48); + + int[] frames = Enumerable.Range(0, 24) + .Select(tick => config.FrameAt(tick * 48L)) + .ToArray(); + + Assert.Equal( + Enumerable.Range(0, 12).Concat(Enumerable.Range(0, 12)), + frames); + Assert.DoesNotContain(12, frames); + } + [Fact] public void WaitIndicatorToggleAndTextLayoutPublicationReachHost() { diff --git a/engine/Age.Engine.Tests/GfxRangeTransformTests.cs b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs index 4a3b212..b5f6283 100644 --- a/engine/Age.Engine.Tests/GfxRangeTransformTests.cs +++ b/engine/Age.Engine.Tests/GfxRangeTransformTests.cs @@ -72,5 +72,16 @@ public class GfxRangeTransformTests Assert.NotNull(vm.Gfx.SnapshotVisibleObjects().Single().RangeTransform); } + [Fact] + public void BunkiAnimationPublicationExcludesStablePopupHandles() + { + var animationRange = new GfxHandleRange(0, 60000); + + Assert.True(animationRange.Contains(59999)); + Assert.False(animationRange.Contains(60000)); + Assert.False(animationRange.Contains(60050)); + Assert.True(GfxHandleRange.All.Contains(60050)); + } + private static Operand I(long v) => new(0, v); } diff --git a/engine/Age.Engine.Tests/Transform2DMathTests.cs b/engine/Age.Engine.Tests/Transform2DMathTests.cs index b490d2b..4cce31b 100644 --- a/engine/Age.Engine.Tests/Transform2DMathTests.cs +++ b/engine/Age.Engine.Tests/Transform2DMathTests.cs @@ -48,6 +48,19 @@ public class Transform2DMathTests Assert.Equal(0, allocated); } + [Fact] + public void CanvasAxisDecompositionPreservesBunkiPopupScale() + { + var affine = Transform2DMath.Build(new TransformState( + 0.05, 0.65, 1, 0, 0, 0, 400, 300, 0)); + + var (rotation, scaleX, scaleY) = affine.DecomposeCanvasAxes(); + + Assert.Equal(0, rotation, 12); + Assert.Equal(0.05, scaleX, 12); + Assert.Equal(0.65, scaleY, 12); + } + private static double Next(Random random, double minimum, double maximum) => minimum + random.NextDouble() * (maximum - minimum); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 433ad1d..8bef289 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -6,7 +6,17 @@ namespace Age.Engine.Hosting; public readonly record struct AdvWaitIndicatorConfig( int LayoutSlot, int X, int Y, int SurfaceSlot, int SourceX, int SourceY, int CellWidth, int CellHeight, - int TerminalFrame, long FramePeriodMs); + int TerminalFrame, long FramePeriodMs) +{ + /// Select the current atlas frame. TerminalFrame is the exclusive native upper bound, + /// so SYSTEM4's value 12 addresses the twelve cells 0 through 11. + public int FrameAt(long elapsedMs) + { + int frameCount = System.Math.Max(1, TerminalFrame); + long period = System.Math.Max(1, FramePeriodMs); + return (int)(System.Math.Max(0, elapsedMs) / period % frameCount); + } +} public readonly record struct AdvAutoWaitState( bool Enabled, bool VoicePending, long PostVoiceDelayMs, long UnvoicedDelayMs); diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 40d06be..f7e7deb 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -84,6 +84,15 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long Affine2D? RangeTransform = null, bool TimeVarying = false); +/// The retained handle interval selected by an op-0x222 backbuffer publication. +public readonly record struct GfxHandleRange(long First, long Count) +{ + public static GfxHandleRange All => new(0, long.MaxValue); + + public bool Contains(long handle) + => Count > 0 && handle >= First && (ulong)(handle - First) < (ulong)Count; +} + /// 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 /// native retained-gfx owner+0x408 map (EngineCtx+0x46a1c) that op 0x215 queries and the geometry get/set ops share. Each object carries a diff --git a/engine/Age.Engine/Model/Transform2DMath.cs b/engine/Age.Engine/Model/Transform2DMath.cs index 39f9132..79fbf4f 100644 --- a/engine/Age.Engine/Model/Transform2DMath.cs +++ b/engine/Age.Engine/Model/Transform2DMath.cs @@ -31,6 +31,20 @@ public readonly record struct Affine2D(double XX, double XY, double YX, double Y inverse = new(xx, xy, yx, yy, -(TX * xx + TY * yx), -(TX * xy + TY * yy)); return true; } + + /// Decompose the projected axes for a Godot-style canvas item. AGE's ordinary retained + /// transforms contain scale/rotation but no shear; the determinant preserves reflected Y axes. + public (double RotationRadians, double ScaleX, double ScaleY) DecomposeCanvasAxes() + { + double scaleX = System.Math.Sqrt(XX * XX + XY * XY); + double scaleY = System.Math.Sqrt(YX * YX + YY * YY); + double determinant = XX * YY - XY * YX; + if (determinant < 0) scaleY = -scaleY; + double rotation = scaleX > 1e-12 + ? System.Math.Atan2(XY, XX) + : System.Math.Atan2(-YX, YY); + return (rotation, scaleX, scaleY); + } } /// Exact 2D projection of AGE's row-vector retained-object matrix. Native call order is anchored diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 907a224..67de8ac 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -69,6 +69,8 @@ public sealed class GodotAdvHost : IHost private GfxState? _foregroundGfx; private readonly object _screenTransitionLock = new(); private readonly Dictionary> _renderTargetSnapshots = new(); + private readonly object _backbufferRangeLock = new(); + private GfxHandleRange _backbufferRange = GfxHandleRange.All; private LegacyScreenTransition? _screenTransition; public volatile bool IsWaiting; public volatile bool IsTransitionWaiting; @@ -277,10 +279,28 @@ public sealed class GodotAdvHost : IHost PublishObjectRangeToSurface(gfx, firstHandle, count); return; } + // The software/GPU port reconstructs the backbuffer instead of preserving native D3D pixels. + // Zero-based ranges therefore define the complete published scene. A nonzero range is an + // incremental overlay in native code, so retain the full reconstruction for those call sites. + lock (_backbufferRangeLock) + _backbufferRange = firstHandle == 0 + ? new GfxHandleRange(firstHandle, count) + : GfxHandleRange.All; System.Threading.Interlocked.Exchange(ref _presentRequested, 1); _timeline?.Event("present-object-range", new() { ["first"] = firstHandle, ["count"] = count }); } + public void SnapshotBackbufferObjects(GfxState gfx, long nowMs, List snapshot) + { + gfx.SnapshotVisibleObjects(nowMs, snapshot); + GfxHandleRange range; + lock (_backbufferRangeLock) range = _backbufferRange; + int write = 0; + for (int read = 0; read < snapshot.Count; read++) + if (range.Contains(snapshot[read].Handle)) snapshot[write++] = snapshot[read]; + if (write < snapshot.Count) snapshot.RemoveRange(write, snapshot.Count - write); + } + public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { lock (_textLock) _waitIndicators[config.LayoutSlot] = config; @@ -323,9 +343,7 @@ public sealed class GodotAdvHost : IHost var asset = _res.ResolveTexture(resourceId); var image = asset != null ? Decode(asset) : null; if (asset == null || image == null || config.CellWidth <= 0 || config.CellHeight <= 0) return null; - int frames = System.Math.Max(1, config.TerminalFrame + 1); - long period = System.Math.Max(1, config.FramePeriodMs); - int frame = (int)((_clock.NowMs - _waitIndicatorStartedMs) / period % frames); + int frame = config.FrameAt(_clock.NowMs - _waitIndicatorStartedMs); return new AdvWaitIndicatorSnapshot(image, asset.Name, asset.PackedId, config, frame); } @@ -584,6 +602,7 @@ public sealed class GodotAdvHost : IHost }); return; } + lock (_backbufferRangeLock) _backbufferRange = GfxHandleRange.All; int started = gfx.StartForegroundTransitions(_clock.NowMs); int completed = gfx.CompleteForegroundTransitions(_clock.NowMs); if (started > 0 || completed > 0) @@ -702,6 +721,7 @@ public sealed class GodotAdvHost : IHost // scene_context_init_reset releases ordinary surface/movie bindings but keeps decoded asset // caches and process-owned audio/configuration available to the reloaded SYSTEM4 root. ReleaseSurfaceRange(0, 1000); + lock (_backbufferRangeLock) _backbufferRange = GfxHandleRange.All; lock (_textLock) { _surfaceText.Clear(); @@ -1162,6 +1182,8 @@ public sealed class GodotAdvHost : IHost }); lock (_imageLock) _surfaceImages[targetSlot] = destination; + PublishSurfaceTextRangeToSurface( + gfx, visible, firstHandle, count, targetSlot, dimensions.W, dimensions.H); IReadOnlyList retained = visible .Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count) .ToArray(); @@ -1176,6 +1198,51 @@ public sealed class GodotAdvHost : IHost }); } + private void PublishSurfaceTextRangeToSurface( + GfxState gfx, IReadOnlyList visible, long firstHandle, long count, + int targetSlot, int targetWidth, int targetHeight) + { + List projected; + lock (_textLock) + projected = _surfaceText.TryGetValue(targetSlot, out var retained) + ? new List(retained) + : new List(); + + foreach (RenderObject item in visible) + { + if (item.Handle < firstHandle || item.Handle - firstHandle >= count) continue; + var raw = gfx.TryGet(item.Handle); + if (raw == null) continue; + List? source; + lock (_textLock) + source = _surfaceText.TryGetValue(raw.SourceSlot, out var draws) + ? new List(draws) + : null; + if (source == null) continue; + + Affine2D localToTarget = + Transform2DMath.Build(item.Transform, item.Rotation).FromLocalOrigin(item.DstX, item.DstY); + if (item.RangeTransform is { } rangeTransform) + localToTarget = localToTarget.Then(rangeTransform); + foreach (SurfaceTextDraw draw in source) + { + if (draw.X < item.SrcX || draw.X >= item.SrcX + item.W || + draw.Y < item.SrcY || draw.Y >= item.SrcY + item.H) continue; + var position = localToTarget.Apply(draw.X - item.SrcX, draw.Y - item.SrcY); + int x = (int)System.Math.Round(position.X); + int y = (int)System.Math.Round(position.Y); + if (x < 0 || x >= targetWidth || y < 0 || y >= targetHeight) continue; + projected.Add(new SurfaceTextDraw(x, y, draw.Text, draw.Style)); + } + } + + lock (_textLock) + { + if (projected.Count == 0) _surfaceText.Remove(targetSlot); + else _surfaceText[targetSlot] = projected; + } + } + public void ReleaseSurfaceRange(int firstSlot, int count) { IReadOnlyList stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count); diff --git a/godot/Main.cs b/godot/Main.cs index 091583e..e2db12f 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -864,7 +864,7 @@ public partial class Main : Godot.Control long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; if (_host.TrySnapshotScreenTransition(out _)) return false; // P4: whole-screen offscreen targets - _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); + _host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot); snapshotCaptured = true; _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); @@ -952,9 +952,9 @@ public partial class Main : Godot.Control { if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W || surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue; - var textPos = affine.Apply(surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY); var label = GetSurfaceTextLabel(surfaceTextLabelIndex++); - label.Position = new Vector2((float)textPos.X, (float)textPos.Y); + ApplySurfaceTextTransform( + label, affine, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY); label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)), System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY))); label.Text = surfaceText.Text; @@ -1078,7 +1078,8 @@ public partial class Main : Godot.Control phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; if (sampledVisible == null) - _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); // synchronized objects + ranges + _host.SnapshotBackbufferObjects( + _vm.Gfx, _clock.NowMs, _visibleSnapshot); // synchronized objects + publication range _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; @@ -1197,9 +1198,9 @@ public partial class Main : Godot.Control { if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W || surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue; - var textPos = localToDest.Apply(surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY); var label = GetSurfaceTextLabel(surfaceTextLabelIndex++); - label.Position = new Vector2((float)textPos.X, (float)textPos.Y); + ApplySurfaceTextTransform( + label, localToDest, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY); label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)), System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY))); label.Text = surfaceText.Text; @@ -1254,6 +1255,17 @@ public partial class Main : Godot.Control return _surfaceTextLabels[index]; } + private static void ApplySurfaceTextTransform(Label label, Affine2D localToDestination, + int localX, int localY) + { + var position = localToDestination.Apply(localX, localY); + var (rotation, scaleX, scaleY) = localToDestination.DecomposeCanvasAxes(); + label.Position = new Vector2((float)position.X, (float)position.Y); + label.PivotOffset = Vector2.Zero; + label.Rotation = (float)rotation; + label.Scale = new Vector2((float)scaleX, (float)scaleY); + } + private Label CreateAdvPresentationLabel() { var label = new Label diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 79e0b0d..e4b7290 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1050,7 +1050,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x73_configure_wait_indicator@0x41e900 passes operands 1..9 to adv_text_configure_wait_indicator@0x44ff60 and operand 10 to adv_indicator_set_frame_period@0x44d060. The worker writes surface/source rect, layout-relative destination, enabled=1, and terminal/column values to the selected layout at +0x3c..+0x60. SYSTEM4 executes `set-texture 0x337c 0xc 0xff00` (raw id 0x337c = SO000.AGF, a 390x27 strip of thirteen 30x27 bat frames), then `0x73 1 385 140 12 0 0 30 27 12 48`; layout 1 begins at y=430, placing the 30x27 marker at screen (385,570), matching the original. Op 0x72 activates this descriptor only after reveal completion." +evidence = "Ghidra /v2: op_0x73_configure_wait_indicator@0x41e900 passes operands 1..9 to adv_text_configure_wait_indicator@0x44ff60 and operand 10 to adv_indicator_set_frame_period@0x44d060. The worker writes surface/source rect, layout-relative destination, enabled=1, and terminal/column values to the selected layout at +0x3c..+0x60. SYSTEM4 executes `set-texture 0x337c 0xc 0xff00` (raw id 0x337c = SO000.AGF, a decoded 360x27 strip of twelve 30x27 bat frames), then `0x73 1 385 140 12 0 0 30 27 12 48`; the value 12 is the exclusive terminal/frame count, so animation publishes frames 0..11 and wraps before 12. Layout 1 begins at y=430, placing the 30x27 marker at screen (385,570), matching the original. Op 0x72 activates this descriptor only after reveal completion." [[opcode.semantics.args]] i = 1 @@ -1094,7 +1094,7 @@ observed_types = ["imm"] [[opcode.semantics.args]] i = 9 -role = "terminal frame index / sheet columns" +role = "exclusive terminal frame / sheet column count" observed_types = ["imm"] [[opcode.semantics.args]]