From 41001f6f7700887e79db35fe21a78fb456fbf58f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 30 Jul 2026 18:05:43 -0400 Subject: [PATCH] engine: pin retained glyph text contract --- docs/engine-re.md | 53 ++++++++++++++---- docs/opcode-reference.md | 12 ++-- docs/remake-architecture-and-roadmap.md | 16 +++++- .../AdvRetainedTextContractTests.cs | 55 ++++++++++++++++++ engine/Age.Engine.Tests/AdvTextOpsTests.cs | 6 ++ .../NativeNumberedSaveCodecTests.cs | 28 ++++++++++ .../Model/AdvRetainedTextContract.cs | 56 +++++++++++++++++++ engine/Age.Engine/Model/AdvTextHistory.cs | 26 ++++----- vm-map/opcodes.toml | 10 ++-- 9 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 engine/Age.Engine.Tests/AdvRetainedTextContractTests.cs create mode 100644 engine/Age.Engine/Model/AdvRetainedTextContract.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index fba3042..99c8fa9 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2507,6 +2507,16 @@ serialization, and `text_history_deserialize@0x456130` restores them on the matc `NativeTextHistoryCodec` now implements that appended tail directly against the same live `AdvTextHistory` model; it remains independent of shared profile state and `RT.DAT`. +That tail is specifically the History backlog, not the live ADV layout presentation. +`text_history_deserialize` calls `text_history_clear`, reconstructs only the logical index and 0x48-byte +History record/string vectors, and does not recreate or replace live layout definitions, layout glyph +records, source-surface pixels, selected layout, wait-indicator handles, or glyph-object ranges. The main +layout-3 container likewise reloads only flagged asset surfaces; created ADV layout surfaces have no +serialized pixels. Full restoration runs the saved scripts from their ordinary entry prologues through +`CALLBACK_LOAD.BIN`/`0xae`, so the current page is rebuilt and republished by script control flow. +`AdvTextHistory.RestorePersistenceSnapshot` consequently replaces only backlog data and preserves any +initialized live layout configuration/bindings. A focused native-tail regression pins that separation. + History's display support consists of the ordinary presentation operations: primary/ruby font sizes (`0x75/0x197`), font weight (`0x2bd`), colors/effect mode/offset (`0x76/0x77/0x78/0x1a4`), layout origin (`0x198`), surface rectangle fill (`0x20b`), message-window alpha (`0x131`), and retained-object presentation @@ -2763,13 +2773,27 @@ record with `gfx_object_bind_draw`. Native SC0000 records `ctx+0x14e9c = 50 ms`: at `0x834`, then publishes them one at a time before reaching wait `0x83c`. A click during reveal completes the remaining records and is consumed; the next click releases the stable wait. -The record layout is `{publication-chain flag, x, y, width, height}` as five dwords. The publisher binds -handle `layout+0x68 + reveal_index` from source surface `layout_slot+0x14`, uses `(x,y,width,height)` as -the source rectangle, and places it at `(layout_origin_x+x, layout_origin_y+y)`. A set first dword makes the -service publish the following record in the same tick; otherwise the configured glyph delay controls the -next publication. This gives the port a direct path to ordinary `GfxState` objects: rasterize the complete -layout surface first, then materialize these exact bounded records in handle order rather than adding a -second overlay compositor. +The record layout is `{publication-chain flag, left, top, right, bottom}` as five dwords. These are native +rectangle edges, not width/height: `gfx_object_bind_draw@0x47e870` copies them directly into its source-edge +record. The publisher binds handle `layout+0x68 + reveal_index` from source surface `layout_slot+0x14` and +places it at `(layout_origin_x+left, layout_origin_y+top)`. A first dword equal to one makes the service +publish the following record in the same tick; otherwise the configured glyph delay controls the next +publication. This gives the port a direct path to ordinary `GfxState` objects: rasterize the complete layout +surface first, then materialize these exact bounded records in handle order rather than adding a second +overlay compositor. + +`adv_text_layout_check_overflow@0x45efc0` sets bit 1 only when `right_bound < glyph_right` and bit 2 only +when `bottom_bound < glyph_bottom`; equality fits. Vertical overflow terminates the builder. With horizontal +wrapping enabled, right overflow inserts a line break before the current glyph except for CP932 `0x8141`, +`0x8142`, and `0x8176` (`、`, `。`, `」`), which remain attached to the preceding line even beyond the +bound. The line pitch is the positive primary font pixel height plus configured leading. + +Timed `adv_text_publish_next_glyph@0x451220` binds only while the reveal index is below layout `+0x6c`, +the configured retained-object capacity; reaching the capacity ends the timed service even if more records +exist. SYSTEM4 configures all nine Himegari layouts with `0x1f4` (500) handles. The decoded static dialogue +corpus contains 30,057 rows, has a maximum individual show-text string length of 68, and contains no +500-character string, so shipped ordinary text does not approach that limit. This is a corpus safety +baseline, not permission for a mod-facing renderer to overrun the interval. The port retains the SC0000-visible contract without exposing thousands of individual host glyph objects: surface strings remain associated with blank surface slots for later retained-object binding, while ADV @@ -3357,8 +3381,11 @@ concrete route relevant here. `op_0x20a_publish_adv_text_layout@0x422ce0` first calls `adv_text_publish_layout@0x450c80`. Slot zero selects the text manager's current layout; a nonzero operand selects that indexed layout. The worker erases the layout's old retained-object range, walks its 20-byte -text records, and rebinds their draw objects. When run-state bit `0x40000000` is active, the opcode also -calls `adv_text_publish_wait_indicator_frame@0x453120` with the current indicator frame. The latter worker +text records, and rebinds their draw objects. It snapshots global reveal index manager `+0x570`, clamps it +only to the layout's record count, and binds records zero through that index inclusive; unlike timed reveal, +this path does not clamp to layout `+0x6c` capacity. Himegari's 500-handle ranges keep the shipped path within +the configured interval. When run-state bit `0x40000000` is active, the opcode also calls +`adv_text_publish_wait_indicator_frame@0x453120` with the current indicator frame. The latter worker uses frame `-1` to return/capture the terminal frame, `-2` to erase the indicator object, and a nonnegative frame to choose and bind the corresponding atlas cell. All corpus uses are `0x20a(1)`; `HISTORY.BIN@0x13ab` reaches it through the shared ADV redraw callback. @@ -3645,10 +3672,16 @@ cursor record immediately. `op_0x1c1_set_adv_text_bounds@0x41f6c0` reads `(layout_slot, right, bottom)` and calls `adv_text_layout_set_bounds@0x44ff00`, which writes the pair to layout `+0x24/+0x28`. They are absolute layout-local overflow coordinates. `adv_text_layout_check_overflow@0x45efc0` compares glyph end x/y -against those fields and returns horizontal/vertical overflow bits. When a layout resets, +against those fields with strict `bound < glyph_edge` tests and returns horizontal/vertical overflow bits. +When a layout resets, `adv_text_layout_reset_cursor_and_bounds_record@0x455070` replaces its retained state with a five-dword record `{0, reset_x, reset_y, right, bottom}` copied from all four configured fields. +`adv_text_layout_reset@0x455210` then zeroes every pixel in surface `layout_slot+0x14` and erases the +complete configured glyph-object interval at layout `+0x68/+0x6c`. History recording is a separate tail: +unless suppression bit 31 is set, reset appends the logical boundary and arms the next History record's +group-start flag. + The corpus supplies a complete consistency check: all nine sites for each opcode are in SYSTEM4 and alternate after layouts 1–9 are defined and initially reset. Layout 1 configures cursor `(100,47)` and bounds `(720,147)`; layouts 2–6 use `(45,42)` and `(645,135)`; layout 7 uses `(53,10)` and `(495,180)`; diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 1bc43d8..5bee577 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -8,7 +8,9 @@ ### 0x6e `show-text` (show-text, argc 2) - **summary:** (layout_slot)(text) - build and publish a retained text run in the selected ADV layout, using the current glyph delay and raster style; slot zero selects the current layout. - **grounding:** source=investigation, confidence=high -- **evidence:** Native op 0x6e enters adv_text_build_glyph_records@0x4576c0, which snapshots the selected layout cursor and active font/color state, rasterizes one record per CP932 glyph, advances the live cursor, and publishes records through adv_text_publish_next_glyph@0x451220 using text-manager charDelay. Native SC0000 traces show a 50-ms ordinary delay and retained layout publication; MAMES/ITMES/SKMES/INFOMES set delay zero for immediate UI descriptions. +- **evidence:** Native op 0x6e enters adv_text_build_glyph_records@0x4576c0, which snapshots the selected layout cursor and active font/color state, rasterizes one record per CP932 glyph, advances the live cursor, and publishes records through adv_text_publish_next_glyph@0x451220 using text-manager charDelay. adv_text_layout_check_overflow@0x45efc0 returns independent horizontal/vertical bits from strict right/bottom comparisons; the builder's wrap branch excludes CP932 0x8141/0x8142/0x8176. The publisher passes record edges directly to gfx_object_bind_draw@0x47e870 and stops at layout+0x6c capacity. Native SC0000 traces show a 50-ms ordinary delay and retained layout publication; MAMES/ITMES/SKMES/INFOMES set delay zero for immediate UI descriptions. + +The live 20-byte glyph record is {chain_flag,left,top,right,bottom}; rectangle members are edges. Overflow bits use strict bound Assert.True(AdvRetainedTextContract.PreventsHorizontalWrapBefore((ushort)cp932)); + + [Fact] + public void OtherCp932GlyphsMayWrapNormally() + { + Assert.False(AdvRetainedTextContract.PreventsHorizontalWrapBefore(0x8140)); // full-width space + Assert.False(AdvRetainedTextContract.PreventsHorizontalWrapBefore(0x82a0)); // あ + } + + [Fact] + public void TimedPublicationStopsAtConfiguredObjectCapacity() + { + Assert.True(AdvRetainedTextContract.TimedPublicationHandleExists(0, 500)); + Assert.True(AdvRetainedTextContract.TimedPublicationHandleExists(499, 500)); + Assert.False(AdvRetainedTextContract.TimedPublicationHandleExists(500, 500)); + Assert.False(AdvRetainedTextContract.TimedPublicationHandleExists(-1, 500)); + } +} diff --git a/engine/Age.Engine.Tests/AdvTextOpsTests.cs b/engine/Age.Engine.Tests/AdvTextOpsTests.cs index ad02b18..2d9ef5e 100644 --- a/engine/Age.Engine.Tests/AdvTextOpsTests.cs +++ b/engine/Age.Engine.Tests/AdvTextOpsTests.cs @@ -289,6 +289,12 @@ public class AdvTextOpsTests Assert.Equal((10, 10, 250, 368), CursorAndBounds(history.GetLayoutSnapshot(9))); Assert.Equal(new[] { 1 }, history.LayoutsCoveredByTextObjectErase(0xd6d8, 0x1f4)); Assert.Equal(new[] { 7 }, history.LayoutsCoveredByTextObjectErase(0x7d0, 0x1f4)); + Assert.All(Enumerable.Range(1, 9), slot => + { + AdvTextLayoutPresentationBinding binding = history.GetPresentationBinding(slot); + Assert.Equal(slot + 0x14, binding.SourceSurfaceSlot); + Assert.Equal(0x1f4, binding.ObjectCapacity); + }); static (int X, int Y, int Right, int Bottom) CursorAndBounds(AdvTextLayoutSnapshot layout) => (layout.CursorX, layout.CursorY, layout.Right, layout.Bottom); diff --git a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs index e001ab1..fcd6901 100644 --- a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs +++ b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs @@ -92,6 +92,34 @@ public class NativeNumberedSaveCodecTests Assert.Equal(AdvTextHistoryRecordKind.Metadata, restored.Records[1].Kind); } + [Fact] + public void HistoryTailRestorePreservesInitializedLiveLayoutBindings() + { + var savedHistory = new AdvTextHistory(); + savedHistory.DefineLayout(1, 400, 120, 75, 340); + savedHistory.AppendText(1, 0x123, "保存履歴", AdvTextStyle.Default); + + var liveHistory = new AdvTextHistory(); + liveHistory.DefineLayout(3, 320, 90, 20, 400); + liveHistory.SetResetCursor(3, 45, 42); + liveHistory.SetBounds(3, 300, 80); + liveHistory.SetWaitIndicatorObjectHandle(3, 0x1234); + liveHistory.SetTextObjectRange(3, 0x2000, 500); + + NativeTextHistoryCodec.DecodeInto(NativeTextHistoryCodec.Encode(savedHistory), liveHistory); + + Assert.Equal(savedHistory.Entries, liveHistory.Entries); + Assert.Equal(savedHistory.Records.Select(record => record.Text), + liveHistory.Records.Select(record => record.Text)); + Assert.Equal(3, liveHistory.CurrentLayoutSlot); + Assert.Equal( + new AdvTextLayoutSnapshot(3, 320, 90, 20, 400, 0, 0, 300, 80), + liveHistory.GetLayoutSnapshot(3)); + Assert.Equal( + new AdvTextLayoutPresentationBinding(3, 0x17, 0x2000, 500, 0x1234), + liveHistory.GetPresentationBinding(3)); + } + [Fact] public void DirectoryStorePreservesHistoryTailAfterNativeContainer() { diff --git a/engine/Age.Engine/Model/AdvRetainedTextContract.cs b/engine/Age.Engine/Model/AdvRetainedTextContract.cs new file mode 100644 index 0000000..fab2f0b --- /dev/null +++ b/engine/Age.Engine/Model/AdvRetainedTextContract.cs @@ -0,0 +1,56 @@ +namespace Age.Engine.Model; + +[Flags] +public enum AdvTextOverflowFlags +{ + None = 0, + Horizontal = 1, + Vertical = 2, +} + +/// +/// Native AGE's 20-byte live ADV glyph record. Rectangle values are edges, matching the +/// retained-object bind ABI; they are not x/y/width/height values. +/// +public readonly record struct AdvRetainedGlyphRecord( + int PublicationChainFlag, + int Left, + int Top, + int Right, + int Bottom) +{ + public int Width => Right - Left; + public int Height => Bottom - Top; + public bool PublishesNextInSameTick => PublicationChainFlag == 1; +} + +/// The script-owned retained-object binding associated with one live ADV layout. +public readonly record struct AdvTextLayoutPresentationBinding( + int LayoutSlot, + int SourceSurfaceSlot, + long FirstObjectHandle, + long ObjectCapacity, + long WaitIndicatorObjectHandle); + +/// Platform-neutral rules established from AGE's native retained-glyph workers. +public static class AdvRetainedTextContract +{ + public static AdvTextOverflowFlags CheckOverflow( + int rightBound, int bottomBound, int glyphRight, int glyphBottom) + { + var result = AdvTextOverflowFlags.None; + if (rightBound < glyphRight) result |= AdvTextOverflowFlags.Horizontal; + if (bottomBound < glyphBottom) result |= AdvTextOverflowFlags.Vertical; + return result; + } + + /// + /// CP932 closing punctuation which AGE keeps on the preceding horizontal line even when the + /// current glyph crosses the right bound. + /// + public static bool PreventsHorizontalWrapBefore(ushort cp932) + => cp932 is 0x8141 or 0x8142 or 0x8176; // 、 。 」 + + public static bool TimedPublicationHandleExists(int revealIndex, long objectCapacity) + => revealIndex >= 0 && revealIndex < objectCapacity; +} diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs index f9a8041..2e5beed 100644 --- a/engine/Age.Engine/Model/AdvTextHistory.cs +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -195,6 +195,15 @@ public sealed class AdvTextHistory layout.TextObjectRangeCount = count; } + public AdvTextLayoutPresentationBinding GetPresentationBinding(int requestedSlot) + { + int slot = ResolveLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + return new AdvTextLayoutPresentationBinding( + slot, checked(slot + 0x14), layout.TextObjectRangeFirst, + layout.TextObjectRangeCount, layout.WaitIndicatorObjectHandle); + } + /// /// Resolve layouts whose complete native retained-glyph handle interval is covered by one op-0x1f7 /// erase. The Label backend collapses those glyph objects into live text runs, so full native range @@ -275,20 +284,9 @@ public sealed class AdvTextHistory _entries.Clear(); _entries.AddRange(entries); _pendingGroupStarts.Clear(); - _layouts.Clear(); - foreach (AdvTextHistoryRecord record in records) - { - var layout = GetOrCreateLayout(record.Layout.Slot); - layout.Width = record.Layout.Width; - layout.Height = record.Layout.Height; - layout.OriginX = record.Layout.OriginX; - layout.OriginY = record.Layout.OriginY; - layout.CursorX = record.Layout.CursorX; - layout.CursorY = record.Layout.CursorY; - layout.Right = record.Layout.Right; - layout.Bottom = record.Layout.Bottom; - } - CurrentLayoutSlot = entries.Count > 0 ? entries[^1].LayoutSlot : 0; + // Native text_history_deserialize clears and reconstructs only the history entry/record vectors. + // Live layout definitions, selected layout, surfaces, and retained-object bindings remain owned by + // the initialized text manager and are rebuilt by the script restore path as needed. _navigationAnchorIndex = entries.Count - 1; } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 4d922a4..68c08b5 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -916,11 +916,12 @@ abi_source = "kelebek+decode-validated" name = "show-text" category = "adv" summary = "(layout_slot)(text) - build and publish a retained text run in the selected ADV layout, using the current glyph delay and raster style; slot zero selects the current layout." +details = "The live 20-byte glyph record is {chain_flag,left,top,right,bottom}; rectangle members are edges. Overflow bits use strict bound