From a93d3b64bf7b8ced70819e49523f9f5eaf7c012f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 15:54:13 -0400 Subject: [PATCH] Implement numeric HUD rendering --- docs/engine-re.md | 12 +- docs/phase-b-framework.md | 12 +- .../Age.Engine.Tests/NumericGlyphOpsTests.cs | 132 ++++++++++++++++++ engine/Age.Engine/Model/GfxState.cs | 77 ++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 47 ++++++- engine/Age.Engine/Vm/VmOptions.cs | 5 +- 6 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 engine/Age.Engine.Tests/NumericGlyphOpsTests.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index 9975b2b..c5c3eb2 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2488,16 +2488,20 @@ the port: them for the field HUD's turn/control/mana/level/HP/SP/FS values. This path creates ordinary retained graphics objects, so the existing atlas decode and compositor are -already the correct backend. The missing engine model is the 11 style records plus the two VM dispatches; -it is not an immediate `GodotAdvHost.DrawTexture` raster operation. +the correct backend; it is not an immediate `GodotAdvHost.DrawTexture` raster operation. The port now +models all 11 EngineCtx style records in `GfxState` and implements both dispatches. Each `0x23b` call erases +its full destination-handle capacity and then uses the ordinary +`BindDraw` path for every displayed digit, preserving surface replacement, z-order, and compositor effects. The absent unit and weapon names are a separate layout-compute gap. `DRAWCHP.BIN` does populate both strings and calls `draw-string` at `0x9c6`/`0x9f5`, but first centers each one with opcode `0x1a6`. `op_0x1a6_half_byte_strlen@0x427020` resolves the NUL-terminated engine byte string and writes `strlen(bytes) >> 1`. The script multiplies that result by 21 and subtracts it from x=257 on a 263-pixel scratch surface. With the opcode skipped, x remains 257 and Godot correctly clips nearly all of the text. -An implementation must preserve the original encoded byte count (or reproduce it with CP932 encoding), -not use the .NET UTF-16 character count. +The port reproduces the encoded byte count rather than using the .NET UTF-16 character count. The VM's +native-string code page is configurable for other container frontends and defaults to SYS4's CP932; the +calculation also stops at an embedded NUL before shifting. Focused tests cover CP932 mixed-width strings, +all three numeric layout flags, zero padding, retained-object replacement, and invalid/unregistered styles. --- diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index e7f83bb..dbe489c 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -441,15 +441,17 @@ bounded opcode gaps rather than missing game state: - `0x13a` registers one of 11 `(surface, atlas x/y, digit width/height)` styles, and `0x23b` expands a decimal value into retained per-digit objects with zero-pad/center/left/right layout flags. `DRAWCHP.BIN` contains eight style registrations and 22 numeric draws covering the visible turn/control/mana/level and - HP/SP/FS fields. Both opcodes are currently unimplemented, which precisely explains the blank values. + HP/SP/FS fields. Skipping both opcodes precisely explained the blank values. - `DRAWCHP.BIN` already reads and submits the unit and weapon strings to `0x204`, but its two preceding - `0x1a6` centering calls are unimplemented. Native `0x1a6` returns `strlen(CP932_bytes) >> 1`; skipping it + `0x1a6` centering calls were unimplemented. Native `0x1a6` returns `strlen(CP932_bytes) >> 1`; skipping it places the strings at x=257 on a 263-pixel scratch surface, so the existing compositor clips them. The exact native contracts and addresses live in `docs/engine-re.md`; opcode-source metadata is in -`vm-map/opcodes.toml`. No runtime implementation was made in this investigation. The next field slice is -therefore to implement the shared 11-style numeric renderer and the encoded-byte-length calculation, then -recheck the same `DEBUGMAP` HUD before pursuing any state seeding. +`vm-map/opcodes.toml`. The shared implementation is now landed in the VM/retained-graphics model: the style +registry holds the 11 EngineCtx records, decimal glyphs become ordinary retained objects, and `0x1a6` +measures the configured native encoding (CP932 for SYS4). Focused coverage plus the full 278-test engine +suite, zero-warning Godot build, and threaded frontend selftest pass. The next field action is the manual +visual recheck of the same `DEBUGMAP` HUD before pursuing any state seeding. ## Later Phase B breadth diff --git a/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs b/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs new file mode 100644 index 0000000..efe3556 --- /dev/null +++ b/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class NumericGlyphOpsTests +{ + private static OpcodeTable T() => OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(0, value); + private static Operand S(int index) => new(2, index); + private static Operand G(int address) => new(3, address); + private static Operand L(int address) => new(9, address); + private static Operand LS(int address) => new(11, address); + private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); + + private static VirtualMachine RunNumericDraw(int value, int capacity, int flags) + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "NUMERIC-GLYPHS", new List<(int, Operand[])> + { + (0x1f9, new[] { I(0x123), I(0x48), I(0) }), + (0x13a, new[] { I(0), I(0x48), I(0x189), I(0x165), I(22), I(30) }), + (0x23b, new[] { I(1000), I(0), I(value), I(100), I(20), I(capacity), I(flags) }), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, table, new RecordingHost()); + vm.Run(); + return vm; + } + + [Fact] + public void DecimalGlyphDrawRightAlignsAndBindsLeastSignificantDigitFirst() + { + var vm = RunNumericDraw(42, 3, 0); + + Assert.Equal("exit", vm.HaltReason); + var visible = vm.Gfx.SnapshotVisibleObjects(); + Assert.Equal(2, visible.Count); + Assert.Equal((1000L, 0x189 + 2 * 22, 144), + (visible[0].Handle, visible[0].SrcX, visible[0].DstX)); + Assert.Equal((1001L, 0x189 + 4 * 22, 122), + (visible[1].Handle, visible[1].SrcX, visible[1].DstX)); + Assert.All(visible, item => + { + Assert.Equal(0x123, item.SurfaceResId); + Assert.Equal((0x165, 22, 30, 20), (item.SrcY, item.W, item.H, item.DstY)); + }); + } + + [Theory] + [InlineData(1, 3, 144, 122, 100)] // zero padded, right aligned + [InlineData(4, 2, 122, 100, -1)] // left aligned to the used width + [InlineData(2, 2, 133, 111, -1)] // centered within the three-digit capacity + public void DecimalGlyphFlagsMatchNativePlacement(int flags, int expectedCount, + int firstX, int secondX, int thirdX) + { + var visible = RunNumericDraw(42, 3, flags).Gfx.SnapshotVisibleObjects(); + + Assert.Equal(expectedCount, visible.Count); + Assert.Equal(firstX, visible[0].DstX); + Assert.Equal(secondX, visible[1].DstX); + if (thirdX >= 0) Assert.Equal(thirdX, visible[2].DstX); + } + + [Fact] + public void RedrawingAShorterValueErasesStaleDigitObjects() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "NUMERIC-REDRAW", new List<(int, Operand[])> + { + (0x1f9, new[] { I(0x123), I(7), I(0) }), + (0x13a, new[] { I(2), I(7), I(10), I(20), I(8), I(12) }), + (0x23b, new[] { I(200), I(2), I(999), I(5), I(6), I(3), I(0) }), + (0x23b, new[] { I(200), I(2), I(7), I(5), I(6), I(3), I(0) }), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, table, new RecordingHost()); + + vm.Run(); + + var visible = Assert.Single(vm.Gfx.SnapshotVisibleObjects()); + Assert.Equal((200L, 5 + 2 * 8, 10 + 7 * 8), (visible.Handle, visible.DstX, visible.SrcX)); + } + + [Fact] + public void InvalidOrUnregisteredStylesHaltInsteadOfSilentlyDroppingTheDraw() + { + var table = T(); + var invalidRegistration = ScriptAssembler.Assemble(table, "BAD-NUMERIC-STYLE", + new List<(int, Operand[])> + { + (0x13a, new[] { I(11), I(1), I(0), I(0), I(8), I(12) }), + Exit(), + }, System.Array.Empty()); + var unregisteredDraw = ScriptAssembler.Assemble(table, "MISSING-NUMERIC-STYLE", + new List<(int, Operand[])> + { + (0x23b, new[] { I(1), I(0), I(7), I(0), I(0), I(1), I(0) }), + Exit(), + }, System.Array.Empty()); + + var invalidVm = new VirtualMachine(invalidRegistration, table, new RecordingHost()); + var unregisteredVm = new VirtualMachine(unregisteredDraw, table, new RecordingHost()); + invalidVm.Run(); + unregisteredVm.Run(); + + Assert.Equal("numeric-glyph-style-index-out-of-range:11", invalidVm.HaltReason); + Assert.Equal("numeric-glyph-style-unregistered:0", unregisteredVm.HaltReason); + } + + [Fact] + public void HalfByteStringLengthUsesNativeCp932BytesForLiteralAndLocalStrings() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "HALF-BYTE-LENGTH", new List<(int, Operand[])> + { + (0x1a6, new[] { L(0), S(0) }), + (0x55, new[] { G(100), L(0) }), + (0x55, new[] { LS(0), S(1) }), + (0x1a6, new[] { L(1), LS(0) }), + (0x55, new[] { G(101), L(1) }), + Exit(), + }, new[] { "AB姫", "リリィ" }); + var vm = new VirtualMachine(scene, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(2, vm.Globals[100]); // four CP932 bytes, not three .NET chars + Assert.Equal(3, vm.Globals[101]); // six CP932 bytes + } +} diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 305b41c..5fb615c 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -24,6 +24,13 @@ public readonly record struct SurfaceTransitionState(long CommandKey, int Target public readonly record struct ColorTransitionState(long Current, long Target, long DelayMs, long DurationMs, long StartMs, double Progress, bool Active); +/// One EngineCtx numeric-glyph style registered by opcode 0x13a. +public readonly record struct NumericGlyphStyle(int SurfaceSlot, int AtlasX, int AtlasY, + int DigitWidth, int DigitHeight) +{ + public bool Registered => SurfaceSlot != 0; +} + /// A renderable view of one visible gfx object — the host composites these in ascending-handle order /// (= the engine's z-order) each frame. Built by ; the surface /// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md, @@ -119,6 +126,7 @@ public sealed class GfxState // Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet. private readonly Dictionary _objects = new(); + private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11]; // Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled // matrix is post-multiplied onto only the selected handle range during native composition. FIELD uses @@ -626,6 +634,75 @@ public sealed class GfxState } } + /// Op 0x13a: replace one of the native engine's eleven decimal-glyph atlas styles. + public bool RegisterNumericGlyphStyle(int styleIndex, int surfaceSlot, int atlasX, int atlasY, + int digitWidth, int digitHeight) + { + lock (_lock) + { + if ((uint)styleIndex >= (uint)_numericGlyphStyles.Length) return false; + _numericGlyphStyles[styleIndex] = + new NumericGlyphStyle(surfaceSlot, atlasX, atlasY, digitWidth, digitHeight); + return true; + } + } + + /// Op 0x23b: erase a destination handle range, split a signed 32-bit value into decimal + /// digits, and bind one retained object per displayed atlas cell. Returns false for an invalid or + /// unregistered style, matching the native handler's script-error path. + public bool DrawDecimalGlyphs(long baseHandle, int styleIndex, int value, int x, int y, + int digitCapacity, int flags) + { + lock (_lock) + { + if ((uint)styleIndex >= (uint)_numericGlyphStyles.Length + || !_numericGlyphStyles[styleIndex].Registered) return false; + + NumericGlyphStyle style = _numericGlyphStyles[styleIndex]; + EraseRange(baseHandle, digitCapacity); + + int remaining = value; + int handleOffset = 0; + int digitCount = 1; + for (int n = remaining / 10; n != 0; n /= 10) digitCount++; + + for (int slot = digitCapacity - 1; slot >= 0; slot--) + { + int digit = remaining % 10; + int drawX; + bool draw; + + if ((flags & 0x2) != 0) + { + draw = slot == digitCapacity - 1 || remaining != 0; + drawX = unchecked(x + style.DigitWidth * slot + - (digitCapacity - digitCount) * style.DigitWidth / 2); + } + else if ((flags & 0x4) != 0) + { + digitCount--; + draw = slot == digitCapacity - 1 || remaining != 0; + drawX = unchecked(x + style.DigitWidth * digitCount); + } + else + { + draw = (flags & 0x1) != 0 || slot == digitCapacity - 1 || remaining != 0; + drawX = unchecked(x + style.DigitWidth * slot); + } + + if (draw) + { + int srcX = unchecked(style.AtlasX + digit * style.DigitWidth); + BindDraw(baseHandle + handleOffset, style.SurfaceSlot, + srcX, style.AtlasY, style.DigitWidth, style.DigitHeight, drawX, y); + handleOffset++; + } + remaining /= 10; + } + return true; + } + } + /// Op 0x1fd: immediately replace the object's current scale matrix. Script operands are /// integer percentages; native divides them by 100 before matrix4_make_scale at obj+0x6c. public void SetCurrentScale(long handle, (long X, long Y, long Z) percent) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index d4fbd49..483820c 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1,6 +1,7 @@ using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; +using System.Text; namespace Age.Engine.Vm; /// A stable identity/snapshot of the exact script frame currently executing. @@ -22,6 +23,7 @@ public sealed class VirtualMachine private readonly OpcodeTable _t; private readonly IHost _host; private readonly VmOptions _o; + private readonly Encoding _nativeStringEncoding; private readonly IScriptProvider? _provider; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private ExecFrame _cur = null!; @@ -87,8 +89,12 @@ public sealed class VirtualMachine public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null, ITraceSink? sink = null, AdvTextHistory? textHistory = null) - { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; - _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); } + { + _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + _nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage); + _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); + } /// Queue global writes and return only the identified active frame at its next opcode boundary. /// Writes are copied here and applied by the VM thread before another opcode executes. @@ -315,6 +321,12 @@ public sealed class VirtualMachine } } + private int NativeStringByteLength(string value) + { + int nul = value.IndexOf('\0'); + return _nativeStringEncoding.GetByteCount(nul < 0 ? value : value[..nul]); + } + private static VmAddress Ga(Dictionary d, int k) => d.TryGetValue(k, out var value) ? value : VmAddress.Global(0); @@ -657,6 +669,9 @@ public sealed class VirtualMachine if (IsStr(a[0]) || IsStr(a[1])) WriteStr(a[0], ReadStr(a[1])); else Write(a[0], Read(a[1])); return pc + 1; + case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1 + Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1); + return pc + 1; case "lookup-array": LookupStore(a[0], BaseAddr(a[1]).Offset(Read(a[2]))); return pc + 1; case "lookup-array-2d": @@ -1347,6 +1362,34 @@ public sealed class VirtualMachine (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); return pc + 1; // IHost seam (oracle log; Godot no-ops) + case "u0041F3A0": + case "register-numeric-glyph-style": // 0x13a: (style)(surface)(atlas x/y)(digit w/h) + { + int styleIndex = unchecked((int)Read(a[0])); + if (!Gfx.RegisterNumericGlyphStyle(styleIndex, unchecked((int)Read(a[1])), + unchecked((int)Read(a[2])), unchecked((int)Read(a[3])), + unchecked((int)Read(a[4])), unchecked((int)Read(a[5])))) + { + HaltReason ??= $"numeric-glyph-style-index-out-of-range:{styleIndex}"; + return HALT; + } + return pc + 1; + } + case "u00422460": + case "draw-decimal-glyphs": // 0x23b: retained decimal glyph draw + { + int styleIndex = unchecked((int)Read(a[1])); + if (!Gfx.DrawDecimalGlyphs(Read(a[0]), styleIndex, unchecked((int)Read(a[2])), + unchecked((int)Read(a[3])), unchecked((int)Read(a[4])), + unchecked((int)Read(a[5])), unchecked((int)Read(a[6])))) + { + HaltReason ??= (uint)styleIndex >= 11 + ? $"numeric-glyph-style-index-out-of-range:{styleIndex}" + : $"numeric-glyph-style-unregistered:{styleIndex}"; + return HALT; + } + return pc + 1; + } case "get-texture-size": // 0x208 (slot) (out_w) (out_h) { var (gw, gh) = _host.GetTextureSize((int)Read(a[0])); diff --git a/engine/Age.Engine/Vm/VmOptions.cs b/engine/Age.Engine/Vm/VmOptions.cs index 7d8a200..a9a8d19 100644 --- a/engine/Age.Engine/Vm/VmOptions.cs +++ b/engine/Age.Engine/Vm/VmOptions.cs @@ -8,5 +8,8 @@ namespace Age.Engine.Vm; /// (Godot) and for the dialogue-coverage sweep that deliberately walks every page. /// Debug-only divergence: treat op 0x1 as a no-op so unreachable /// post-exit bytecode can be explored. Leave false for native-faithful execution. +/// Encoding used when an opcode measures the engine's byte-string +/// representation. SYS4 defaults to CP932; another container frontend can select its own code page. public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64, - bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false); + bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false, + int NativeStringCodePage = 932);