From ad022ace5169db22741981d43adecf0ac3aee54f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 18 Jul 2026 21:14:06 -0400 Subject: [PATCH] Implement native ADV Hide Window path --- docs/PROJECT-STRUCTURE.md | 2 +- docs/engine-re.md | 59 ++++++-- docs/global-reference.md | 3 +- docs/opcode-reference.md | 8 +- docs/phase-a-slice-plan.md | 21 +++ engine/Age.Engine.Tests/CurDecoderTests.cs | 28 ++++ engine/Age.Engine.Tests/HotspotInputTests.cs | 144 +++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 7 +- engine/Age.Engine/Hosting/IHost.cs | 5 + engine/Age.Engine/Sys4/CurDecoder.cs | 80 +++++++++++ engine/Age.Engine/Sys4/ResourceMap.cs | 15 ++ engine/Age.Engine/Vm/ExecFrame.cs | 8 ++ engine/Age.Engine/Vm/VirtualMachine.cs | 133 ++++++++++++++++- godot/GodotAdvHost.cs | 26 ++++ godot/Main.cs | 49 ++++++- tools/age_opcodes_himegari.py | 6 +- vm-map/globals.toml | 11 ++ vm-map/opcodes.toml | 8 +- 18 files changed, 578 insertions(+), 35 deletions(-) create mode 100644 engine/Age.Engine.Tests/CurDecoderTests.cs create mode 100644 engine/Age.Engine/Sys4/CurDecoder.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 6505437..5af4271 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -99,7 +99,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ ├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests) │ └── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store, - │ script provider, AGF-to-RGBA8/LZSS decoder, and resource facade + │ script provider, AGF/LZSS and Windows CUR decoders, and resource facade ├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md) └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine) ``` diff --git a/docs/engine-re.md b/docs/engine-re.md index 1f2fb4e..ae54e79 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1245,25 +1245,23 @@ save/load chain and corrects the relevant function prototypes; saved 2026-07-18. ### Remaining ADV control-strip actions and implementation cost (2026-07-18) -The five standard controls are now fully inventoried. Auto message and all-message Skip have working host -services; Read-message Skip is the profile-wide `RT.DAT`/ReadTextDB slice above. The two other actions are -History and Hide Window, and they exercise different engine subsystems rather than variations of Skip. +The five standard controls are now fully inventoried. Auto message, all-message Skip, and Hide Window have +working native-path services; Read-message Skip is the profile-wide `RT.DAT`/ReadTextDB slice above. +History remains a distinct retained-text subsystem rather than another variation of Skip. | Control | Native action | Current port boundary | Relative cost | |---|---|---|---| -| History (`x=684`) | Cancel the ADV hotspot wait and run `HISTORY.BIN` over the text manager's retained record stream | Hotspot and nested `call-script` work, but retained text-history records, history query/render opcodes, generic mouse/joy callback dispatch, and several UI/font operations do not | High | +| History (`x=684`) | Cancel the ADV hotspot wait and run `HISTORY.BIN` over the text manager's retained record stream | Hotspot, nested `call-script`, and generic callback/input services work; retained text-history records, history query/render opcodes, and several UI/font operations do not | High | | Auto (`x=706`) | Toggle the Auto service | Implemented, including timed wait completion | Done | | Message Skip (`x=728`) | Enable persistent all-message fast-forward | Implemented; pacing discrepancies remain a later fidelity adjustment | Done | | Read-message Skip (`x=750`) | Toggle `message:ReadTextSkip`; gate advancement through shared ReadTextDB state | Native persistence and queue/commit/query flow investigated; service not implemented | Medium-high, bounded | -| Hide Window (`x=772`) | Op `0x199` enters the saved ADV coroutine handler, removes chrome, and runs `HIDEWIN.BIN` | Saved handler metadata and retained scene transforms exist, but op `0x199` and generic callback/input/cursor services do not | Medium | +| Hide Window (`x=772`) | Op `0x199` enters the saved ADV coroutine handler, removes chrome, and runs `HIDEWIN.BIN` | Implemented through the native script path, including coroutine re-entry, per-frame callbacks, mouse/joy state, and `.CUR` resources | Done | -`HIDEWIN.BIN` is primarily an input/scheduler slice, not a new renderer. Static coverage is 29/37 distinct -opcodes handled (251/286 instructions); its eight effectful gaps are cursor selection (`0x86/0x87`), mouse -callback registration/dispatch (`0xcc/0xcd`), mouse-button state (`0x108`), and joy callback -registration/poll/dispatch (`0xfb/0xff/0x100`). The script saves retained-object translations, hides the ADV -chrome through the parent coroutine, allows the scene to be viewed/panned, then restores state. A clean port -slice therefore needs real op-`0x199` frame redirection plus a per-frame callback table and host input -snapshot; the existing retained renderer supplies the visual state. +`HIDEWIN.BIN` is primarily an input/scheduler slice, not a new renderer. Its former eight effectful gaps were +cursor selection (`0x86/0x87`), mouse callback registration/dispatch (`0xcc/0xcd`), mouse-button state +(`0x108`), and joy callback registration/poll/dispatch (`0xfb/0xff/0x100`). The implementation below adds +those services plus real op-`0x199` frame redirection; the existing retained renderer supplies the visual +state while the script saves translations, hides the ADV chrome, permits view/pan input, and restores state. `HISTORY.BIN` is a substantially larger subsystem. Static coverage is 41/78 distinct opcodes handled (761/854 instructions), leaving 37 effectful opcode kinds. AGE does not build the backlog from `RT.DAT`. @@ -1274,11 +1272,42 @@ also needs the generic callback/input layer used by Hide Window, local literal-a menu/text-surface operations. ReadTextDB can share the point where a message completes, but it cannot serve as the backlog data model because it stores only read flags, not text, styling, names, or voice metadata. -Implementation order by dependency and risk is therefore: **Hide Window first**, because it establishes -the reusable callback/coroutine input layer on an otherwise well-covered script; then Read-message Skip; -then History after both the input layer and message-completion seam exist. The `/v2` image names/comments +The dependency order was **Hide Window first** to establish reusable callback/coroutine input, then +Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is +now complete; the remaining order is Read-message Skip followed by History. The `/v2` image names/comments the cursor, callback dispatch, retained-history navigation/render/metadata, and history-voice opcode paths. +### ADV Hide Window implementation (2026-07-18) + +The x=772 callback now follows the original control flow rather than a Godot-only visibility shortcut. +Opcode `0x199` saves the instruction after the yield, enters the handler-A PC registered by `0x7b`, and, +when HIDEWIN calls `0x199` again, enters handler B. Opcode `0x7c` then restores the saved ADV PC. This keeps +chrome removal/restoration, nested `call-script 0x20`, retained drawing, and the 100-ms cursor re-arm under +the shipped SC0000 bytecode. + +The reusable input layer implements the eight previously effectful HIDEWIN gaps: `0x86/0x87` select and +clear indexed cursor resources; `0xcc/0xcd` register and dispatch the timed mouse callback; `0xfb`, `0xff`, +and `0x100` maintain and dispatch the frame-local 32-entry joy/input callback table; and `0x108` returns the +live mouse-button mask. Existing cursor coordinate ops `0x109/0x10a` are now effectful in the VM as well. +Godot supplies virtual-screen pointer coordinates, left/right mouse bits (`0x1`/`0x2`), and the script's +directional input indices (down/left/up/right = 0/1/2/3; accept/cancel = 4/5). The common index-10 release +callback is queued on action release. + +Raw ids `0x3318..0x331f` resolve through SYS4INI to the game's 32x32 monochrome Windows `.CUR` assets. The +runtime decodes their DIB XOR/AND masks and hotspots to RGBA textures and installs them through Godot's +custom-cursor API. This is asset-backed behavior; no replacement cursor art is authored by the port. + +Every ordinary ADV script gates the handler-A call to HIDEWIN on `G[0x62425]`. No script writes that global, +and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native scheduler-owned +inherited state rather than numbered save data or the data-only `--boot` prefix. The Godot scene bootstrap +mirrors the original enabled value as `adv_hide_window_enabled=1`, next to the already documented +`adv_chrome_enabled` state. A real-script regression activates SC0000's x=772 record, enters HIDEWIN.BIN, +services multiple timed input iterations, closes through the native right-button bit, and returns to the +parked ADV wait. Synthetic regressions separately cover both coroutine handlers, callback dispatch, live +pointer/button reads, cursor host forwarding, and real CUR decoding. +Validation is engine 175/175, opcode/global generator tests and lints clean, zero-warning Godot build, and +threaded `SELFTEST OK`. + ### ADV retained text — ops `0x7a` / `0x204` and show-text publication (2026-07-10) The SC0000 textbox uses two related native paths under the text manager at `ctx+0x14940`: diff --git a/docs/global-reference.md b/docs/global-reference.md index 550b3d2..bdab8e5 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -16416 globals (71 curated, 16345 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +16417 globals (72 curated, 16345 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -3529,6 +3529,7 @@ | `0x6cb` | adv_hover_message_skip | high | investigation | Pointer-hover flag for the standard ADV all-message Skip button at (728,572). See adv_hover_history. | | `0x6cc` | adv_hover_read_message_skip | high | investigation | Pointer-hover flag for the standard ADV read-message-only Skip button at (750,572). See adv_hover_history. | | `0x6cd` | adv_hover_hide_window | high | investigation | Pointer-hover flag for the standard ADV Hide-window button at (772,572). See adv_hover_history. | +| `0x62425` | adv_hide_window_enabled | high | investigation | Native ADV-scheduler permission for the standard Hide Window action. After op 0x199 enters the registered yield-A handler, every standard ADV scene calls HIDEWIN.BIN only while this value is nonzero. No script writes it and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native-owned inherited state rather than saved-game or script boot data. The Godot scene bootstrap mirrors the original enabled value 1. | ## unknown diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 7767ca0..89e04eb 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -141,7 +141,7 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t ### 0x199 `yield-adv-coroutine` (u00414D50, argc 0) - **summary:** Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow. -- **grounding:** source=investigation, confidence=med +- **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN. ### 0x1cc `get-adv-read-skip-state` (get-adv-read-skip-state, argc 1) @@ -448,16 +448,16 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ### 0x108 `get-mouse-button-state` (u00415E70, argc 1) - **summary:** (out) - return the current mouse-button state bitmask. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x108_get_mouse_button_state@0x428b60 fills a local through the mouse-state helper at 0x4602e0 and writes it to operand 1. HIDEWIN.BIN and HISTORY.BIN test individual bits to detect press/release transitions. +- **evidence:** Ghidra /v2: op_0x108_get_mouse_button_state@0x428b60 fills a local through the mouse-state helper at 0x4602e0 and writes it to operand 1. HIDEWIN.BIN uses bit 0x1 for left-click edge interaction and bit 0x2 for its close/restore gesture; HISTORY.BIN likewise tests individual bits for transitions. ### 0x109 `get-cursor-virtual` (u00415EC0, argc 2) - **summary:** (out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates. -- **grounding:** source=investigation, confidence=high, noop_headless=True +- **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x109_get_cursor_virtual@0x428bb0 calls the cursor-position helper, converts client/display coordinates through the active VirtualFullScreen transform, and writes x/y to operands 1/2. The ADV chrome callbacks preserve x and then move y by alternating -1/+1 before op 0x10a. ### 0x10a `set-cursor-virtual` (u0041E540, argc 2) - **summary:** (x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor. -- **grounding:** source=investigation, confidence=high, noop_headless=True +- **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x10a_set_cursor_virtual@0x421590 maps virtual coordinates through the active VirtualFullScreen geometry and calls SetCursorPos. SC0000 alternates the cursor by one vertical pixel after state-changing ADV button clicks so the hover state re-enters cleanly. ### 0x19a `get-message-skip` (u00414E50, argc 1) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index d7cdd8d..02d5c8b 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1599,3 +1599,24 @@ land the generic coroutine/input-callback service that History will later reuse. the next bounded persistence slice; History should follow after both foundations exist. Full native evidence and the difficulty table live in [`engine-re.md`](engine-re.md#remaining-adv-control-strip-actions-and-implementation-cost-2026-07-18). + +### ADV Hide Window implemented (2026-07-18) + +The real x=772 callback now yields through op `0x199`, runs the shipped `HIDEWIN.BIN`, re-enters the second +ADV coroutine handler, and resumes after the original yield via op `0x7c`. The VM owns frame-local timed +mouse and 32-entry input callback registrations; Godot supplies virtual coordinates, distinct left/right +button bits, directional/action indices, and release callbacks. Cursor ids `0x3318..0x331f` resolve from the +original asset catalog, decode from Windows CUR XOR/AND masks with native hotspots, and install as Godot +custom cursors. + +`G[0x62425]` is now named `adv_hide_window_enabled`. It is not a temporary test seed or save-slot value: all +ordinary ADV scripts read it, none writes it, and the full VM-write capture misses it because AGE's native +scheduler owns the inherited state. The bounded Godot scene bootstrap mirrors the original enabled value 1. +Focused tests cover real SC0000 activation and HIDEWIN return, the two-handler coroutine sequence, timed +mouse callbacks, held input callbacks, cursor opcode forwarding, and decoding a real CUR asset. +Validation: engine 175/175, opcode/global generator tests and lints clean, zero-warning Godot build, and +threaded `SELFTEST OK`. + +**Next:** manually validate x=772 hide/restore and edge cursors in a normal SC0000 run. The next development +slice should be Read-message Skip's profile-wide ReadTextDB model; History can then reuse both that +message-completion seam and the generic callback/input layer landed here. diff --git a/engine/Age.Engine.Tests/CurDecoderTests.cs b/engine/Age.Engine.Tests/CurDecoderTests.cs new file mode 100644 index 0000000..0fc6abb --- /dev/null +++ b/engine/Age.Engine.Tests/CurDecoderTests.cs @@ -0,0 +1,28 @@ +using System.Linq; +using Age.Engine.Sys4; +using Xunit; + +public class CurDecoderTests +{ + [Fact] + public void HimegariCursor_DecodesPixelsAndHotspot() + { + var resources = ResourceMap.Load(); + var entry = resources.ResolveCursor(0x3318); + + Assert.NotNull(entry); + Assert.Equal("CURSOR03.CUR", entry!.Name); + var cursor = resources.DecodeCursor(entry); + + Assert.Equal(32, cursor.Image.Width); + Assert.Equal(32, cursor.Image.Height); + Assert.Equal(25, cursor.HotspotX); + Assert.Equal(25, cursor.HotspotY); + Assert.Contains(cursor.Image.Pixels.Where((_, i) => i % 4 == 3), alpha => alpha == 0); + Assert.Contains(cursor.Image.Pixels.Where((_, i) => i % 4 == 3), alpha => alpha == 255); + } + + [Fact] + public void TruncatedCursor_IsRejected() + => Assert.Throws(() => CurDecoder.Decode(new byte[21], "bad.cur")); +} diff --git a/engine/Age.Engine.Tests/HotspotInputTests.cs b/engine/Age.Engine.Tests/HotspotInputTests.cs index f260ec8..efce646 100644 --- a/engine/Age.Engine.Tests/HotspotInputTests.cs +++ b/engine/Age.Engine.Tests/HotspotInputTests.cs @@ -90,6 +90,35 @@ public class HotspotInputTests } } + private sealed class Sc0000HideWindowHost : RecordingHost + { + public VirtualMachine Vm = null!; + private long _now; + public int HideLoopSleeps; + public override long InputClockMilliseconds => _now; + + public override void Sleep(long duration) + { + base.Sleep(duration); + _now += System.Math.Max(16, duration); + if (duration <= 1 && ++HideLoopSleeps == 2) + { + Vm.UpdateMouseButtonState(0x1, false); // release the x=772 activation click + Vm.UpdateMouseButtonState(0x2, true); // native right-click close/restore gesture + } + } + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Vm.UpdatePointer(772, 572); + while (serviceInputCallback()) { } + Vm.UpdateMouseButtonState(0x1, true); + Assert.True(Vm.TryActivatePointer(772, 572)); + while (serviceInputCallback()) { } + throw new StopAtFirstWaitException(); + } + } + [Fact] public void ArmedHotspot_DispatchesHoverAndConsumesActivationWithoutAdvancingPage() { @@ -278,6 +307,46 @@ public class HotspotInputTests Assert.Contains(true, host.MessageSkipChanges); } + [Fact] + public void CursorOpcodes_ForwardResourceAndClearToHost() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "CURSOR", new List<(int, Operand[])> + { + (0x86, new[] { I(0x3318) }), + (0x87, Array.Empty()), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + + new VirtualMachine(script, table, host).Run(); + + Assert.Equal(new long[] { 0x3318 }, host.CursorResources); + Assert.Equal(1, host.CursorClearCount); + } + + [Fact] + public void Sc0000HideWindowButton_RunsRealHidewinAndReturnsToAdvWait() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var scene = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); + var hide = Sys4Loader.Load(Paths.Scripts()["HIDEWIN.BIN"], table); + var provider = new MapProvider(new Dictionary { [0x20] = hide }); + var trace = new RecordingTraceSink { TracingSteps = true }; + var host = new Sc0000HideWindowHost(); + var vm = new VirtualMachine(scene, table, host, new VmOptions(MaxSteps: 1_000_000), provider, trace); + host.Vm = vm; + vm.Globals[0x6c1] = 1; + vm.Globals[0x62425] = 1; // inherited native ADV scheduler state, mirrored by Godot Main + + Assert.Throws(() => vm.Run()); + + Assert.True(host.HideLoopSleeps >= 2, + $"hide sleeps={host.HideLoopSleeps}; halt={vm.HaltReason}; frames={string.Join(',', trace.Events.Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter).Select(e => e.Name))}; tail={string.Join(',', trace.Events.Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.Step).TakeLast(30).Select(e => $"{e.Ins!.Offset:x}:{e.Opcode:x}"))}"); + Assert.Contains(trace.Events, e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter + && e.Name?.EndsWith("HIDEWIN.BIN", StringComparison.OrdinalIgnoreCase) == true); + } + [Fact] public void MessageSkipState_ReachesHostBeforeFollowingOpcodeCadenceYields() { @@ -297,6 +366,81 @@ public class HotspotInputTests Assert.Equal(3, host.ActiveSkipYields); } + [Fact] + public void AdvCoroutineYield_RunsHandlerAThenHandlerBAndResumesAfterOpcode() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + const int handlerA = 17, handlerB = 23; + var script = ScriptAssembler.Assemble(table, "ADV_COROUTINE", new List<(int, Operand[])> + { + (0x7b, new[] { I(handlerA), I(handlerB) }), + (0x55, new[] { G(0x160), I(1) }), + (0x199, Array.Empty()), + (0x55, new[] { G(0x163), I(1) }), + (0x2, Array.Empty()), + (0x55, new[] { G(0x161), I(1) }), + (0x199, Array.Empty()), + (0x55, new[] { G(0x162), I(1) }), + (0x7c, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x160)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x161)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x162)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x163)); + Assert.Equal("exit", vm.HaltReason); + } + + [Fact] + public void MouseCallback_UsesLivePointerAndButtonState() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + const int callback = 7; + var script = ScriptAssembler.Assemble(table, "MOUSE_CALLBACK", new List<(int, Operand[])> + { + (0xcc, new[] { I(0), I(callback) }), + (0xcd, Array.Empty()), + (0x2, Array.Empty()), + (0x109, new[] { G(0x170), G(0x171) }), + (0x108, new[] { G(0x172) }), + (0x5, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + vm.UpdatePointer(321, 456); + vm.UpdateMouseButtonState(0x1, true); + + vm.Run(); + + Assert.Equal(321, vm.Globals.GetValueOrDefault(0x170)); + Assert.Equal(456, vm.Globals.GetValueOrDefault(0x171)); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x172)); + } + + [Fact] + public void JoyCallbackTable_DispatchesHeldInput() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + const int callback = 8; + var script = ScriptAssembler.Assemble(table, "JOY_CALLBACK", new List<(int, Operand[])> + { + (0xfb, new[] { I(0), I(callback) }), + (0xff, Array.Empty()), + (0x100, Array.Empty()), + (0x2, Array.Empty()), + (0x55, new[] { G(0x180), I(1) }), + (0x5, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, table, new RecordingHost()); + vm.UpdateInputCallbackState(0, true); + + vm.Run(); + + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x180)); + } + private static Operand I(long value) => new(0, value); private static Operand G(long address) => new(3, address); } diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 0433ae2..43fb9f9 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -25,6 +25,8 @@ internal class RecordingHost : IHost public readonly List<(int Target, long Duration)> BgmFades = new(); public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); public readonly List MessageSkipChanges = new(); + public readonly List CursorResources = new(); + public int CursorClearCount; public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) @@ -40,7 +42,10 @@ internal class RecordingHost : IHost Func autoWaitState) => WaitForInput(layoutSlot, serviceInputCallback); public void InputCallbackCompleted(GfxState gfx) => InputCallbackFrames++; - public void Sleep(long duration) => SleptDurations.Add(duration); + public virtual long InputClockMilliseconds => Environment.TickCount64; + public void SetCursorResource(long resourceId) => CursorResources.Add(resourceId); + public void ClearCursorResource() => CursorClearCount++; + public virtual void Sleep(long duration) => SleptDurations.Add(duration); public virtual void FrameYield() { } public bool IsMessageSkipActive => MessageSkip; public void SetMessageSkipActive(bool active) diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index fd35aa8..a71f2b8 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -32,6 +32,11 @@ public interface IHost => WaitForInput(layoutSlot, serviceInputCallback); void WakeInputCallbackService() { } void InputCallbackCompleted(GfxState gfx) { } + // Generic AGE input-callback services (ops 0xcc/0xcd, 0xfb/0xff/0x100, 0x108). + // Interactive hosts expose the same monotonic clock used by their frame scheduler. + long InputClockMilliseconds => Environment.TickCount64; + void SetCursorResource(long resourceId) { } + void ClearCursorResource() { } void Sleep(long duration); void FrameYield(); // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive diff --git a/engine/Age.Engine/Sys4/CurDecoder.cs b/engine/Age.Engine/Sys4/CurDecoder.cs new file mode 100644 index 0000000..a10c797 --- /dev/null +++ b/engine/Age.Engine/Sys4/CurDecoder.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; + +namespace Age.Engine.Sys4; + +/// A decoded Windows cursor image and its native hotspot. +public sealed record CursorImage(RgbaImage Image, int HotspotX, int HotspotY); + +/// Decoder for Himegari's monochrome Windows .CUR resources. +public static class CurDecoder +{ + public static CursorImage Decode(ReadOnlySpan file, string name = "CUR") + { + if (file.Length < 22 || U16(file, 0) != 0 || U16(file, 2) != 2 || U16(file, 4) < 1) + throw new InvalidDataException($"{name}: expected a Windows cursor directory"); + + int width = file[6] == 0 ? 256 : file[6]; + int height = file[7] == 0 ? 256 : file[7]; + int hotspotX = U16(file, 10); + int hotspotY = U16(file, 12); + int imageSize = I32(file, 14); + int imageOffset = I32(file, 18); + if (imageSize <= 0 || imageOffset < 22 || imageOffset > file.Length - imageSize) + throw new InvalidDataException($"{name}: cursor image range is invalid"); + + int headerSize = I32(file, imageOffset); + if (headerSize < 40 || imageOffset > file.Length - headerSize) + throw new InvalidDataException($"{name}: unsupported bitmap header"); + int dibWidth = I32(file, imageOffset + 4); + int dibHeight = I32(file, imageOffset + 8); + int planes = U16(file, imageOffset + 12); + int bitsPerPixel = U16(file, imageOffset + 14); + int compression = I32(file, imageOffset + 16); + if (dibWidth != width || System.Math.Abs(dibHeight) != height * 2 || planes != 1 + || bitsPerPixel != 1 || compression != 0) + throw new InvalidDataException($"{name}: expected an uncompressed 1-bit {width}x{height} cursor"); + + int paletteOffset = checked(imageOffset + headerSize); + if (paletteOffset > file.Length - 8) throw new InvalidDataException($"{name}: palette is truncated"); + int xorStride = checked(((width + 31) / 32) * 4); + int maskBytes = checked(xorStride * height); + int xorOffset = checked(paletteOffset + 8); + int andOffset = checked(xorOffset + maskBytes); + if (andOffset > file.Length - maskBytes) throw new InvalidDataException($"{name}: cursor masks are truncated"); + + var rgba = new byte[checked(width * height * 4)]; + bool bottomUp = dibHeight > 0; + for (int y = 0; y < height; y++) + { + int sourceY = bottomUp ? height - 1 - y : y; + int xorRow = xorOffset + sourceY * xorStride; + int andRow = andOffset + sourceY * xorStride; + for (int x = 0; x < width; x++) + { + int shift = 7 - (x & 7); + int paletteIndex = (file[xorRow + (x >> 3)] >> shift) & 1; + bool transparent = ((file[andRow + (x >> 3)] >> shift) & 1) != 0 && paletteIndex == 0; + int palette = paletteOffset + paletteIndex * 4; + int dst = (y * width + x) * 4; + rgba[dst] = file[palette + 2]; + rgba[dst + 1] = file[palette + 1]; + rgba[dst + 2] = file[palette]; + rgba[dst + 3] = transparent ? (byte)0 : (byte)255; + } + } + + return new CursorImage(new RgbaImage(width, height, rgba), hotspotX, hotspotY); + } + + private static int U16(ReadOnlySpan data, int offset) + { + if ((uint)offset > (uint)(data.Length - 2)) throw new InvalidDataException("CUR: truncated field"); + return BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + } + + private static int I32(ReadOnlySpan data, int offset) + { + if ((uint)offset > (uint)(data.Length - 4)) throw new InvalidDataException("CUR: truncated field"); + return BinaryPrimitives.ReadInt32LittleEndian(data[offset..]); + } +} diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 1f3f305..9aa2d99 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -37,6 +37,21 @@ public sealed class ResourceMap /// Decode an AGF directly from loose-first VFS bytes. public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry); + /// Resolve a native packed raw id to one of AGE's Windows cursor resources. + public AssetEntry? ResolveCursor(long resourceId) + { + var entry = _catalog.ResolvePacked(resourceId); + return entry is { IsPlaceholder: false } + && entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase) ? entry : null; + } + + public CursorImage DecodeCursor(AssetEntry entry) + { + if (!entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"not a CUR asset: {entry.Name}"); + return CurDecoder.Decode(_store.ReadAll(entry), entry.Name); + } + public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name); /// diff --git a/engine/Age.Engine/Vm/ExecFrame.cs b/engine/Age.Engine/Vm/ExecFrame.cs index 8d02dad..7840801 100644 --- a/engine/Age.Engine/Vm/ExecFrame.cs +++ b/engine/Age.Engine/Vm/ExecFrame.cs @@ -13,7 +13,15 @@ internal sealed class ExecFrame public readonly Dictionary EmitSeen = new(); public int? CoroutineYieldHandlerA; // op 0x7b: native per-frame handler PCs public int? CoroutineYieldHandlerB; + public int? CoroutineResumePc; // op 0x199 -> handler A/B -> op 0x7c + public bool CoroutineYieldActive; public readonly Dictionary CoroutineYieldVisits = new(); // instruction index -> visits + public readonly int[] InputCallbackTargets = Enumerable.Repeat(-1, 32).ToArray(); // op 0xfb + public int PendingInputCallbackMask; // op 0xff snapshot consumed by op 0x100 + public int InputCallbackScanIndex; + public int MouseCallbackTarget = -1; // op 0xcc target dword offset + public long MouseCallbackIntervalMs; + public long MouseCallbackNextAtMs; public readonly HotspotRegistry Hotspots = new(); public ExecFrame(Script script, int pc) { Script = script; Pc = pc; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index b0704bc..0095a60 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -26,6 +26,9 @@ public sealed class VirtualMachine private readonly object _interactiveLock = new(); private ExecFrame? _interactiveFrame; private int _pointerX = int.MinValue, _pointerY = int.MinValue; + private int _mouseButtonState; + private int _heldInputCallbackMask; + private int _queuedInputCallbackMask; private bool _autoMessageEnabled; private long _autoMessageTime0Ms = 500; private long _autoMessageTime1Ms = 2000; @@ -79,6 +82,39 @@ public sealed class VirtualMachine return consumed; } + /// Update one native mouse-button bit (left=0x1, right=0x2 in Himegari). + public void UpdateMouseButtonState(int bit, bool pressed) => UpdateMaskBit(ref _mouseButtonState, bit, pressed); + + /// Update one held AGE input-callback index used by ops 0xfb/0xff/0x100. + public void UpdateInputCallbackState(int index, bool pressed) + { + if ((uint)index >= 32) return; + UpdateMaskBit(ref _heldInputCallbackMask, 1 << index, pressed); + } + + /// Queue a one-shot AGE input callback, such as the shared release callback at index 10. + public void QueueInputCallback(int index) + { + if ((uint)index >= 32) return; + int bit = 1 << index; + int before, after; + do + { + before = Volatile.Read(ref _queuedInputCallbackMask); + after = before | bit; + } while (Interlocked.CompareExchange(ref _queuedInputCallbackMask, after, before) != before); + } + + private static void UpdateMaskBit(ref int field, int bit, bool set) + { + int before, after; + do + { + before = Volatile.Read(ref field); + after = set ? before | bit : before & ~bit; + } while (Interlocked.CompareExchange(ref field, after, before) != before); + } + private static long Gi(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : 0; private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k); private static string Gs(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : ""; @@ -253,7 +289,13 @@ public sealed class VirtualMachine if (sentinel >= 0) _cur.CallStack.RemoveAt(sentinel); } lock (_interactiveLock) + { + // History/Hide callbacks cancel the active registry, run a nested script, then republish the + // parent frame's definitions. Nested RunFrame deliberately clears the disarmed interactive + // pointer, so restore the still-running parent before rearming its rebuilt registry. + if (_interactiveFrame == null && _cur.Hotspots.HasDefinitions) _interactiveFrame = _cur; _interactiveFrame?.Hotspots.RearmAfterCallback(_pointerX, _pointerY); + } _host.InputCallbackCompleted(Gfx); return true; } @@ -307,9 +349,31 @@ public sealed class VirtualMachine _cur.CoroutineYieldHandlerA = (int)Read(a[0]); _cur.CoroutineYieldHandlerB = (int)Read(a[1]); return pc + 1; + case "u00414D50": + case "yield-adv-coroutine": // 0x199: A -> nested service -> B -> 0x7c resume + { + int? targetOffset; + if (!_cur.CoroutineYieldActive) + { + _cur.CoroutineResumePc = pc + 1; + _cur.CoroutineYieldActive = true; + targetOffset = _cur.CoroutineYieldHandlerA; + } + else targetOffset = _cur.CoroutineYieldHandlerB; + + return targetOffset is int offset + ? _cur.Script.IndexByOffset.GetValueOrDefault(offset, pc + 1) + : pc + 1; + } case "u00416A90": - case "coroutine-resume": // 0x7c: host FrameYield/FrameClock owns re-entry - return pc + 1; + case "coroutine-resume": // 0x7c: restore the PC saved by op 0x199 + if (_cur.CoroutineResumePc is int resumePc) + { + _cur.CoroutineResumePc = null; + _cur.CoroutineYieldActive = false; + return resumePc; + } + return pc + 1; // cold bounded scene-entry path case "u0041F9C0": case "coroutine-label-yield": // 0x140: bounded host model for LABEL/J only { @@ -426,6 +490,71 @@ public sealed class VirtualMachine _cur.Hotspots.BindKey((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4])); return pc + 1; + case "u0041B210": + case "set-cursor-resource": // 0x86: raw indexed .CUR resource + _host.SetCursorResource(Read(a[0])); return pc + 1; + case "u00414D10": + case "clear-cursor-resource": // 0x87 + _host.ClearCursorResource(); return pc + 1; + case "mouse_callback": + case "register-mouse-callback": // 0xcc (poll interval ms, local target dword offset) + _cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0])); + _cur.MouseCallbackTarget = (int)Read(a[1]); + _cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs; + return pc + 1; + case "get-input-type": + case "dispatch-mouse-callback": // 0xcd + { + long now = _host.InputClockMilliseconds; + if (_cur.MouseCallbackTarget < 0 || now < _cur.MouseCallbackNextAtMs) return pc + 1; + _cur.MouseCallbackNextAtMs = now + _cur.MouseCallbackIntervalMs; + if (!_cur.Script.IndexByOffset.TryGetValue(_cur.MouseCallbackTarget, out int target)) + return pc + 1; + _cur.CallStack.Add(pc + 1); + return target; + } + case "joy_callback": + case "register-joy-callback": // 0xfb (input index, local target dword offset) + { + int index = (int)Read(a[0]); + if ((uint)index < 32) _cur.InputCallbackTargets[index] = (int)Read(a[1]); + return pc + 1; + } + case "u00415A10": + case "poll-joy-callback-input": // 0xff + _cur.PendingInputCallbackMask = Volatile.Read(ref _heldInputCallbackMask) + | Interlocked.Exchange(ref _queuedInputCallbackMask, 0); + _cur.InputCallbackScanIndex = 0; + return pc + 1; + case "u00415A60": + case "dispatch-joy-callbacks": // 0x100 + while (_cur.InputCallbackScanIndex < 32) + { + int index = _cur.InputCallbackScanIndex++; + if ((_cur.PendingInputCallbackMask & (1 << index)) == 0) continue; + int targetOffset = _cur.InputCallbackTargets[index]; + if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target)) + continue; + // Resume on op 0x100 so another simultaneously active input can dispatch. + _cur.CallStack.Add(pc); + return target; + } + return pc + 1; + case "u00415E70": + case "get-mouse-button-state": // 0x108 + Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1; + case "u00415EC0": + case "get-cursor-virtual": // 0x109 + { + int x, y; + lock (_interactiveLock) { x = _pointerX; y = _pointerY; } + Write(a[0], x == int.MinValue ? 0 : x); + Write(a[1], y == int.MinValue ? 0 : y); + return pc + 1; + } + case "u0041E540": + case "set-cursor-virtual": // 0x10a; retain the virtual position even without OS warping + UpdatePointer((int)Read(a[0]), (int)Read(a[1])); return pc + 1; case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing. _host.Sleep(Read(a[0])); return pc + 1; case "u0041B290": diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 4a34198..c0e03d2 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -253,6 +253,31 @@ public sealed class GodotAdvHost : IHost public void InputCallbackCompleted(GfxState gfx) => Interlocked.Exchange(ref _presentRequested, 1); + public long InputClockMilliseconds => _clock.NowMs; + + public void SetCursorResource(long resourceId) + { + var asset = _res.ResolveCursor(resourceId); + if (asset == null) return; + try + { + var cursor = _res.DecodeCursor(asset); + _main.CallDeferred("SetAgeCursor", cursor.Image.Pixels, cursor.Image.Width, cursor.Image.Height, + cursor.HotspotX, cursor.HotspotY); + _timeline?.Event("cursor-set", new() { ["resource"] = resourceId, ["asset"] = asset.Name }); + } + catch (Exception ex) + { + System.Console.Error.WriteLine($"cursor decode {asset.Name}: {ex.Message}"); + } + } + + public void ClearCursorResource() + { + _main.CallDeferred("ClearAgeCursor"); + _timeline?.Event("cursor-clear", new()); + } + public void WaitForForegroundTransition(GfxState gfx) { int started = gfx.StartForegroundTransitions(_clock.NowMs); @@ -308,6 +333,7 @@ public sealed class GodotAdvHost : IHost public void Stop() { _stopping = true; + _main.CallDeferred("ClearAgeCursor"); lock (_textLock) _advTextForceComplete = true; if (_gate.CurrentCount == 0) _gate.Release(); _inputCallbackSignal.Set(); diff --git a/godot/Main.cs b/godot/Main.cs index 5fbfb02..9cffb08 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -17,6 +17,7 @@ public partial class Main : Godot.Control private TextureRect _screenView = null!; // shows the composited screen backbuffer private Image _screen = null!; // 800x600 immediate-mode canvas private ImageTexture _screenTex = null!; + private ImageTexture? _ageCursorTexture; private TextureRect _waitIndicator = null!; private ImageTexture? _waitIndicatorSheet; private AtlasTexture? _waitIndicatorAtlas; @@ -237,7 +238,13 @@ public partial class Main : Godot.Control // The native SYSTEM4 UI boot enables standard ADV chrome after the data-only *INIT prefix above. // Without this inherited value the visible SO001 strip is still drawn, but every ADV script skips // its five pointer rectangles and registers only the off-screen keyboard/pad records. - if (!_selftest) _vm.Globals[0x6c1] = 1; + if (!_selftest) + { + _vm.Globals[0x6c1] = 1; + // The native ADV scheduler supplies this Hide Window permission outside script-visible + // writes. Every ADV scene gates its HIDEWIN call on it after opcode 0x199 re-entry. + _vm.Globals[0x62425] = 1; + } // Native AGE owns this transient secondary-SFX channel outside script-visible writes. // The matching SC0000 trace has value 4 at 0xc31; seed only this proven profile/slice. if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) @@ -339,20 +346,40 @@ public partial class Main : Godot.Control _vm.UpdatePointer(p.X, p.Y); return; } - if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left) + if (e is InputEventMouseButton mb + && (mb.ButtonIndex == MouseButton.Left || mb.ButtonIndex == MouseButton.Right)) { var p = ToNativeScreen(mb.Position); - if (_vm.TryActivatePointer(p.X, p.Y)) + _vm.UpdatePointer(p.X, p.Y); + int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2; + _vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed); + if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y)) { GetViewport().SetInputAsHandled(); return; } - _host.SignalInput(); + if (mb.ButtonIndex == MouseButton.Left && mb.Pressed) _host.SignalInput(); return; } + UpdateAgeInputCallback(e, "ui_down", 0); + UpdateAgeInputCallback(e, "ui_left", 1); + UpdateAgeInputCallback(e, "ui_up", 2); + UpdateAgeInputCallback(e, "ui_right", 3); + UpdateAgeInputCallback(e, "ui_accept", 4); + UpdateAgeInputCallback(e, "ui_cancel", 5); if (e.IsActionPressed("ui_accept")) _host.SignalInput(); } + private void UpdateAgeInputCallback(InputEvent e, StringName action, int index) + { + if (e.IsActionPressed(action)) _vm.UpdateInputCallbackState(index, true); + if (e.IsActionReleased(action)) + { + _vm.UpdateInputCallbackState(index, false); + _vm.QueueInputCallback(10); // common release callback registered by HISTORY/HIDEWIN + } + } + private (int X, int Y) ToNativeScreen(Vector2 position) { Vector2 size = GetViewportRect().Size; @@ -361,6 +388,20 @@ public partial class Main : Godot.Control (int)System.Math.Floor(position.Y * ScreenHeight / size.Y)); } + public void SetAgeCursor(byte[] rgba, int width, int height, int hotspotX, int hotspotY) + { + var image = Image.CreateFromData(width, height, false, Image.Format.Rgba8, rgba); + _ageCursorTexture = ImageTexture.CreateFromImage(image); + Input.SetCustomMouseCursor(_ageCursorTexture, Input.CursorShape.Arrow, + new Vector2(hotspotX, hotspotY)); + } + + public void ClearAgeCursor() + { + Input.SetCustomMouseCursor(null, Input.CursorShape.Arrow); + _ageCursorTexture = null; + } + public override void _ExitTree() { DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose(); diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index ab1dc20..b6c8fc6 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -26,10 +26,10 @@ INFERRED: dict[int, dict] = { 0x100: dict(name='dispatch-joy-callbacks', category='input', noop=False, confidence='high', source='investigation', summary='Dispatch registered callbacks for the current or pending joy/input selection.'), 0x101: dict(name='reset-message-skip-input', category='input', noop=False, confidence='med', source='investigation', summary="Reset transient message-skip/input service state after an ADV chrome action without clearing op 0x88's persistent all-message Skip flag."), 0x108: dict(name='get-mouse-button-state', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current mouse-button state bitmask.'), - 0x109: dict(name='get-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary="(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates."), - 0x10a: dict(name='set-cursor-virtual', category='input', noop=True, confidence='high', source='investigation', summary='(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor.'), + 0x109: dict(name='get-cursor-virtual', category='input', noop=False, confidence='high', source='investigation', summary="(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates."), + 0x10a: dict(name='set-cursor-virtual', category='input', noop=False, confidence='high', source='investigation', summary='(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor.'), 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), - 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='med', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), + 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='high', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), 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/globals.toml b/vm-map/globals.toml index 83e9087..067eabb 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -61,6 +61,17 @@ source = "investigation" confidence = "high" depends_on = ["0x6c9", "0x6ca", "0x6cb", "0x6cc", "0x6cd"] +[[global]] +address = "0x62425" +name = "adv_hide_window_enabled" +category = "ui-toggle" +type = "int" +value_domain = "{0,1}" +usage = "Native ADV-scheduler permission for the standard Hide Window action. After op 0x199 enters the registered yield-A handler, every standard ADV scene calls HIDEWIN.BIN only while this value is nonzero. No script writes it and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native-owned inherited state rather than saved-game or script boot data. The Godot scene bootstrap mirrors the original enabled value 1." +source = "investigation" +confidence = "high" +depends_on = ["0x6c1"] + [[global]] address = "0x6c9" name = "adv_hover_history" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index a562b4c..30578dd 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2344,7 +2344,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x108_get_mouse_button_state@0x428b60 fills a local through the mouse-state helper at 0x4602e0 and writes it to operand 1. HIDEWIN.BIN and HISTORY.BIN test individual bits to detect press/release transitions." +evidence = "Ghidra /v2: op_0x108_get_mouse_button_state@0x428b60 fills a local through the mouse-state helper at 0x4602e0 and writes it to operand 1. HIDEWIN.BIN uses bit 0x1 for left-click edge interaction and bit 0x2 for its close/restore gesture; HISTORY.BIN likewise tests individual bits for transitions." [[opcode.semantics.args]] i = 1 @@ -2361,7 +2361,7 @@ abi_source = "kelebek+decode-validated" name = "get-cursor-virtual" category = "input" summary = "(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates." -noop_headless = true +noop_headless = false source = "investigation" confidence = "high" depends_on = [] @@ -2387,7 +2387,7 @@ abi_source = "kelebek+decode-validated" name = "set-cursor-virtual" category = "input" summary = "(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor." -noop_headless = true +noop_headless = false source = "investigation" confidence = "high" depends_on = [] @@ -3260,7 +3260,7 @@ category = "control" summary = "Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow." noop_headless = false source = "investigation" -confidence = "med" +confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0x199_yield_adv_coroutine@0x416440 selects the registered coroutine yield-A or yield-B PC according to ctx+0x6dbc8, saves the current resume offset/state, and redirects the current frame PC. SC0000's x=772 ADV button invokes it; the SO001 tooltip at source x=528 reads Window hide, and the surrounding coroutine calls HIDEWIN.BIN."