diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index b40514d..8d60a7a 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -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 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 `MS 明朝` 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 Before changing runtime architecture, record the original game's path from process start through the first diff --git a/engine/Age.Engine.Tests/DebugSceneLaunchTests.cs b/engine/Age.Engine.Tests/DebugSceneLaunchTests.cs index 467d0e0..8a5668e 100644 --- a/engine/Age.Engine.Tests/DebugSceneLaunchTests.cs +++ b/engine/Age.Engine.Tests/DebugSceneLaunchTests.cs @@ -7,6 +7,36 @@ using Xunit; 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(Vm.DebugFrame); + Assert.True(Vm.TryRequestDebugFrameReturn(frame.FrameId, new Dictionary + { + [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 G(long address) => new(3, address); 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[]) Exit() => (0x2, Array.Empty()); + [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(exception); + Assert.Null(vm.HaltReason); + } + [Fact] public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript() { diff --git a/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs index e84f90b..6db5223 100644 --- a/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs +++ b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs @@ -60,6 +60,19 @@ public class WindowsGdiGlyphMaskRasterizerTests 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("MS 明朝", 24, -12, 0, 0x3000)); + + Assert.True(space.CellAdvanceX > 0); + Assert.All(space.Coverage.ToArray(), value => Assert.Equal(0, value)); + } + [Fact] public void FontHandlesUseTheSharedBoundedLruAndDisposeCleanly() { diff --git a/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs index ae27d76..6413d08 100644 --- a/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs +++ b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs @@ -124,7 +124,11 @@ public sealed class WindowsGdiGlyphMaskRasterizer int height = checked((int)metrics.BlackBoxY); int stride = checked((width + 3) & ~3); 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( $"GDI gray-4 buffer size {size} disagrees with {width}x{height}, stride {stride}."); diff --git a/godot/Main.cs b/godot/Main.cs index b2ff6bb..3219c6d 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -39,6 +39,7 @@ public partial class Main : Godot.Control private volatile bool _done; private bool _ended; private Task? _vmTask; + private Exception? _vmFailure; private bool _selftest; private string? _shotPath; // --shot : capture a page then quit (dev tool) private int _shotPage = 1; // --shot-page : which page to capture (default 1) @@ -455,6 +456,7 @@ public partial class Main : Godot.Control _vmTask = Task.Run(() => { try { _vm.Run(); } + catch (Exception error) { _vmFailure = error; } finally { _done = true; } }); @@ -560,14 +562,24 @@ public partial class Main : Godot.Control { _ended = true; DumpHistogram(); - GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps"); - ReportSubroutines(); - ShowEnd(); - if (_vm.HaltReason == "STEP-LIMIT") + if (_vmFailure != null) { - GodotTraceSnapshot haltTrace = _trace.HaltSnapshot ?? _trace.Snapshot(); - GD.Print(StepLimitDiagnosticFormatter.Format(haltTrace, _table!)); - CaptureStallDiagnostic(haltTrace, "step-limit"); + GD.PushError($"[vm] worker failed after {_vm!.Steps} steps: {_vmFailure}"); + ReportSubroutines(); + 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(); }