From e0aae05f32aa3560ffeffffbc2f27c1cbe13cb9c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 19 Jul 2026 23:32:38 -0400 Subject: [PATCH] Implement AGE text line spacing --- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 25 +++++++++++++++++ docs/opcode-reference.md | 9 ++++--- docs/phase-a-slice-plan.md | 27 +++++++++++++++++++ .../HistoryPresentationOpsTests.cs | 10 ++++--- engine/Age.Engine/Model/AdvTextHistory.cs | 5 ++-- engine/Age.Engine/Vm/VirtualMachine.cs | 9 ++++++- godot/Main.cs | 1 + tools/age_opcodes_himegari.py | 1 + vm-map/engine-ctx.toml | 5 ++++ vm-map/opcodes.toml | 14 +++++----- 11 files changed, 90 insertions(+), 17 deletions(-) diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index e5c1b81..6291c46 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -16,6 +16,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) | | `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | | `0x14d54` | `gfx_obj_ptr_table` | `void*` | per-object pointer table (ops 0x212/0x213 write obj+0x64/0x68/0x6c) | +| `0x14ea0` | `text_line_spacing` | `int` | extra pixel leading between text lines; defaults to 6, op 0x8b writes it | | `0x14f45` | `script_frame_index` | `int` | call-script frame index (0x1e-dword frames) | | `0x46d14` | `query_table_46d14` | `void*` | stride-0x14 table read by op 0x216 | | `0x51b64` | `frame_timer` | `int` | frame timer (present updates 0x51b64/0x51b68) | diff --git a/docs/engine-re.md b/docs/engine-re.md index 563c9a7..7dc1041 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1700,6 +1700,31 @@ inventing unsigned behavior. `/v2` names/comments this mount/load/open chain and --- +### ADV text line spacing -- opcode `0x8b` (2026-07-19) + +`op_0x8b_set_text_line_spacing@0x41f270` stores its single operand at ADV text-manager offset +`+0x560`, or `EngineCtx.text_line_spacing` (`ctx+0x14ea0`). This is pixel leading, not a font face, +weight, or effect selector. `adv_text_manager_initialize@0x456800` gives it a native default of 6. + +The horizontal line-break path (`adv_text_append_line_break_horizontal@0x456fd0`) and retained History +renderer (`text_history_render_records@0x452970`) pass `manager+0x560 - manager+0x4cc` as the line +advance. Offset `+0x4cc` is the primary `LOGFONT.lfHeight`, which AGE stores as a negative pixel height, +so the effective pitch is `font pixel height + text_line_spacing`. The corresponding vertical-writing +path uses the same pitch to move to the next column. Corpus values support that interpretation: scripts +pair 8 pixels with 22/24-pixel Mincho text and 9 pixels with 16-pixel Gothic text. + +History records do not snapshot the `+0x560` field. `text_history_append_text_record@0x456000` retains +font, color, effect, geometry, flags, and string state, while `text_history_render_records` reads the +manager's current line spacing when it encounters a retained line-break marker. The port therefore keeps +line spacing in the current ADV style manager and overrides a History render batch with that current value; +the individual record continues to supply its retained font/color/effect fields. + +The `/v2` image names/comments the handler, manager initializer, horizontal/vertical line-break paths, and +their low-level cursor-advance helpers. The regenerated 58-field `EngineCtx` is applied and the program is +saved. + +--- + ## Native walls backlog (targets for this loop) - ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script ` = raw SYS4INI file index. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 5dfc295..3392d82 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -55,6 +55,11 @@ - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x85_handler@0x4163d0 calls text_history_clear@0x455d70 on the text manager. The worker destroys the 0x48-byte record vector and clears the 8-byte logical index vector. Corpus: two sites in each of 143 normal ADV scripts bracket the retained backlog lifetime. +### 0x8b `set-text-line-spacing` (u0041B3D0, argc 1) +- **summary:** (pixels) - set the extra pixel spacing added between text lines. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x8b_set_text_line_spacing@0x41f270 stores operand 1 at text manager +0x560 (ctx+0x14ea0). adv_text_manager_initialize@0x456800 defaults it to 6. Horizontal newline/history paths at 0x456fd0/0x452970 advance y by (+0x560 - primary LOGFONT.lfHeight); lfHeight is negative, so this is font pixel height plus configured leading. Corpus arguments are 8 for 22/24px Mincho blocks and 9 for 16px Gothic blocks. + ### 0x197 `set-ruby-font-size` (set-ruby-font-size, argc 1) - **summary:** (pixels) - set the secondary/ruby text font height and rebuild its native rasterization state. - **grounding:** source=investigation, confidence=high @@ -815,10 +820,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x8b `u0041B3D0` (u0041B3D0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x8c `jmp` (jmp, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=med diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 0096719..b510cac 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1899,3 +1899,30 @@ gap. Keep `0x1cb` deferred, and treat `0x1ce`/`0x20a` together as their own spri Validation: all 205 engine tests pass, opcode and EngineCtx tests/lints are clean, vm0 RECOVER passes, the Godot build has zero warnings, and threaded `SELFTEST OK`. + +### ADV History text line spacing implemented (2026-07-19) + +Native RE resolves the two remaining `0x8b` calls as text-manager line leading. The handler writes its +single pixel count to manager offset `+0x560` (`EngineCtx+0x14ea0`), whose initializer default is 6. +Horizontal newline and History rendering add that value to the primary font's positive pixel height; the +script's concrete style blocks use 8 pixels with their 22/24-pixel Mincho fonts and 9 with a 16-pixel +Gothic font. + +The VM now retains that current style property, Godot applies it as the Label `line_spacing` theme +constant, and History rendering matches native ownership: line spacing comes from the text manager's +current state rather than from the retained record. A focused regression distinguishes a record retained +with spacing 9 from the current History spacing 8, and the real HISTORY route requires all non-empty rows +to render with its setup value of 8. + +The canonical opcode and EngineCtx sources are regenerated. The `/v2` handler and all supporting +initializer/line-advance functions are named and commented, the expanded 58-field `EngineCtx` is reapplied, +and the program is saved. HISTORY is now 75/78 distinct opcodes and 851/854 instructions handled or +safe-noop. The remaining instructions are paired sprite-animation service ops `0x1ce`/`0x20a` and the +deferred Read-message Skip setting getter `0x1cb`. + +**Next:** investigate and implement `0x1ce`/`0x20a` together as the final non-persistence History support +slice. Keep `0x1cb` deferred until the global save/profile storage boundary is chosen. + +Validation: all 205 engine tests pass, including four focused History presentation cases; opcode and +EngineCtx tests/lints are clean, vm0 RECOVER passes, the Godot build has zero warnings, and threaded +`SELFTEST OK`. diff --git a/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs index e53c235..49ecd77 100644 --- a/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs +++ b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs @@ -17,7 +17,7 @@ public class HistoryPresentationOpsTests history.DefineLayout(1, 640, 160, 80, 430); history.AppendMetadata(9, 2, AdvTextStyle.Default); history.AppendText(0, 10, "retained dialogue", new AdvTextStyle(24, 8, false, - 0xffffff, 0x606060, 3, 1, 1)); + 0xffffff, 0x606060, 3, 1, 1, 9)); history.ResetLayout(1); history.AppendText(0, 11, "next group", AdvTextStyle.Default); history.SetRecordingEnabled(false); @@ -29,6 +29,7 @@ public class HistoryPresentationOpsTests { (0x198, new[] { I(4), I(65), I(150) }), (0x7a, new[] { I(4), I(45), I(42) }), + (0x8b, new[] { I(8) }), (0x1d1, new[] { I(4), I(0), I(0), I(0), I(0) }), (0x2, Array.Empty()), }, Array.Empty()); @@ -41,8 +42,9 @@ public class HistoryPresentationOpsTests Assert.Equal((4, 0, "retained dialogue"), (render.LayoutSlot, render.FirstRecordIndex, render.Text)); Assert.Equal(new AdvTextLayoutSnapshot(4, 600, 150, 65, 150, 45, 42), render.Layout); - Assert.Equal((24, 0xffffffL, 0x606060L), - (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor)); + Assert.Equal((24, 0xffffffL, 0x606060L, 8), + (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor, + render.Style.LineSpacing)); } [Fact] @@ -97,6 +99,8 @@ public class HistoryPresentationOpsTests Assert.NotEqual("step-cap", result.Halt); Assert.Contains(host.HistoryRenders, render => render.Text.Length > 0); + Assert.All(host.HistoryRenders.Where(render => render.Text.Length > 0), + render => Assert.Equal(8, render.Style.LineSpacing)); Assert.Contains(host.PresentedRanges, range => range == (0L, 60000L)); } } diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs index 3bc1a24..201437f 100644 --- a/engine/Age.Engine/Model/AdvTextHistory.cs +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -26,9 +26,10 @@ public readonly record struct AdvTextStyle( long EffectColor, int RenderMode, int EffectOffsetX, - int EffectOffsetY) + int EffectOffsetY, + int LineSpacing) { - public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0); + public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0, 6); } /// A stable snapshot of the layout state associated with a retained record. diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index ca91ec4..107ab4a 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -868,7 +868,11 @@ public sealed class VirtualMachine int flags = (int)Read(a[2]); if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch( (int)Read(a[0]), (int)Read(a[1]), flags, Read(a[3]), Read(a[4]), out var batch)) - _host.RenderTextHistory(batch); + _host.RenderTextHistory(batch with + { + // Native History uses the text manager's current leading, not a retained-record field. + Style = batch.Style with { LineSpacing = _advTextStyle.LineSpacing } + }); return pc + 1; } case "u0041BB90": @@ -891,6 +895,9 @@ public sealed class VirtualMachine _advTextStyle = _advTextStyle with { PrimaryFontSize = (int)Read(a[0]) }; return pc + 1; case "set-ruby-font-size": _advTextStyle = _advTextStyle with { RubyFontSize = (int)Read(a[0]) }; return pc + 1; + case "u0041B3D0": + case "set-text-line-spacing": + _advTextStyle = _advTextStyle with { LineSpacing = (int)Read(a[0]) }; return pc + 1; case "set-font-bold": _advTextStyle = _advTextStyle with { Bold = Read(a[0]) != 0 }; return pc + 1; case "set-text-color": diff --git a/godot/Main.cs b/godot/Main.cs index e99f8c2..3330471 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -630,6 +630,7 @@ public partial class Main : Godot.Control label.AddThemeFontSizeOverride("font_size", fontSize); label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White)); label.AddThemeColorOverride("font_outline_color", RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f))); + label.AddThemeConstantOverride("line_spacing", style.LineSpacing); int outline = style.RenderMode == 0 ? 0 : System.Math.Max(1, System.Math.Max(System.Math.Abs(style.EffectOffsetX), System.Math.Abs(style.EffectOffsetY))); label.AddThemeConstantOverride("outline_size", outline); diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 3c08988..43a7419 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -9,6 +9,7 @@ INFERRED: dict[int, dict] = { 0x86: dict(name='set-cursor-resource', category='input', noop=False, confidence='high', source='investigation', summary='(resource_id) - load an indexed cursor asset and install it as the active custom cursor.'), 0x87: dict(name='clear-cursor-resource', category='input', noop=False, confidence='high', source='investigation', summary='Clear the active custom cursor and refresh the OS cursor when the game window is active.'), 0x88: dict(name='set-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set persistent all-message Skip state. Nonzero makes the interpreter inject ADV fast-forward input every tick; zero stops injection and clears the transient skip run-state bit.'), + 0x8b: dict(name='set-text-line-spacing', category='adv', noop=False, confidence='high', source='investigation', summary='(pixels) - set the extra pixel spacing added between text lines.'), 0x90: dict(name='register-hotspot-callbacks', category='input', noop=True, confidence='high', source='investigation', summary="(x)(y)(w)(h)(on_enter_pc)(on_leave_pc)(on_activate_pc) - append an inclusive cursor rectangle and three callback PCs to the current script frame's native input registry. This opcode only registers; the ADV input service dispatches callbacks later."), 0x93: dict(name='cancel-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary="Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows."), 0x94: dict(name='arm-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary='Arm native hotspot input processing after the script has registered its rectangles.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 0a716fd..bddd91a 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -58,6 +58,11 @@ name = "gfx_obj_ptr_table" type = "void*" note = "per-object pointer table (ops 0x212/0x213 write obj+0x64/0x68/0x6c)" [[field]] +offset = 0x14ea0 +name = "text_line_spacing" +type = "int" +note = "extra pixel leading between text lines; defaults to 6, op 0x8b writes it" +[[field]] offset = 0x14f45 name = "script_frame_index" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index cedbdd8..29d5adf 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1405,18 +1405,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041B3D0" -category = "unknown" -summary = "" +name = "set-text-line-spacing" +category = "adv" +summary = "(pixels) - set the extra pixel spacing added between text lines." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x8b_set_text_line_spacing@0x41f270 stores operand 1 at text manager +0x560 (ctx+0x14ea0). adv_text_manager_initialize@0x456800 defaults it to 6. Horizontal newline/history paths at 0x456fd0/0x452970 advance y by (+0x560 - primary LOGFONT.lfHeight); lfHeight is negative, so this is font pixel height plus configured leading. Corpus arguments are 8 for 22/24px Mincho blocks and 9 for 16px Gothic blocks." [[opcode.semantics.args]] i = 1 -role = "" +role = "extra line spacing in pixels" observed_types = ["imm"] [[opcode]]