Fix GDI zero-ink glyph handling

This commit is contained in:
gamer147
2026-08-15 10:30:32 -04:00
parent d38511665a
commit 3289948ce7
5 changed files with 107 additions and 8 deletions

View File

@@ -71,6 +71,28 @@ When the escalation trigger is met:
End the escalation once the reproduced discrepancy and its relevant contract are explained. Do not expand it End the escalation once the reproduced discrepancy and its relevant contract are explained. Do not expand it
into reconstructing unused handlers, recovering the whole native engine, or producing a byte-matching C build. into reconstructing unused handlers, recovering the whole native engine, or producing a byte-matching C build.
### DEBUGMAP P008/P009 GDI worker-end regression corrected (2026-08-15)
A live `SYSTEM4 > FIELD > SC0270` run first reported `[vm] ended: unknown` while the locator still showed P008
(`SC0270@0x5b22`). The coordinate is an ordinary `wait-for-input`; its next instruction begins P009. A
corpus-backed regression and automated portable-text runs crossed that boundary, but those runs did not exercise
the exact Windows GDI rasterizer used by normal Windows play. The `unknown` message also hid the actionable
exception because the worker's `finally` set the completion flag while the main loop printed only the unset VM
halt reason. Godot now retains and prints the complete worker exception and automatically writes a
`worker-failure` diagnostic snapshot at the last traced script/opcode coordinate.
The next live failure and that snapshot localized the defect to P009's `show-text` at `SC0270@0x5bea`. This line
contains an ideographic full-width space (`U+3000`, CP932 `0x8140`). With ` 明朝` at the active 24-pixel
style, `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` returns the spacing glyph's advance and a nominal 1x1 metric box but
reports a zero-byte bitmap requirement. The adapter incorrectly required the reported buffer size to equal the
four-byte aligned metric box and threw before P009 could be published. It now treats only a zero-byte result as
a zero-ink glyph: the GDI placement and advance metrics are retained and the implied mask is filled with
transparent coverage. Nonzero short or oversized payloads remain hard failures. A focused Windows regression
reproduces the original 0-versus-4 result and verifies that the ideographic space advances without drawing ink;
the SYSTEM4/DEBUGMAP boundary regression remains in place. The user then repeated the normal exact-GDI
DEBUGMAP entry and confirmed that the dialogue now continues into the map without the worker failure, accepting
the fix end to end.
## Stage B0 — Ground-truth reconnaissance ## Stage B0 — Ground-truth reconnaissance
Before changing runtime architecture, record the original game's path from process start through the first Before changing runtime architecture, record the original game's path from process start through the first

View File

@@ -7,6 +7,36 @@ using Xunit;
public class DebugSceneLaunchTests public class DebugSceneLaunchTests
{ {
private sealed class ReachedDebugMapPage8ContinuationException : Exception { }
private sealed class LaunchDebugMapAndStopAfterPage8Sink : ITraceSink
{
public VirtualMachine Vm = null!;
public bool TracingSteps => true;
public void Emit(in TraceEvent e)
{
if (e.Kind == TraceEventKind.FrameEnter
&& e.Name?.Equals("TITLE.BIN", StringComparison.OrdinalIgnoreCase) == true)
{
DebugFrameSnapshot frame = Assert.IsType<DebugFrameSnapshot>(Vm.DebugFrame);
Assert.True(Vm.TryRequestDebugFrameReturn(frame.FrameId, new Dictionary<int, long>
{
[0] = 1,
[0xaba5c] = -1,
[0x62ccf] = 0,
[0x699] = 0x338c,
}));
}
if (e.Kind == TraceEventKind.Step
&& e.Ins?.Offset == 0x5b25
&& Vm.DebugFrame?.CurrentScript.Equals(
"SC0270.BIN", StringComparison.OrdinalIgnoreCase) == true)
throw new ReachedDebugMapPage8ContinuationException();
}
}
private static Operand I(long value) => new(0, value); private static Operand I(long value) => new(0, value);
private static Operand G(long address) => new(3, address); private static Operand G(long address) => new(3, address);
private static (int, Operand[]) Call(Operand id) => (0x3, new[] { id }); private static (int, Operand[]) Call(Operand id) => (0x3, new[] { id });
@@ -15,6 +45,24 @@ public class DebugSceneLaunchTests
private static (int, Operand[]) Sleep() => (0xc8, new[] { I(1) }); private static (int, Operand[]) Sleep() => (0xc8, new[] { I(1) });
private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>()); private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>());
[Fact]
[Trait("Category", "Workspace")]
public void RealDebugMapLaunchContinuesPastSc0270Page8Wait()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Sys4ScriptProvider.Load(table);
var sink = new LaunchDebugMapAndStopAfterPage8Sink();
var vm = new VirtualMachine(
scripts.RequireByName("SYSTEM4.BIN"), table, new CaptureHost(),
new VmOptions(MaxSteps: 1_000_000), scripts, sink);
sink.Vm = vm;
Exception? exception = Record.Exception(() => vm.Run());
Assert.IsType<ReachedDebugMapPage8ContinuationException>(exception);
Assert.Null(vm.HaltReason);
}
[Fact] [Fact]
public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript() public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript()
{ {

View File

@@ -60,6 +60,19 @@ public class WindowsGdiGlyphMaskRasterizerTests
Assert.All(actual.Coverage.ToArray(), value => Assert.InRange(value, (byte)0, (byte)16)); Assert.All(actual.Coverage.ToArray(), value => Assert.InRange(value, (byte)0, (byte)16));
} }
[Fact]
public void IdeographicSpacePreservesAdvanceWithTransparentCoverage()
{
if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out _)) return;
using var rasterizer = new WindowsGdiGlyphMaskRasterizer();
GlyphMask space = rasterizer.Rasterize(
NativeRequest(" 明朝", 24, -12, 0, 0x3000));
Assert.True(space.CellAdvanceX > 0);
Assert.All(space.Coverage.ToArray(), value => Assert.Equal(0, value));
}
[Fact] [Fact]
public void FontHandlesUseTheSharedBoundedLruAndDisposeCleanly() public void FontHandlesUseTheSharedBoundedLruAndDisposeCleanly()
{ {

View File

@@ -124,7 +124,11 @@ public sealed class WindowsGdiGlyphMaskRasterizer
int height = checked((int)metrics.BlackBoxY); int height = checked((int)metrics.BlackBoxY);
int stride = checked((width + 3) & ~3); int stride = checked((width + 3) & ~3);
int expectedBytes = checked(stride * height); int expectedBytes = checked(stride * height);
if (size != expectedBytes) // CP932 0x8140 (U+3000 IDEOGRAPHIC SPACE) is a zero-ink spacing glyph. GDI reports
// its placement as a nominal 1x1 black box but returns a zero-byte required buffer.
// Preserve those metrics and materialize the implied transparent mask; a nonzero
// short/oversized payload still means that the bitmap contract is inconsistent.
if (size != 0 && size != expectedBytes)
throw new InvalidOperationException( throw new InvalidOperationException(
$"GDI gray-4 buffer size {size} disagrees with {width}x{height}, stride {stride}."); $"GDI gray-4 buffer size {size} disagrees with {width}x{height}, stride {stride}.");

View File

@@ -39,6 +39,7 @@ public partial class Main : Godot.Control
private volatile bool _done; private volatile bool _done;
private bool _ended; private bool _ended;
private Task? _vmTask; private Task? _vmTask;
private Exception? _vmFailure;
private bool _selftest; private bool _selftest;
private string? _shotPath; // --shot <png>: capture a page then quit (dev tool) private string? _shotPath; // --shot <png>: capture a page then quit (dev tool)
private int _shotPage = 1; // --shot-page <n>: which page to capture (default 1) private int _shotPage = 1; // --shot-page <n>: which page to capture (default 1)
@@ -455,6 +456,7 @@ public partial class Main : Godot.Control
_vmTask = Task.Run(() => _vmTask = Task.Run(() =>
{ {
try { _vm.Run(); } try { _vm.Run(); }
catch (Exception error) { _vmFailure = error; }
finally { _done = true; } finally { _done = true; }
}); });
@@ -560,14 +562,24 @@ public partial class Main : Godot.Control
{ {
_ended = true; _ended = true;
DumpHistogram(); DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps"); if (_vmFailure != null)
ReportSubroutines();
ShowEnd();
if (_vm.HaltReason == "STEP-LIMIT")
{ {
GodotTraceSnapshot haltTrace = _trace.HaltSnapshot ?? _trace.Snapshot(); GD.PushError($"[vm] worker failed after {_vm!.Steps} steps: {_vmFailure}");
GD.Print(StepLimitDiagnosticFormatter.Format(haltTrace, _table!)); ReportSubroutines();
CaptureStallDiagnostic(haltTrace, "step-limit"); if (!_selftest)
CaptureStallDiagnostic(_trace.Snapshot(), "worker-failure");
}
else
{
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_vm.HaltReason == "STEP-LIMIT")
{
GodotTraceSnapshot haltTrace = _trace.HaltSnapshot ?? _trace.Snapshot();
GD.Print(StepLimitDiagnosticFormatter.Format(haltTrace, _table!));
CaptureStallDiagnostic(haltTrace, "step-limit");
}
} }
if (_selftest) RunSelfTestAndQuitOnFailure(); if (_selftest) RunSelfTestAndQuitOnFailure();
} }