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