Implement numeric HUD rendering

This commit is contained in:
gamer147
2026-07-21 15:54:13 -04:00
parent fe1531097a
commit a93d3b64bf
6 changed files with 273 additions and 12 deletions

View File

@@ -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.
---

View File

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

View File

@@ -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<Operand>());
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<string>());
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<string>());
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<string>());
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<string>());
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
}
}

View File

@@ -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);
/// <summary>One EngineCtx numeric-glyph style registered by opcode 0x13a.</summary>
public readonly record struct NumericGlyphStyle(int SurfaceSlot, int AtlasX, int AtlasY,
int DigitWidth, int DigitHeight)
{
public bool Registered => SurfaceSlot != 0;
}
/// <summary>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 <see cref="GfxState.SnapshotVisibleObjects"/>; 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<long, GfxObject> _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
}
}
/// <summary>Op 0x13a: replace one of the native engine's eleven decimal-glyph atlas styles.</summary>
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;
}
}
/// <summary>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.</summary>
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;
}
}
/// <summary>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.</summary>
public void SetCurrentScale(long handle, (long X, long Y, long Z) percent)

View File

@@ -1,6 +1,7 @@
using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using System.Text;
namespace Age.Engine.Vm;
/// <summary>A stable identity/snapshot of the exact script frame currently executing.</summary>
@@ -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();
}
/// <summary>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.</summary>
@@ -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<int, VmAddress> 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]));

View File

@@ -8,5 +8,8 @@ namespace Age.Engine.Vm;
/// (Godot) and for the dialogue-coverage sweep that deliberately walks every page.</param>
/// <param name="IgnoreExitRequests">Debug-only divergence: treat op 0x1 as a no-op so unreachable
/// post-exit bytecode can be explored. Leave false for native-faithful execution.</param>
/// <param name="NativeStringCodePage">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.</param>
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);