Implement deployment card numeric rendering

This commit is contained in:
gamer147
2026-07-21 17:31:57 -04:00
parent 56cafeb9f2
commit cc6db721db
7 changed files with 175 additions and 17 deletions

View File

@@ -2539,6 +2539,30 @@ 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 case. The full 281-test engine suite, zero-warning Godot build, and threaded frontend selftest pass; manual
DEBUGMAP and developer-menu visual rechecks remain. DEBUGMAP and developer-menu visual rechecks remain.
### 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
unit-information card is built by `DRAWENP.BIN` on temporary surface `0x51`: ordinary `0x204` calls draw
labels and separators such as `LV` and `/`, while 16 calls to previously skipped opcode `0x205` draw the
level, current/maximum HP/SP/FS, and the two stat columns. This explains why the values alone were absent
even after the separate `0x13a`/`0x23b` retained-glyph HUD path was restored.
`op_0x205_handler@0x422ab0` takes
`(surface_slot, x, y, value, field_width, flags)`. It records the 13-dword instruction length, calls
`format_integer_for_surface@0x407190`, and sends the returned string and adjusted x coordinate to the same
`draw_string_to_surface@0x450150` worker used by opcode `0x204`. The field width includes an optional sign.
Flag bit `0x1` zero-pads, `0x2` centers omitted leading cells, and `0x4` left-aligns; the default keeps the
field's right edge fixed by moving x right for omitted cells. Bits `0x8`/`0x10` request a plus sign for a
positive/zero value, while bit `0x20` gives zero a minus sign. Bit `0x10000`, used by every `DRAWENP` site,
keeps half-width ASCII and uses half the current font-cell advance; without it,
`ascii_to_fullwidth_cp932_inplace@0x417800` converts the formatted field to full-width CP932.
The VM now routes the formatted result through the existing styled surface-text host path, preserving the
font, color, and effect changes surrounding each call. Focused tests reproduce DRAWENP's exact level-80
call and cover half-width right alignment, zero padding, and the observed full-width left-aligned variant.
DRAWENP is now 36/36 opcodes and 611/611 instructions handled; all 284 engine tests, the zero-warning Godot
build, and threaded frontend selftest pass.
--- ---
## Native walls backlog (targets for this loop) ## Native walls backlog (targets for this loop)

View File

@@ -30,6 +30,7 @@
### 0x75 `set-font-size` (set-font-size, argc 1) ### 0x75 `set-font-size` (set-font-size, argc 1)
- **summary:** (pixels) - set the primary text font height and rebuild its native rasterization state. - **summary:** (pixels) - set the primary text font height and rebuild its native rasterization state.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **depended on by:** 0x205
- **evidence:** Ghidra /v2: op_0x75_handler@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, derives width, and rebuilds font resources. HISTORY.BIN selects 22 pixels while active and restores 24 on exit. - **evidence:** Ghidra /v2: op_0x75_handler@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, derives width, and rebuilds font resources. HISTORY.BIN selects 22 pixels while active and restores 24 on exit.
### 0x76 `set-text-color` (set-text-color, argc 1) ### 0x76 `set-text-color` (set-text-color, argc 1)
@@ -127,6 +128,7 @@
### 0x204 `draw-string` (draw-string, argc 4) ### 0x204 `draw-string` (draw-string, argc 4)
- **summary:** (surface_slot)(x)(y)(string) - rasterize a CP932 string immediately into a numbered graphics surface using current font/color/effect state. - **summary:** (surface_slot)(x)(y)(string) - rasterize a CP932 string immediately into a numbered graphics surface using current font/color/effect state.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **depended on by:** 0x205
- **evidence:** Ghidra /v2: op_0x204_handler@0x422a60 resolves operand 4 as a string, fetches surface/x/y, then calls draw_string_to_surface@0x450150 on text manager ctx+0x14940. The worker validates and locks gfx-manager surface table +0xa590[slot], chooses text_raster_string_uncached@0x459d90 or cached/effect path @0x45b600, rasterizes GDI GetGlyphOutlineA bitmaps through text_blit_glyph_bitmap@0x458c80 using font/color state +0x4d0/+0x458, then unlocks. SC0000 0x9b2 draws the speaker name into 400x30 surface 0xd at (1,1); following 0x1fb binds it to retained object 0xe678 at (74,444). - **evidence:** Ghidra /v2: op_0x204_handler@0x422a60 resolves operand 4 as a string, fetches surface/x/y, then calls draw_string_to_surface@0x450150 on text manager ctx+0x14940. The worker validates and locks gfx-manager surface table +0xa590[slot], chooses text_raster_string_uncached@0x459d90 or cached/effect path @0x45b600, rasterizes GDI GetGlyphOutlineA bitmaps through text_blit_glyph_bitmap@0x458c80 using font/color state +0x4d0/+0x458, then unlocks. SC0000 0x9b2 draws the speaker name into 400x30 surface 0xd at (1,1); following 0x1fb binds it to retained object 0xe678 at (74,444).
### 0x20a `publish-adv-text-layout` (u00420CE0, argc 1) ### 0x20a `publish-adv-text-layout` (u00420CE0, argc 1)
@@ -498,6 +500,14 @@ The handler clears the map embedded at retained-gfx owner+0x408, resets its coun
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9. Correct D3D9 constants at 0x4774c0 prove mode 1 writes SRCBLEND=5/SRCALPHA and DESTBLEND=2/ONE, not INVSRCALPHA. TITLE draws two opaque-black, no-colorkey SO022 flame sprites with mode 1; additive composition removes black and preserves the blue glow, verified in a windowed capture. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white. - **evidence:** Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9. Correct D3D9 constants at 0x4774c0 prove mode 1 writes SRCBLEND=5/SRCALPHA and DESTBLEND=2/ONE, not INVSRCALPHA. TITLE draws two opaque-black, no-colorkey SO022 flame sprites with mode 1; additive composition removes black and preserves the blue glow, verified in a windowed capture. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white.
### 0x205 `draw-formatted-integer` (u00420A60, argc 6)
- **summary:** Format an integer into a fixed-width decimal field and rasterize it immediately into a numbered graphics surface using the current font/color/effect state.
- **grounding:** source=investigation, confidence=high
- **depends on:** 0x75, 0x204
- **evidence:** Ghidra /v2: op_0x205_handler@0x422ab0 fetches (surface,x,y,value,width,flags), calls format_integer_for_surface@0x407190, then draw_string_to_surface@0x450150. DRAWENP contains all 16 previously stubbed calls responsible for its level, HP/SP/FS, and stat values.
The field width includes an optional sign. Bit 0 zero-pads; bit 1 centers omitted leading cells; bit 2 left-aligns; bits 3/4 request '+' for positive/zero; bit 5 renders zero with '-'. With bit 16, output remains half-width ASCII and omitted-cell x adjustment uses half the current font cell advance; otherwise the formatter converts digits/signs to full-width CP932. Default alignment preserves the field's right edge by shifting x right for each omitted leading cell.
### 0x208 `get-texture-size` (get-texture-size, argc 3) ### 0x208 `get-texture-size` (get-texture-size, argc 3)
- **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649) - **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649)
- **grounding:** source=inference, confidence=med - **grounding:** source=inference, confidence=med
@@ -1224,10 +1234,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
### 0x205 `u00420A60` (u00420A60, argc 6)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x207 `u00420B00` (u00420B00, argc 8) ### 0x207 `u00420B00` (u00420B00, argc 8)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -467,6 +467,16 @@ focused CP932/NUL, operand-resolution, and stale-destination regressions. All 28
Godot build, and threaded selftest pass. The next bounded action is a visual recheck of the DEBUGMAP popup and Godot build, and threaded selftest pass. The next bounded action is a visual recheck of the DEBUGMAP popup and
TITLE developer menu. TITLE developer menu.
The subsequent DEBUGMAP deployment-picker comparison exposed a separate numeric renderer in
`DRAWENP.BIN`. Its red unit-information card draws labels and `/` separators through `0x204`, but draws
level, HP/SP/FS pairs, and both stat columns through 16 calls to opcode `0x205`. Native `0x205` formats a
fixed-width signed decimal field, applies zero-pad/alignment/half-width flags, adjusts the x anchor for
suppressed leading cells, and rasterizes the result into the same temporary surface using current text
style. The shared VM implementation now covers that path; focused tests use DRAWENP's exact level call and
the observed half-width, zero-padded, and full-width variants. DRAWENP is now 611/611 instructions handled;
all 284 engine tests, the zero-warning Godot build, and threaded frontend selftest pass. A manual
deployment-card visual recheck remains.
## Later Phase B breadth ## Later Phase B breadth
Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks:

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class FormattedIntegerSurfaceOpsTests
{
[Fact]
public void DrawEnpLevelPatternDrawsHalfWidthValueAtRightAlignedPosition()
{
var host = Run(
(0x75, new[] { I(18) }),
(0x205, new[] { I(0x51), I(0x3f), I(0x3c), I(80), I(3), I(0x10000) }));
Assert.Equal((0x51, 0x48, 0x3c, "80"), Assert.Single(host.SurfaceStrings));
}
[Fact]
public void ZeroPaddingConsumesTheWholeFieldWithoutMovingTheAnchor()
{
var host = Run(
(0x75, new[] { I(18) }),
(0x205, new[] { I(0x51), I(100), I(20), I(7), I(3), I(0x10001) }));
Assert.Equal((0x51, 100, 20, "007"), Assert.Single(host.SurfaceStrings));
}
[Fact]
public void FullWidthLeftAlignedFormattingUsesCp932GlyphForms()
{
var host = Run(
(0x75, new[] { I(22) }),
(0x205, new[] { I(0x2a), I(40), I(22), I(61), I(3), I(0x04) }));
Assert.Equal((0x2a, 40, 22, ""), Assert.Single(host.SurfaceStrings));
}
private static RecordingHost Run(params (int Opcode, Operand[] Args)[] instructions)
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var body = new List<(int, Operand[])>(instructions) { (0x2, Array.Empty<Operand>()) };
var script = ScriptAssembler.Assemble(table, "FORMAT_INTEGER", body, Array.Empty<string>());
var host = new RecordingHost();
new VirtualMachine(script, table, host).Run();
return host;
}
private static Operand I(long value) => new(0, value);
}

View File

@@ -327,6 +327,60 @@ public sealed class VirtualMachine
return _nativeStringEncoding.GetByteCount(nul < 0 ? value : value[..nul]); return _nativeStringEncoding.GetByteCount(nul < 0 ? value : value[..nul]);
} }
private string FormatIntegerForSurface(ref int x, int value, int fieldWidth, int flags)
{
int width = Math.Max(0, fieldWidth);
var field = new char[Math.Max(1, width)];
bool signed = value < 0
|| (value > 0 && (flags & 0x08) != 0)
|| (value == 0 && (flags & 0x30) != 0);
char sign = value < 0 || (value == 0 && (flags & 0x10) == 0 && (flags & 0x20) != 0) ? '-' : '+';
int digitSlots = width - (signed ? 1 : 0);
int first = 0;
long magnitude = value < 0 ? -(long)value : value;
for (int pos = digitSlots - 1; pos >= 0; pos--)
{
if (pos == digitSlots - 1 || magnitude != 0 || (flags & 0x01) != 0)
{
field[pos + (signed ? 1 : 0)] = (char)('0' + magnitude % 10);
first = pos;
}
magnitude /= 10;
}
if (signed)
field[first] = digitSlots < 0 ? '#' : sign;
int length = first;
while (length < field.Length && field[length] != '\0') length++;
string text = new(field, first, length - first);
// Native uses the primary LOGFONT cell height as a fixed-width advance, halved for ASCII.
// Odd heights (and the engine's 32/33-pixel special cases) are rounded down on font rebuild.
int fontSize = _advTextStyle.PrimaryFontSize > 0 ? _advTextStyle.PrimaryFontSize : 24;
int cellAdvance = fontSize == 33 ? 31
: fontSize == 32 || (fontSize & 1) != 0 ? fontSize - 1
: fontSize;
if ((flags & 0x04) == 0)
{
int divisor = (flags & 0x10000) != 0 ? 2 : 1;
if ((flags & 0x02) != 0) divisor *= 2;
x += (cellAdvance * first) / divisor;
}
if ((flags & 0x10000) == 0)
text = string.Concat(text.Select(c => c switch
{
>= '0' and <= '9' => (char)('' + c - '0'),
'-' => '',
>= 'A' and <= 'Z' => (char)('' + c - 'A'),
>= 'a' and <= 'z' => (char)('' + c - 'a'),
_ => '',
}));
return text;
}
private static VmAddress Ga(Dictionary<int, VmAddress> d, int k) private static VmAddress Ga(Dictionary<int, VmAddress> d, int k)
=> d.TryGetValue(k, out var value) ? value : VmAddress.Global(0); => d.TryGetValue(k, out var value) ? value : VmAddress.Global(0);
@@ -977,6 +1031,15 @@ public sealed class VirtualMachine
_host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]), _host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]),
_advTextStyle); _advTextStyle);
return pc + 1; return pc + 1;
case "u00420A60": // pre-reference compatibility
case "draw-formatted-integer": // 0x205 (surface slot, x, y, value, field width, flags)
{
int x = (int)Read(a[1]);
string text = FormatIntegerForSurface(
ref x, unchecked((int)Read(a[3])), (int)Read(a[4]), (int)Read(a[5]));
_host.DrawStringToSurface((int)Read(a[0]), x, (int)Read(a[2]), text, _advTextStyle);
return pc + 1;
}
case "wait-for-input": case "wait-for-input":
// Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions).
if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; }

View File

@@ -66,6 +66,7 @@ INFERRED: dict[int, dict] = {
0x1fd: dict(name='gfx-set-scale-current', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(scale_x_percent)(scale_y_percent)(scale_z_percent) — immediately replace the retained object's current scale matrix at obj+0x6c. The handler divides each integer operand by 100.0 before calling matrix4_make_scale; this is distinct from 0x21e's delayed one-shot target scale."), 0x1fd: dict(name='gfx-set-scale-current', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(scale_x_percent)(scale_y_percent)(scale_z_percent) — immediately replace the retained object's current scale matrix at obj+0x6c. The handler divides each integer operand by 100.0 before calling matrix4_make_scale; this is distinct from 0x21e's delayed one-shot target scale."),
0x1fe: dict(name='set-current-rotation-axis-angle', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(axis_x)(axis_y)(axis_z)(angle_degrees) — immediately replace the retained object's current axis-angle rotation matrix. This is the direct-current companion to op 0x21f's delayed one-shot rotation target."), 0x1fe: dict(name='set-current-rotation-axis-angle', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(axis_x)(axis_y)(axis_z)(angle_degrees) — immediately replace the retained object's current axis-angle rotation matrix. This is the direct-current companion to op 0x21f's delayed one-shot rotation target."),
0x1ff: dict(name='set-current-translation', category='draw', noop=False, confidence='high', source='investigation', summary="0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac."), 0x1ff: dict(name='set-current-translation', category='draw', noop=False, confidence='high', source='investigation', summary="0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac."),
0x205: dict(name='draw-formatted-integer', category='draw', noop=False, confidence='high', source='investigation', summary='Format an integer into a fixed-width decimal field and rasterize it immediately into a numbered graphics surface using the current font/color/effect state.'),
0x20a: dict(name='publish-adv-text-layout', category='adv', noop=False, confidence='high', source='investigation', summary='(layout_slot) - republish one ADV text layout and, while active, its current wait-indicator frame.'), 0x20a: dict(name='publish-adv-text-layout', category='adv', noop=False, confidence='high', source='investigation', summary='(layout_slot) - republish one ADV text layout and, while active, its current wait-indicator frame.'),
0x21b: dict(name='line-id?', category='marker', noop=True, confidence='med', source='harness', summary='1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control'), 0x21b: dict(name='line-id?', category='marker', noop=True, confidence='med', source='harness', summary='1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control'),
0x228: dict(name='query-gfx-translation-target', category='draw', noop=False, confidence='high', source='investigation', summary='0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster.'), 0x228: dict(name='query-gfx-translation-target', category='draw', noop=False, confidence='high', source='investigation', summary='0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster.'),

View File

@@ -4920,43 +4920,44 @@ argc = 6
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00420A60" name = "draw-formatted-integer"
category = "unknown" category = "draw"
summary = "" summary = "Format an integer into a fixed-width decimal field and rasterize it immediately into a numbered graphics surface using the current font/color/effect state."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] depends_on = [0x75, 0x204]
evidence = "" evidence = "Ghidra /v2: op_0x205_handler@0x422ab0 fetches (surface,x,y,value,width,flags), calls format_integer_for_surface@0x407190, then draw_string_to_surface@0x450150. DRAWENP contains all 16 previously stubbed calls responsible for its level, HP/SP/FS, and stat values."
details = "The field width includes an optional sign. Bit 0 zero-pads; bit 1 centers omitted leading cells; bit 2 left-aligns; bits 3/4 request '+' for positive/zero; bit 5 renders zero with '-'. With bit 16, output remains half-width ASCII and omitted-cell x adjustment uses half the current font cell advance; otherwise the formatter converts digits/signs to full-width CP932. Default alignment preserves the field's right edge by shifting x right for each omitted leading cell."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "destination surface slot"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "x"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "y"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 4 i = 4
role = "" role = "value"
observed_types = ["g-int", "l-int", "l-ptr"] observed_types = ["g-int", "l-int", "l-ptr"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 5 i = 5
role = "" role = "field width (characters, including sign)"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 6 i = 6
role = "" role = "format/alignment flags"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]