From cbd1521872287ea8511406347a358bab2eafd900 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 29 Jul 2026 11:54:09 -0400 Subject: [PATCH] Implement diagnostic output opcodes --- docs/engine-ctx-reference.md | 5 + docs/engine-re.md | 45 +++++++ docs/opcode-reference.md | 36 ++++-- docs/phase-b-framework.md | 17 ++- .../DiagnosticOutputOpcodeTests.cs | 111 ++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 7 ++ engine/Age.Engine/Hosting/CaptureHost.cs | 2 + engine/Age.Engine/Hosting/IHost.cs | 6 + engine/Age.Engine/Vm/DiagnosticOutputState.cs | 17 +++ engine/Age.Engine/Vm/GameSession.cs | 4 +- engine/Age.Engine/Vm/VirtualMachine.cs | 49 +++++++- godot/GodotAdvHost.cs | 16 +++ godot/Main.cs | 16 +++ tools/age_opcodes_himegari.py | 3 + vm-map/engine-ctx.toml | 25 ++++ vm-map/opcodes.toml | 45 +++---- 16 files changed, 367 insertions(+), 37 deletions(-) create mode 100644 engine/Age.Engine.Tests/DiagnosticOutputOpcodeTests.cs create mode 100644 engine/Age.Engine/Vm/DiagnosticOutputState.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index fbd89f8..0867ea7 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -6,6 +6,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | offset | name | type | note | |---|---|---|---| +| `0x4` | `host_ui_interface` | `void*` | host-facing UI interface used by the ctx+4 virtual-dispatch thunks; default slot +4 logs and presents AGE message boxes | | `0x814` | `input_action_count` | `int` | logical action count (0..31); op 0xfe sets it, op 0x100 scans actions below it and uses callback slot count itself when the polled mask is empty | | `0x898` | `joystick_physical_button_count` | `int` | WinMM JOYCAPS physical button count returned by op 0x106 | | `0x89c` | `joystick_button_map` | `int` | base of 32-entry logical button-slot to physical joystick-button table; op 0x107 writes it, slot N emits action N+4 | @@ -54,6 +55,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x53d60` | `ctx_record_base` | `void*` | 0x78-byte context-record array base (coroutine/script contexts) | | `0x53d64` | `frame_script_resource_id` | `uint` | raw packed SYS4/AAI resource id for this 0x78-byte script frame; persisted ReadTextDB script key | | `0x53d88` | `frame_instruction_word_count` | `int` | current decoded instruction length in dwords for each 0x78-byte script frame; interpreter advances PC by this value * 4 | +| `0x54fe8` | `owner_window_handle` | `void*` | native owner HWND forwarded to host UI/message-box calls, including op 0x1b4 diagnostic presentation | | `0x54ff0` | `initial_root_run` | `int` | initialized to 1 at AGE context construction; queried by op 0x130 and cleared by op 0x9 before root script id 0 is reloaded | | `0x550fc` | `message_skip_display_enabled` | `int` | persistent all-message Skip state returned by op 0x19a for the ADV control-strip active overlay | | `0x55100` | `adv_skip_service_enabled` | `int` | ADV presentation lifecycle gate: op 0x19b clears it while preserving the persistent Skip toggle; op 0x19c sets it before recomputing active message/read skip | @@ -95,6 +97,9 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x6dbf0` | `voice_bgm_duck_control_flags` | `uint` | transient mask replaced by op 0x1cf; bit 0 suppresses automatic voice-triggered BGM attenuation | | `0x6dbf4` | `message_skip_queued_voice_id` | `int` | latest voice resource deferred by op 0xc4 while run_state_flags message-skip bit is active | | `0x6dbf8` | `message_skip_queued_voice_arg` | `int` | second argument retained with message_skip_queued_voice_id; Himegari op 0xc4 stores zero | +| `0x6ddac` | `opcode_debug_metadata_table` | `void*` | base of 512 optional per-opcode debug-metadata pointers; Himegari's release image zero-initializes the table and has no writer, so diagnostic COMMAND names render as '-' | +| `0x6f798` | `script_source_line_table` | `void*` | base of 41 optional per-frame source-line-map pointers; Himegari's release image zero-initializes the table and has no writer, so diagnostic LINE values render as -1 | +| `0x6f880` | `diagnostic_text_buffer` | `int` | base of an embedded 28-byte MSVC string accumulator; op 0x1b2 appends formatted operand text, op 0x1b3 appends CRLF, and op 0x1b4 presents then clears it | | `0x6f89c` | `script_launch_queue` | `int` | embedded integer FIFO used for engine-level auxiliary/root script launches; data pointer and queue indices follow | | `0x6f8a0` | `script_launch_queue_data` | `void*` | dword storage for packed script ids or negative frame-resume ids | | `0x6f8a4` | `script_launch_queue_read_cursor` | `int` | next queued launch consumed by script_launch_queue_dispatch_next | diff --git a/docs/engine-re.md b/docs/engine-re.md index 33dece3..1a3b2da 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1376,6 +1376,51 @@ scale, rotation, and source-rectangle sampling; clears all ten native timing dwo numbered-save record, including the unmodeled second cyclic matrix; and leaves base/current transforms, cyclic targets, and finite one-shot channels intact. +#### Diagnostic accumulator opcodes `0x1b2`–`0x1b4` (2026-07-29) + +The three remaining diagnostic-looking instructions are one native family. The real dispatch registrations +are `ctx+0x9b914 = 0x41f4b0`, `ctx+0x9b918 = 0x41a400`, and `ctx+0x9b91c = 0x419240`; the legacy Kelebek +labels do not identify these handlers correctly. + +- `op_0x1b2_append_diagnostic_value@0x41f4b0` formats operand 1 through + `vm_operand_format_as_string@0x41af90`. Integer operand forms become signed decimal text, while string + forms return their byte strings; the helper also covers AGE's other operand families. The handler + appends the exact returned bytes to the embedded 28-byte MSVC string at `ctx+0x6f880`, now + `EngineCtx::diagnostic_text_buffer`. +- `op_0x1b3_append_diagnostic_newline@0x41a400` appends the two bytes at `0x57082c`, `0d 0a`, to that + same accumulator. It neither presents nor clears anything. +- `op_0x1b4_show_and_clear_diagnostic@0x419240` passes owner HWND + `EngineCtx::owner_window_handle` (`ctx+0x54fe8`), the accumulator's current byte pointer, and flags + `0x10004` through `engine_host_show_message@0x403820`. The thunk loads + `EngineCtx::host_ui_interface` from `ctx+4` and dispatches virtual slot `+4`; `ctx+0x54fe8` is an + explicit argument, not the interface object. +- The installed default vslot, `default_host_show_message@0x431520`, writes the raw diagnostic between + `"\r\n致命的エラー[\r\n"` / `"]\r\n"` trace markers, then calls + `age_show_message_box@0x409370`. Flag `0x10000` appends the current interpreter coordinate using + `"\n\nデバック情報:\nFILE=%s ADDRESS=%X LINE=%d COMMAND=%s(%d) DEPTH=%d\n"`. Low mode `4` + formats body `%s%s`, selects caption `エラーが発生しました`, and invokes USER32 `MessageBoxA` + synchronously with style `0x40` (`MB_OK | MB_ICONINFORMATION`) and the supplied owner HWND. The + opcode ignores the `IDOK` result and only then erases the whole string with + `msvc_string_erase(buffer, 0, 0xffffffff)`. +- The optional command-metadata table at `ctx+0x6ddac` and per-frame source-line maps at + `ctx+0x6f798` are both zeroed by the constructor. Exhaustive immediate-offset xrefs show reads plus + that initialization only—no release-image writer—so Himegari deterministically emits + `LINE=-1 COMMAND=-(436)` for `0x1b4`. `FILE`, dword `ADDRESS`, and zero-based frame `DEPTH` remain + live values. + +Himegari contains three `0x1b2` sites. FIELD appends the literal invalid-mutual-destruction-state text and +then CRLF. SYSTEM4 appends the literal invalid-execution-mode prefix, appends global +`system_flow_request` in decimal, presents and clears the assembled diagnostic, then appends CRLF to the +now-empty buffer. The trio therefore owns three effectful gaps totaling six instructions and is a bounded +next implementation slice; native ordering, including SYSTEM4's apparently odd post-clear newline, should +be preserved. A compatible host seam therefore needs synchronous modal presentation on the UI thread and +the current script coordinate; it is not a general shell-command interface. + +The port implements the trio with a `GameSession`-shared `DiagnosticOutputState`. Godot marshals the +completed prompt to the main thread through `OS.Alert` and parks the VM until dismissal; headless +`CaptureHost` records the same effect. The accumulator clears after the synchronous call returns, so +FIELD's unpresented line and SYSTEM4's unusual post-clear CRLF retain their native lifetime and ordering. + **Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with affine rasterization. `0x223` is **not affine**: `gfx_queue_surface_alpha_transition` (`0x47f440`) inserts a type-0 command-map record keyed by arg 1: start `+4`, delay/duration `+8/+0xc`, target surface slot `+0x10`, diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 429a1a9..cfc1420 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -633,6 +633,30 @@ Both copies are attempted with overwrite allowed. Status is 0 when both succeed, Port status (2026-07-24): implemented as an active ExecFrame identity marker. It survives nested calls and clears when its owning frame unwinds; the following full numbered-payload slice will consume the exposed zero-based cutoff. +### 0x1b2 `append-diagnostic-value` (u00425790, argc 1) +- **summary:** Format operand 1 with AGE's generic operand-to-string conversion and append its exact bytes to the context diagnostic accumulator. This does not present or clear the accumulated text. +- **grounding:** source=investigation, confidence=high +- **depended on by:** 0x1b3, 0x1b4 +- **evidence:** The real /v2 dispatch slot ctx+0x9b914 registers op_0x1b2_append_diagnostic_value@0x41f4b0. It formats operand 1 through vm_operand_format_as_string@0x41af90, measures the returned NUL-terminated bytes, and appends them to the embedded MSVC string at ctx+0x6f880. Himegari has two literal-string sites and one SYSTEM4 global-integer site. + +Implemented with an EngineCtx-lifetime accumulator shared across GameSession scene VMs. String operands append decoded text, integer and pointer forms append signed decimal, and float forms reproduce native `%lf`'s six fractional digits. + +### 0x1b3 `append-diagnostic-newline` (u004257D0, argc 0) +- **summary:** Append the literal CRLF byte pair to the context diagnostic accumulator without presenting or clearing it. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x1b2 +- **evidence:** The real /v2 dispatch slot ctx+0x9b918 registers op_0x1b3_append_diagnostic_newline@0x41a400. Its one operation appends two bytes from DAT_0057082c to the same embedded string at ctx+0x6f880; memory at that address is 0d 0a 00. Both Himegari sites immediately follow diagnostic construction. + +Implemented as an exact `\r\n` append to the shared diagnostic accumulator, including FIELD's persistent line and SYSTEM4's native post-clear newline. + +### 0x1b4 `show-and-clear-diagnostic` (u004237C0, argc 0) +- **summary:** Show the accumulated text plus native script-location context in a synchronous owner-window information message box, then erase the complete accumulator. +- **grounding:** source=investigation, confidence=high +- **depends on:** 0x1b2 +- **evidence:** The real /v2 dispatch slot ctx+0x9b91c registers op_0x1b4_show_and_clear_diagnostic@0x419240. It resolves the MSVC string bytes at ctx+0x6f880 and calls engine_host_show_message@0x403820 with owner HWND ctx+0x54fe8 and flags 0x10004. The thunk dispatches host_ui_interface vslot +4; the default host implementation at 0x431520 logs the raw text and calls age_show_message_box@0x409370. Flag 0x10000 appends `FILE=%s ADDRESS=%X LINE=%d COMMAND=%s(%d) DEPTH=%d`; low mode 4 formats `%s%s`, uses caption `エラーが発生しました`, and calls USER32 MessageBoxA with style 0x40 (MB_OK|MB_ICONINFORMATION). The optional command metadata at ctx+0x6ddac and source-line maps at ctx+0x6f798 are zero-initialized and have no writer in Himegari's release image, yielding `LINE=-1 COMMAND=-(436)` at this opcode. The opcode ignores the return, then calls msvc_string_erase(buffer,0,0xffffffff). SYSTEM4's sole site presents its invalid-execution-mode text and value; a following 0x1b3 appends CRLF to the now-empty accumulator. + +Implemented as a synchronous host effect. Himegari's release-only null debug tables make the native suffix deterministic (`LINE=-1 COMMAND=-(436)`); FILE, dword ADDRESS, and zero-based frame DEPTH come from the live instruction. Godot marshals `OS.Alert` to the main thread and parks the VM until dismissal; CaptureHost records the effect. The accumulator clears only after the host call returns. + ### 0x1cc `get-adv-read-skip-state` (get-adv-read-skip-state, argc 1) - **summary:** (out) - copy the current ADV read/click-skip service state from ctx+0x6dbd4. label_1235a ORs it with 0x1c7's Ctrl/message-skip bit: zero takes 0x21c's normal transition/yield path; nonzero resets the animation service and presents the completed endpoint through 0x20c. - **grounding:** source=investigation, confidence=high @@ -1973,18 +1997,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **grounding:** source=kelebek, confidence=low - **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. -### 0x1b2 `u00425790` (u00425790, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1b3 `u004257D0` (u004257D0, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0x1b4 `u004237C0` (u004237C0, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1be `u0041D9D0` (u0041D9D0, argc 2) - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index da523ac..3d50a6f 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -857,7 +857,22 @@ sites (five DEBUGADV demonstrations and one FIELD movement setup), reducing the inventory to 15 distinct opcodes / 22 instructions. All 479 engine tests, opcode build/lint, the zero-warning Godot build, and the Himegari-targeted threaded selftest pass. -**NEXT:** investigate `0x1b2`, the widest remaining unknown at three sites. +**Diagnostic-output trio implemented (2026-07-29):** `0x1b2(value)` now formats string, signed-integer, +pointer, and float operand families into an EngineCtx-lifetime accumulator; `0x1b3` appends exact CRLF; +and `0x1b4` appends the native interpreter coordinate, synchronously presents it, then clears only after +the host returns. The release image's optional debug-name/line tables are never populated, so the port +matches its deterministic `LINE=-1 COMMAND=-(436)` suffix while retaining live `FILE`, dword `ADDRESS`, +and zero-based frame `DEPTH`. + +`GameSession` carries the accumulator across fresh scene VMs, preserving FIELD's unpresented invalid-state +line and SYSTEM4's post-clear newline. Interactive Godot dispatches `OS.Alert` on the main thread and parks +the VM until dismissal; headless `CaptureHost` records rather than drops the effect. Focused regressions +cover operand formatting, append/show/clear ordering, context formatting, headless presentation, and +cross-scene lifetime. All 482 engine tests, opcode/EngineCtx build and lint, the zero-warning Godot build, +and the Himegari-targeted threaded selftest pass. The six instructions are closed, reducing the remaining +effectful inventory from 15 distinct opcodes / 22 instructions to 12 / 16. + +**NEXT:** rerank the remaining 12 effectful opcode gaps and investigate the widest coherent slice. ## Later Phase B breadth diff --git a/engine/Age.Engine.Tests/DiagnosticOutputOpcodeTests.cs b/engine/Age.Engine.Tests/DiagnosticOutputOpcodeTests.cs new file mode 100644 index 0000000..381286d --- /dev/null +++ b/engine/Age.Engine.Tests/DiagnosticOutputOpcodeTests.cs @@ -0,0 +1,111 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class DiagnosticOutputOpcodeTests +{ + private static readonly OpcodeTable Table = 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 (int, Operand[]) Exit() => (0x2, []); + + [Fact] + public void TrioPreservesNativeAppendShowClearAndPostClearOrdering() + { + Script scene = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x1b2, [S(0)]), + (0x1b2, [G(0x100)]), + (0x1b3, []), + (0x1b4, []), + (0x1b3, []), + (0x1b2, [S(1)]), + (0x1b4, []), + Exit(), + ], ["invalid mode=", "tail"]); + var host = new RecordingHost(); + VirtualMachine? vm = null; + var pendingAtPresentation = new List(); + host.OnDiagnosticMessage = _ => pendingAtPresentation.Add(vm!.PendingDiagnosticText); + vm = new VirtualMachine(scene, Table, host); + vm.Globals[0x100] = -3; + + vm.Run(); + + Assert.Equal( + [ + "invalid mode=-3\r\n", + "\r\ntail", + ], pendingAtPresentation); + Assert.Equal(2, host.Diagnostics.Count); + Assert.All(host.Diagnostics, message => Assert.Equal("エラーが発生しました", message.Caption)); + Assert.Equal( + "invalid mode=-3\r\n" + + "\n\nデバック情報:\n" + + "FILE=SYSTEM4.BIN ADDRESS=7 LINE=-1 COMMAND=-(436) DEPTH=0\n", + host.Diagnostics[0].Text); + Assert.Equal( + "\r\ntail" + + "\n\nデバック情報:\n" + + "FILE=SYSTEM4.BIN ADDRESS=C LINE=-1 COMMAND=-(436) DEPTH=0\n", + host.Diagnostics[1].Text); + Assert.Equal("", vm.PendingDiagnosticText); + } + + [Fact] + public void GenericFormatterCoversIntegerFloatStringAndPointerOperandFamilies() + { + Script scene = ScriptAssembler.Assemble(Table, "FORMAT_DIAGNOSTIC.BIN", + [ + (0x1b2, [I(unchecked((long)(uint)int.MinValue))]), + (0x1b2, [S(0)]), + (0x1b2, [new Operand(4, 0x10)]), + (0x1b2, [S(1)]), + (0x1b2, [new Operand(6, 0x20)]), + (0x1b2, [S(2)]), + (0x1b2, [new Operand(8, 0x21)]), + (0x1b4, []), + Exit(), + ], [":", "/", "/"]); + var host = new CaptureHost(); + var vm = new VirtualMachine(scene, Table, host); + vm.GlobalFloats[0x10] = BitConverter.SingleToInt32Bits(1.25f); + vm.Globals[0x20] = 0x30; + vm.Globals[0x30] = -9; + vm.Globals[0x21] = 0x31; + vm.GlobalStrings[0x31] = "pointer text"; + + vm.Run(); + + DiagnosticMessage diagnostic = Assert.Single(host.Diagnostics); + Assert.StartsWith("-2147483648:1.250000/-9/pointer text", diagnostic.Text); + } + + [Fact] + public void GameSessionCarriesEngineCtxAccumulatorAcrossFreshSceneVms() + { + var session = new GameSession(); + Script field = ScriptAssembler.Assemble(Table, "FIELD.BIN", + [ + (0x1b2, [S(0)]), + (0x1b3, []), + Exit(), + ], ["field diagnostic"]); + Script system = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x1b2, [S(0)]), + (0x1b4, []), + Exit(), + ], ["system diagnostic"]); + var host = new CaptureHost(); + + session.RunScene(field, Table, new CaptureHost()); + Assert.Equal("field diagnostic\r\n", session.DiagnosticOutput.PendingText); + session.RunScene(system, Table, host); + + Assert.StartsWith("field diagnostic\r\nsystem diagnostic", Assert.Single(host.Diagnostics).Text); + Assert.Equal("", session.DiagnosticOutput.PendingText); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index d7ffa18..bf92f19 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -59,6 +59,8 @@ internal class RecordingHost : IHost public readonly List MessageSkipChanges = new(); public readonly List PhysicalMessageSkipChanges = new(); public readonly List Warnings = new(); + public readonly List Diagnostics = new(); + public System.Action? OnDiagnosticMessage; public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new(); @@ -67,6 +69,11 @@ internal class RecordingHost : IHost public int CursorClearCount; public int SceneContextResets; public void ReportWarning(string message) => Warnings.Add(message); + public void ShowDiagnosticMessage(DiagnosticMessage message) + { + Diagnostics.Add(message); + OnDiagnosticMessage?.Invoke(message); + } public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds) { diff --git a/engine/Age.Engine/Hosting/CaptureHost.cs b/engine/Age.Engine/Hosting/CaptureHost.cs index da651fa..1c953d1 100644 --- a/engine/Age.Engine/Hosting/CaptureHost.cs +++ b/engine/Age.Engine/Hosting/CaptureHost.cs @@ -2,6 +2,8 @@ namespace Age.Engine.Hosting; public sealed class CaptureHost : IHost { public List<(int Offset, string Text)> Emitted { get; } = new(); + public List Diagnostics { get; } = new(); + public void ShowDiagnosticMessage(DiagnosticMessage message) => Diagnostics.Add(message); public void ShowText(int offset, string text) => Emitted.Add((offset, text)); public void WaitForInput() { } public void Sleep(long duration) { } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index f3bc3f9..8c9cc84 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -28,6 +28,9 @@ public readonly record struct SurfaceRectCopy( int SourceSurface, int DestinationSurface, int SourceX, int SourceY, int Width, int Height, int DestinationX, int DestinationY); +/// A synchronous AGE-owned diagnostic prompt after native body/context formatting. +public readonly record struct DiagnosticMessage(string Caption, string Text); + public enum SurfaceBlackFadeDirection { FromBlack, @@ -38,6 +41,9 @@ public interface IHost { /// Report a recoverable runtime discrepancy while allowing script execution to continue. void ReportWarning(string message) => System.Console.Error.WriteLine(message); + /// Present a modal diagnostic and return only after the user dismisses it. + void ShowDiagnosticMessage(DiagnosticMessage message) + => System.Console.Error.WriteLine($"{message.Caption}: {message.Text}"); // Script context is retained for diagnostics/page location; resource operands are universal packed ids. void EnterScriptContext(string scriptName) { } void ExitScriptContext() { } diff --git a/engine/Age.Engine/Vm/DiagnosticOutputState.cs b/engine/Age.Engine/Vm/DiagnosticOutputState.cs new file mode 100644 index 0000000..2f29538 --- /dev/null +++ b/engine/Age.Engine/Vm/DiagnosticOutputState.cs @@ -0,0 +1,17 @@ +using System.Text; + +namespace Age.Engine.Vm; + +/// +/// EngineCtx-lifetime accumulator used by opcodes 0x1b2 through 0x1b4. GameSession shares this +/// state across fresh scene VMs just as native AGE retains the embedded string across script frames. +/// +public sealed class DiagnosticOutputState +{ + private readonly StringBuilder _text = new(); + + public string PendingText => _text.ToString(); + + internal void Append(string value) => _text.Append(value); + internal void Clear() => _text.Clear(); +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index 54624ac..b61b77b 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -32,6 +32,8 @@ public sealed class GameSession public AudioMixerSettings AudioMixerSettings { get; } /// The live retained ADV backlog shared by every VM run in this session. public AdvTextHistory TextHistory { get; } = new(); + /// The EngineCtx-lifetime diagnostic accumulator shared across script/scene VMs. + public DiagnosticOutputState DiagnosticOutput { get; } = new(); public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null, AudioMixerSettings? audioMixerSettings = null) @@ -51,7 +53,7 @@ public sealed class GameSession { var vm = new VirtualMachine( script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore, - AudioMixerSettings); + AudioMixerSettings, DiagnosticOutput); foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in GlobalFloats) vm.GlobalFloats[kv.Key] = kv.Value; foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 7b6a0f2..dcd7b1f 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -29,9 +29,10 @@ public sealed class VirtualMachine private const int HOTSPOT_RETURN = int.MinValue + 2; private const int ROOT_RELOAD = int.MinValue + 3; private const int SceneEntryCoroutineGate = 0xaba5c; - private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, + private const int T_IMM = 0, T_FLOAT = 1, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, T_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12, T_LSTRPTR = 14; + private const string DiagnosticCaption = "エラーが発生しました"; private readonly Script _s; private readonly OpcodeTable _t; @@ -41,6 +42,7 @@ public sealed class VirtualMachine private readonly IScriptProvider? _provider; private readonly SharedProfile _sharedProfile; private readonly AudioMixerSettings _audioMixerSettings; + private readonly DiagnosticOutputState _diagnosticOutput; private readonly INativeDatStore? _nativeDatStore; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private ExecFrame _cur = null!; @@ -108,6 +110,7 @@ public sealed class VirtualMachine public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; + public string PendingDiagnosticText => _diagnosticOutput.PendingText; /// /// Zero-based active-frame cutoff selected by opcode 0x1ad, or null when no surviving marker /// exists. A numbered-save serializer consumes this boundary in the full payload slice. @@ -151,7 +154,8 @@ public sealed class VirtualMachine IScriptProvider? provider = null, ITraceSink? sink = null, AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null, - AudioMixerSettings? audioMixerSettings = null) + AudioMixerSettings? audioMixerSettings = null, + DiagnosticOutputState? diagnosticOutput = null) { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -159,6 +163,7 @@ public sealed class VirtualMachine _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sharedProfile = sharedProfile ?? new SharedProfile(); _audioMixerSettings = audioMixerSettings ?? new AudioMixerSettings(); + _diagnosticOutput = diagnosticOutput ?? new DiagnosticOutputState(); _nativeDatStore = nativeDatStore; _messageWindowAlphaSetting = host.MessageWindowAlphaSetting; _messageGlyphDelayMilliseconds = System.Math.Max(0, host.MessageGlyphDelayMilliseconds); @@ -644,6 +649,33 @@ public sealed class VirtualMachine ? ReadStr(operand) : unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture); + private string FormatDiagnosticOperand(Operand operand) + { + if (IsStr(operand)) return ReadStr(operand); + if (operand.Type is T_FLOAT or T_GFLOAT or T_LFLOAT) + { + float value = BitConverter.Int32BitsToSingle(unchecked((int)Read(operand))); + return value.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); + } + return unchecked((int)Read(operand)) + .ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private DiagnosticMessage BuildDiagnosticMessage(Instruction instruction) + { + // Himegari's release AGE initializes both optional debug metadata tables to null and has no + // writer for either one. The native formatter consequently emits -1 and "-" here. + const int sourceLine = -1; + const string commandName = "-"; + int nativeDepth = Math.Max(0, _depth - 1); + string context = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "\n\nデバック情報:\nFILE={0} ADDRESS={1:X} LINE={2} COMMAND={3}({4}) DEPTH={5}\n", + _cur.Script.Name, instruction.Offset, sourceLine, commandName, + instruction.Opcode, nativeDepth); + return new DiagnosticMessage(DiagnosticCaption, _diagnosticOutput.PendingText + context); + } + private sealed class RootReloadRequestedException : Exception { } private sealed class NumberedRestoreRequestedException : Exception { } private sealed class ProcessExitRequestedException : Exception { } @@ -1194,6 +1226,19 @@ public sealed class VirtualMachine case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1 Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1); return pc + 1; + case "u00425790": // upstream ABI label + case "append-diagnostic-value": // 0x1b2: generic operand text -> EngineCtx accumulator + _diagnosticOutput.Append(FormatDiagnosticOperand(a[0])); + return pc + 1; + case "u004257D0": // upstream ABI label + case "append-diagnostic-newline": // 0x1b3: exact native CRLF bytes + _diagnosticOutput.Append("\r\n"); + return pc + 1; + case "u004237C0": // upstream ABI label + case "show-and-clear-diagnostic": // 0x1b4: synchronous host prompt, then erase + _host.ShowDiagnosticMessage(BuildDiagnosticMessage(ins)); + _diagnosticOutput.Clear(); + return pc + 1; case "is-catalog-resource-unlocked": // 0x19d Write(a[0], _sharedProfile.IsCatalogResourceUnlocked(Read(a[1])) ? 1 : 0); return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index cac1312..12bcb9e 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -26,6 +26,7 @@ public sealed class GodotAdvHost : IHost private readonly Stack _scriptContexts = new(); private readonly ScriptPresentationBarrier _presentationBarrier = new(); private readonly AutoResetEvent _presentationRequestConsumed = new(false); + private readonly AutoResetEvent _diagnosticMessageCompleted = new(false); private readonly bool _synchronizeExplicitPresentation; private long _explicitPresentationRequestGeneration; private long _consumedPresentationRequestGeneration; @@ -117,6 +118,20 @@ public sealed class GodotAdvHost : IHost public void ReportWarning(string message) => System.Console.Error.WriteLine(message); + public void ShowDiagnosticMessage(DiagnosticMessage message) + { + if (_stopping) return; + _timeline?.Event("diagnostic-message", new() + { + ["caption"] = message.Caption, + ["text"] = message.Text, + }); + _main.CallDeferred("ShowAgeDiagnostic", message.Text, message.Caption); + while (!_stopping && !_diagnosticMessageCompleted.WaitOne(50)) { } + } + + public void CompleteDiagnosticMessage() => _diagnosticMessageCompleted.Set(); + private string CurrentScene { get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; } @@ -950,6 +965,7 @@ public sealed class GodotAdvHost : IHost if (_gate.CurrentCount == 0) _gate.Release(); _inputCallbackSignal.Set(); _frameSignal.Set(); + _diagnosticMessageCompleted.Set(); } public void ResetSceneContext() diff --git a/godot/Main.cs b/godot/Main.cs index 94f1f10..cd9c475 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -912,6 +912,22 @@ public partial class Main : Godot.Control _ageCursorTexture = null; } + public void ShowAgeDiagnostic(string text, string caption) + { + try + { + OS.Alert(text, caption); + } + catch (System.Exception error) + { + GD.PushError($"[diagnostic] native alert failed: {error.Message}"); + } + finally + { + _host?.CompleteDiagnosticMessage(); + } + } + public override void _ExitTree() { bool vmStopped = true; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 569fc6d..1bde2ac 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -54,6 +54,9 @@ INFERRED: dict[int, dict] = { 0x19c: dict(name='resume-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary='() - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel.'), 0x1a6: dict(name='half-byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write half the resolved string's byte length, using integer truncation."), 0x1a8: dict(name='instruction-marker-noop', category='marker', noop=True, confidence='high', source='investigation', summary="Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns."), + 0x1b2: dict(name='append-diagnostic-value', category='control', noop=False, confidence='high', source='investigation', summary="Format operand 1 with AGE's generic operand-to-string conversion and append its exact bytes to the context diagnostic accumulator. This does not present or clear the accumulated text."), + 0x1b3: dict(name='append-diagnostic-newline', category='control', noop=False, confidence='high', source='investigation', summary='Append the literal CRLF byte pair to the context diagnostic accumulator without presenting or clearing it.'), + 0x1b4: dict(name='show-and-clear-diagnostic', category='control', noop=False, confidence='high', source='investigation', summary='Show the accumulated text plus native script-location context in a synchronous owner-window information message box, then erase the complete accumulator.'), 0x1b5: dict(name='set-message-glyph-delay', category='adv', noop=False, confidence='high', source='investigation', summary='(milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately.'), 0x1b6: dict(name='get-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return whether automatic message advance is enabled.'), 0x1b7: dict(name='set-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - enable or disable automatic message advance.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 06d7849..07916d9 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -7,6 +7,11 @@ struct_name = "EngineCtx" size = 0xa1000 +[[field]] +offset = 0x4 +name = "host_ui_interface" +type = "void*" +note = "host-facing UI interface used by the ctx+4 virtual-dispatch thunks; default slot +4 logs and presents AGE message boxes" [[field]] offset = 0x814 name = "input_action_count" @@ -248,6 +253,11 @@ name = "frame_instruction_word_count" type = "int" note = "current decoded instruction length in dwords for each 0x78-byte script frame; interpreter advances PC by this value * 4" [[field]] +offset = 0x54fe8 +name = "owner_window_handle" +type = "void*" +note = "native owner HWND forwarded to host UI/message-box calls, including op 0x1b4 diagnostic presentation" +[[field]] offset = 0x54ff0 name = "initial_root_run" type = "int" @@ -453,6 +463,21 @@ name = "message_skip_queued_voice_arg" type = "int" note = "second argument retained with message_skip_queued_voice_id; Himegari op 0xc4 stores zero" [[field]] +offset = 0x6ddac +name = "opcode_debug_metadata_table" +type = "void*" +note = "base of 512 optional per-opcode debug-metadata pointers; Himegari's release image zero-initializes the table and has no writer, so diagnostic COMMAND names render as '-'" +[[field]] +offset = 0x6f798 +name = "script_source_line_table" +type = "void*" +note = "base of 41 optional per-frame source-line-map pointers; Himegari's release image zero-initializes the table and has no writer, so diagnostic LINE values render as -1" +[[field]] +offset = 0x6f880 +name = "diagnostic_text_buffer" +type = "int" +note = "base of an embedded 28-byte MSVC string accumulator; op 0x1b2 appends formatted operand text, op 0x1b3 appends CRLF, and op 0x1b4 presents then clears it" +[[field]] offset = 0x6f89c name = "script_launch_queue" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 5e77e34..c265a27 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3904,18 +3904,19 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00425790" -category = "unknown" -summary = "" +name = "append-diagnostic-value" +category = "control" +summary = "Format operand 1 with AGE's generic operand-to-string conversion and append its exact bytes to the context diagnostic accumulator. This does not present or clear the accumulated text." +details = "Implemented with an EngineCtx-lifetime accumulator shared across GameSession scene VMs. String operands append decoded text, integer and pointer forms append signed decimal, and float forms reproduce native `%lf`'s six fractional digits." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "The real /v2 dispatch slot ctx+0x9b914 registers op_0x1b2_append_diagnostic_value@0x41f4b0. It formats operand 1 through vm_operand_format_as_string@0x41af90, measures the returned NUL-terminated bytes, and appends them to the embedded MSVC string at ctx+0x6f880. Himegari has two literal-string sites and one SYSTEM4 global-integer site." [[opcode.semantics.args]] i = 1 -role = "" +role = "value to format and append" observed_types = ["string", "g-int"] [[opcode]] @@ -3925,14 +3926,15 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004257D0" -category = "unknown" -summary = "" +name = "append-diagnostic-newline" +category = "control" +summary = "Append the literal CRLF byte pair to the context diagnostic accumulator without presenting or clearing it." +details = "Implemented as an exact `\\r\\n` append to the shared diagnostic accumulator, including FIELD's persistent line and SYSTEM4's native post-clear newline." noop_headless = false -source = "kelebek" -confidence = "low" -depends_on = [] -evidence = "" +source = "investigation" +confidence = "high" +depends_on = [0x1b2] +evidence = "The real /v2 dispatch slot ctx+0x9b918 registers op_0x1b3_append_diagnostic_newline@0x41a400. Its one operation appends two bytes from DAT_0057082c to the same embedded string at ctx+0x6f880; memory at that address is 0d 0a 00. Both Himegari sites immediately follow diagnostic construction." [[opcode]] op = 0x1b4 @@ -3941,14 +3943,15 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004237C0" -category = "unknown" -summary = "" +name = "show-and-clear-diagnostic" +category = "control" +summary = "Show the accumulated text plus native script-location context in a synchronous owner-window information message box, then erase the complete accumulator." +details = "Implemented as a synchronous host effect. Himegari's release-only null debug tables make the native suffix deterministic (`LINE=-1 COMMAND=-(436)`); FILE, dword ADDRESS, and zero-based frame DEPTH come from the live instruction. Godot marshals `OS.Alert` to the main thread and parks the VM until dismissal; CaptureHost records the effect. The accumulator clears only after the host call returns." noop_headless = false -source = "kelebek" -confidence = "low" -depends_on = [] -evidence = "" +source = "investigation" +confidence = "high" +depends_on = [0x1b2] +evidence = "The real /v2 dispatch slot ctx+0x9b91c registers op_0x1b4_show_and_clear_diagnostic@0x419240. It resolves the MSVC string bytes at ctx+0x6f880 and calls engine_host_show_message@0x403820 with owner HWND ctx+0x54fe8 and flags 0x10004. The thunk dispatches host_ui_interface vslot +4; the default host implementation at 0x431520 logs the raw text and calls age_show_message_box@0x409370. Flag 0x10000 appends `FILE=%s ADDRESS=%X LINE=%d COMMAND=%s(%d) DEPTH=%d`; low mode 4 formats `%s%s`, uses caption `エラーが発生しました`, and calls USER32 MessageBoxA with style 0x40 (MB_OK|MB_ICONINFORMATION). The optional command metadata at ctx+0x6ddac and source-line maps at ctx+0x6f798 are zero-initialized and have no writer in Himegari's release image, yielding `LINE=-1 COMMAND=-(436)` at this opcode. The opcode ignores the return, then calls msvc_string_erase(buffer,0,0xffffffff). SYSTEM4's sole site presents its invalid-execution-mode text and value; a following 0x1b3 appends CRLF to the now-empty accumulator." [[opcode]] op = 0x1b5