diff --git a/docs/engine-re.md b/docs/engine-re.md index fa1383b..e1c5b34 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2891,6 +2891,13 @@ character cells, copies that text into both `0x144` operands, then calls `0x2c6` the VM worker blocks while Godot's main thread presents a LineEdit dialog, validates the CP932 byte limit and full-width-only rule, and signals the worker on accept or cancel. No save/profile format is involved. +**Port implementation (2026-07-29).** `Cp932Text` now owns the byte-level character count, substring, +and AGERC acceptance rules. The VM dispatches all three handlers and exposes the modal as an explicit +accept/cancel `IHost` exchange, so cancellation cannot overwrite operand 1. Godot uses a main-thread +`FullwidthTextEditorDialog` while its VM worker waits; over-16-byte and non-double-byte submissions retain +focus and show AGERC's exact Japanese errors. The helper operates on the C-string prefix and encodes through +the configured native code page, avoiding both UTF-16 indexing and accidental splitting of CP932 pairs. + `0xc2` is BGM rather than SFX: `op_0xc2_bgm_fade@0x4204c0` sets run-state `0x200`, arms the service timer, and calls `bgm_fade_arm@0x464830`. `bgm_fade_tick@0x464960` linearly interpolates current to target percent; durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index b6f5aec..9a25387 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -423,14 +423,14 @@ This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all - **grounding:** source=investigation, confidence=high - **evidence:** The real /v2 dispatch slot registers op_0x2c6_cp932_character_length@0x42a6d0. The handler sets LC_ALL to `japanese`, resolves operand 2, calls MSVC _mbstrlen, and writes the result to operand 1. INPUTNAME's sole site at 0x1002 uses this count as the loop bound before slicing each character with opcode 0x2c8. -This is character count rather than .NET UTF-16 length or raw CP932 byte length. The implementation should use VmOptions.NativeStringCodePage and preserve valid CP932 multibyte boundaries. +This is character count rather than .NET UTF-16 length or raw CP932 byte length. Port status (2026-07-29): implemented by encoding the C-string prefix through VmOptions.NativeStringCodePage and counting valid CP932 lead/trail pairs as one character; focused tests cover mixed single-byte and double-byte characters. ### 0x2c8 `cp932-substring` (cp932-substring, argc 4) - **summary:** (out)(string)(start)(count) - copy a CP932 substring selected by multibyte-character index and count without splitting valid lead/trail pairs. - **grounding:** source=investigation, confidence=high - **evidence:** The real /v2 dispatch slot registers op_0x2c8_cp932_substring@0x42c420. It copies operand 2 into a 256-byte buffer, sets LC_ALL to `japanese`, obtains _mbstrlen, reads start and count, clamps end=start+count to the character length when end is below 1 or beyond that length, and walks bytes with _mbbtype so CP932 lead/trail pairs are copied together. It writes the selected byte interval back through operand 1. INPUTNAME's sole site at 0x1021 loops substring(name,index,1) into its eight local character cells. -The release call uses nonnegative in-range indices and count 1. A compatible general implementation should reproduce native end clamping (`end = length` when start+count < 1 or > length) and select the half-open character interval [start,end). +The release call uses nonnegative in-range indices and count 1. Port status (2026-07-29): implemented over encoded byte spans, preserving every valid lead/trail pair and reproducing native end clamping (`end = length` when start+count < 1 or > length) before selecting the half-open character interval [start,end). ## control @@ -1256,6 +1256,11 @@ character palette. The VM can use a synchronous host seam like the existing diag the Godot host runs script execution on its worker thread while the main thread owns the modal UI. Compatibility requires the native full-width-only and 16-CP932-byte limits before accepting the result. +Port status (2026-07-29): implemented. The VM issues an explicit accept/cancel request through IHost; +headless hosts preserve the inout value by default. Godot parks the VM worker, opens a main-thread modal +LineEdit, keeps it open on either native validation error, and resumes the worker only after valid accept +or cancel. Accept replaces operand 1 and cancel leaves it untouched; operand 2 is never modified. + ### 0x19a `get-message-skip` (u00414E50, argc 1) - **summary:** (out) - return the current all-message skip state set by op 0x88. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 1e29e63..5b18968 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -930,6 +930,23 @@ gaps and 3 of 11 instructions; no persistence or save-format work is involved. **NEXT:** implement and test `0x144`/`0x2c6`/`0x2c8` together, including CP932 mixed-width helper cases, the native 16-byte/full-width acceptance rules, accept/cancel behavior, and INPUTNAME's split/rejoin shape. +**INPUTNAME string/input slice implemented (2026-07-29):** the VM now dispatches all three opcodes. +`0x2c6` counts CP932 characters rather than UTF-16 units or bytes, and `0x2c8` slices encoded character +spans with native end clamping. A shared validator applies command 10's 16-byte limit first and then +requires every accepted cell to be a valid CP932 lead/trail pair. + +`0x144` uses an explicit synchronous host result, preserving operand 1 on cancel and operand 2 in both +paths. Godot parks the VM worker while a main-thread modal editor owns input; invalid submissions leave +the editor open with AGERC's original error text. Headless hosts cancel safely by default. Focused +regressions cover mixed-width count/slicing, end clamping, valid and invalid names, accept replacement, +cancel preservation, and operand-2 preservation. The slice removes three gaps and three instructions, +leaving 6 effectful opcodes / 8 instructions. Validation passes 493/493 engine tests, opcode and +EngineCtx lint, a zero-warning Godot build, clean diff checking, and the Himegari-targeted threaded +`SELFTEST OK`. + +**NEXT:** rerank the remaining six gaps; begin with the two-site `0x24d` and check whether adjacent +`0x248` belongs to the same native subsystem before choosing the next implementation boundary. + ## Later Phase B breadth **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static diff --git a/engine/Age.Engine.Tests/InputNameStringOpcodeTests.cs b/engine/Age.Engine.Tests/InputNameStringOpcodeTests.cs new file mode 100644 index 0000000..d879791 --- /dev/null +++ b/engine/Age.Engine.Tests/InputNameStringOpcodeTests.cs @@ -0,0 +1,139 @@ +using System.Text; +using Age.Engine.Diagnostics; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class InputNameStringOpcodeTests +{ + 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 Operand GS(int address) => new(5, address); + private static Operand LI(int index) => new(9, index); + private static Operand LS(int index) => new(11, index); + private static Operand LSP(int index) => new(14, index); + private static (int, Operand[]) Exit() => (0x2, []); + + static InputNameStringOpcodeTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [Fact] + public void CharacterLengthAndSubstringUseCp932CharacterBoundaries() + { + Assert.Equal("cp932-character-length", Table.Label(0x2c6)); + Assert.Equal(4, Cp932Text.CharacterLength("AリアB", Encoding.GetEncoding(932))); + int lookup = Table.ByLabel("lookup-array")!.Value; + int move = Table.ByLabel("mov")!.Value; + Script script = ScriptAssembler.Assemble(Table, "INPUTNAME_HELPERS.BIN", + [ + (move, [LS(0), GS(0x700)]), + (0x2c6, [G(0x500), LS(0)]), + (lookup, [LSP(0), GS(0x600), I(0)]), + (0x2c8, [LSP(0), LS(0), I(0), I(1)]), + (lookup, [LSP(1), GS(0x600), I(1)]), + (0x2c8, [LSP(1), LS(0), I(1), I(1)]), + (lookup, [LSP(2), GS(0x600), I(2)]), + (0x2c8, [LSP(2), LS(0), I(2), I(99)]), + Exit(), + ], []); + var trace = new RecordingTraceSink { TracingSteps = true }; + var vm = new VirtualMachine(script, Table, new RecordingHost(), sink: trace); + vm.GlobalStrings[0x700] = "AリアB"; + + vm.Run(); + + Assert.Equal(new[] { move, 0x2c6, lookup, 0x2c8, lookup, 0x2c8, lookup, 0x2c8, 0x2 }, + trace.Events.Where(e => e.Kind == TraceEventKind.Step).Select(e => e.Opcode)); + Assert.Equal(4, vm.Globals[0x500]); + Assert.Equal("A", vm.GlobalStrings[0x600]); + Assert.Equal("リ", vm.GlobalStrings[0x601]); + Assert.Equal("アB", vm.GlobalStrings[0x602]); + } + + [Fact] + public void SubstringReproducesNativeEndClamp() + { + int lookup = Table.ByLabel("lookup-array")!.Value; + int move = Table.ByLabel("mov")!.Value; + Script script = ScriptAssembler.Assemble(Table, "INPUTNAME_CLAMP.BIN", + [ + (move, [LS(0), S(0)]), + (lookup, [LSP(0), GS(0x610), I(0)]), + (0x2c8, [LSP(0), LS(0), I(0), I(0)]), + Exit(), + ], ["魔王"]); + var vm = new VirtualMachine(script, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal("魔王", vm.GlobalStrings[0x610]); + } + + [Fact] + public void ModalEditorAcceptsReplacementAndPreservesInitialOperand() + { + int move = Table.ByLabel("mov")!.Value; + Script script = ScriptAssembler.Assemble(Table, "INPUTNAME_ACCEPT.BIN", + [ + (move, [LS(0), S(0)]), + (move, [LS(1), S(1)]), + (0x144, [LS(0), LS(1)]), + (move, [GS(0x620), LS(0)]), + (move, [GS(0x621), LS(1)]), + Exit(), + ], ["魔王", "魔王"]); + var host = new RecordingHost + { + OnFullwidthTextEdit = _ => new(true, "リリィ"), + }; + var vm = new VirtualMachine(script, Table, host); + + vm.Run(); + + Assert.Equal(new FullwidthTextEditRequest("魔王", "魔王"), + Assert.Single(host.FullwidthTextEdits)); + Assert.Equal("リリィ", vm.GlobalStrings[0x620]); + Assert.Equal("魔王", vm.GlobalStrings[0x621]); + } + + [Fact] + public void ModalEditorCancelLeavesResultOperandUnchanged() + { + int move = Table.ByLabel("mov")!.Value; + Script script = ScriptAssembler.Assemble(Table, "INPUTNAME_CANCEL.BIN", + [ + (move, [LS(0), S(0)]), + (move, [LS(1), S(1)]), + (0x144, [LS(0), LS(1)]), + (move, [GS(0x630), LS(0)]), + Exit(), + ], ["魔王", "初期値"]); + var host = new RecordingHost + { + OnFullwidthTextEdit = _ => new(false, "破棄される文字列"), + }; + var vm = new VirtualMachine(script, Table, host); + + vm.Run(); + + Assert.Equal("魔王", vm.GlobalStrings[0x630]); + } + + [Theory] + [InlineData("", FullwidthTextValidationError.None)] + [InlineData("リリィ", FullwidthTextValidationError.None)] + [InlineData("一二三四五六七八", FullwidthTextValidationError.None)] + [InlineData("一二三四五六七八九", FullwidthTextValidationError.TooLong)] + [InlineData("AB", FullwidthTextValidationError.NonDoubleByteCharacter)] + [InlineData("ア", FullwidthTextValidationError.NonDoubleByteCharacter)] + public void FullwidthValidationMatchesAgercAcceptanceRules( + string text, FullwidthTextValidationError expected) + { + Assert.Equal(expected, Cp932Text.ValidateFullwidthName(text)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index bb29681..473ed3b 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -63,6 +63,8 @@ internal class RecordingHost : IHost public readonly List Warnings = new(); public readonly List Diagnostics = new(); public System.Action? OnDiagnosticMessage; + public readonly List FullwidthTextEdits = new(); + public System.Func? OnFullwidthTextEdit; public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new(); @@ -76,6 +78,11 @@ internal class RecordingHost : IHost Diagnostics.Add(message); OnDiagnosticMessage?.Invoke(message); } + public FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request) + { + FullwidthTextEdits.Add(request); + return OnFullwidthTextEdit?.Invoke(request) ?? new(false, request.CurrentText); + } 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/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 95eea15..fa719bc 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -31,6 +31,10 @@ public readonly record struct SurfaceRectCopy( /// A synchronous AGE-owned diagnostic prompt after native body/context formatting. public readonly record struct DiagnosticMessage(string Caption, string Text); +/// AGERc command 10's synchronous full-width text edit request and result. +public readonly record struct FullwidthTextEditRequest(string CurrentText, string InitialText); +public readonly record struct FullwidthTextEditResult(bool Accepted, string Text); + public enum SurfaceBlackFadeDirection { FromBlack, @@ -44,6 +48,9 @@ public interface IHost /// Present a modal diagnostic and return only after the user dismisses it. void ShowDiagnosticMessage(DiagnosticMessage message) => System.Console.Error.WriteLine($"{message.Caption}: {message.Text}"); + /// Present AGERc's modal full-width editor. Cancel preserves CurrentText. + FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request) + => new(false, request.CurrentText); // 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/Sys4/Cp932Text.cs b/engine/Age.Engine/Sys4/Cp932Text.cs new file mode 100644 index 0000000..cdd26c4 --- /dev/null +++ b/engine/Age.Engine/Sys4/Cp932Text.cs @@ -0,0 +1,98 @@ +using System.Text; + +namespace Age.Engine.Sys4; + +public enum FullwidthTextValidationError +{ + None, + TooLong, + NonDoubleByteCharacter, +} + +/// AGE's Japanese-locale byte-string operations used by INPUTNAME. +public static class Cp932Text +{ + public const int NativeNameByteLimit = 16; + private static readonly Encoding NativeEncoding; + + static Cp932Text() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + NativeEncoding = Encoding.GetEncoding(932); + } + + private static bool IsLeadByte(byte value) + => value is >= 0x81 and <= 0x9f or >= 0xe0 and <= 0xfc; + + private static bool IsTrailByte(byte value) + => value is >= 0x40 and <= 0x7e or >= 0x80 and <= 0xfc; + + private static byte[] EncodeCString(string value, Encoding encoding) + { + int nul = value.IndexOf('\0'); + return encoding.GetBytes(nul < 0 ? value : value[..nul]); + } + + public static int CharacterLength(string value, Encoding encoding) + { + byte[] bytes = EncodeCString(value, encoding); + int count = 0; + for (int offset = 0; offset < bytes.Length; count++) + { + if (IsLeadByte(bytes[offset]) && offset + 1 < bytes.Length + && IsTrailByte(bytes[offset + 1])) + offset += 2; + else + offset++; + } + return count; + } + + public static string Substring( + string value, int start, int count, Encoding encoding) + { + byte[] bytes = EncodeCString(value, encoding); + var characters = new List<(int Offset, int Length)>(); + for (int offset = 0; offset < bytes.Length;) + { + int length = IsLeadByte(bytes[offset]) && offset + 1 < bytes.Length + && IsTrailByte(bytes[offset + 1]) + ? 2 + : 1; + characters.Add((offset, length)); + offset += length; + } + + int end = unchecked(start + count); + if (end < 1 || end > characters.Count) end = characters.Count; + + using var selected = new MemoryStream(); + for (int index = 0; index < characters.Count; index++) + { + if (index < start || index >= end) continue; + (int offset, int length) = characters[index]; + selected.Write(bytes, offset, length); + } + return encoding.GetString(selected.ToArray()); + } + + public static FullwidthTextValidationError ValidateFullwidthName( + string value, Encoding encoding, int byteLimit = NativeNameByteLimit) + { + byte[] bytes = EncodeCString(value, encoding); + if (bytes.Length > byteLimit) return FullwidthTextValidationError.TooLong; + + for (int offset = 0; offset < bytes.Length; offset += 2) + { + if (offset + 1 >= bytes.Length + || !IsLeadByte(bytes[offset]) + || !IsTrailByte(bytes[offset + 1])) + return FullwidthTextValidationError.NonDoubleByteCharacter; + } + return FullwidthTextValidationError.None; + } + + public static FullwidthTextValidationError ValidateFullwidthName( + string value, int byteLimit = NativeNameByteLimit) + => ValidateFullwidthName(value, NativeEncoding, byteLimit); +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index f7da7be..8c9bdd0 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2,6 +2,7 @@ using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Persistence; +using Age.Engine.Sys4; using System.Text; namespace Age.Engine.Vm; @@ -1237,6 +1238,25 @@ 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 "edit-fullwidth-string-dialog": // 0x144: blocking AGERc command-10 editor + { + string current = ReadStr(a[0]); + string initial = ReadStr(a[1]); + FullwidthTextEditResult result = + _host.EditFullwidthString(new(current, initial)); + if (result.Accepted) WriteStr(a[0], result.Text); + return pc + 1; + } + case "cp932-character-length": // 0x2c6: Japanese-locale _mbstrlen + Write(a[0], Cp932Text.CharacterLength(ReadStr(a[1]), _nativeStringEncoding)); + return pc + 1; + case "cp932-substring": // 0x2c8: multibyte-character interval [start,start+count) + WriteStr(a[0], Cp932Text.Substring( + ReadStr(a[1]), + unchecked((int)Read(a[2])), + unchecked((int)Read(a[3])), + _nativeStringEncoding)); + return pc + 1; case "u00425790": // upstream ABI label case "append-diagnostic-value": // 0x1b2: generic operand text -> EngineCtx accumulator _diagnosticOutput.Append(FormatDiagnosticOperand(a[0])); diff --git a/godot/FullwidthTextEditorDialog.cs b/godot/FullwidthTextEditorDialog.cs new file mode 100644 index 0000000..70b9a0a --- /dev/null +++ b/godot/FullwidthTextEditorDialog.cs @@ -0,0 +1,87 @@ +using System; +using Age.Engine.Sys4; +using Godot; + +/// Godot presentation for AGERc command 10's blocking INPUTNAME editor. +public partial class FullwidthTextEditorDialog : Window +{ + private readonly LineEdit _edit = new() { MaxLength = 255 }; + private readonly Label _error = new() + { + AutowrapMode = TextServer.AutowrapMode.WordSmart, + CustomMinimumSize = new Vector2(0, 28), + }; + private bool _completed; + + public event Action? EditCompleted; + + public FullwidthTextEditorDialog() + { + Title = "文字入力"; + Exclusive = true; + Transient = true; + Unresizable = true; + + var margin = new MarginContainer(); + margin.AddThemeConstantOverride("margin_left", 16); + margin.AddThemeConstantOverride("margin_top", 16); + margin.AddThemeConstantOverride("margin_right", 16); + margin.AddThemeConstantOverride("margin_bottom", 16); + AddChild(margin); + margin.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + + var column = new VBoxContainer(); + margin.AddChild(column); + column.AddChild(new Label { Text = "全角文字で名前を入力してください。" }); + _edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + column.AddChild(_edit); + _error.AddThemeColorOverride("font_color", new Color(1.0f, 0.35f, 0.35f)); + column.AddChild(_error); + + var actions = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.End }; + var cancel = new Button { Text = "キャンセル" }; + var accept = new Button { Text = "決定" }; + actions.AddChild(cancel); + actions.AddChild(accept); + column.AddChild(actions); + + accept.Pressed += TryAccept; + cancel.Pressed += Cancel; + _edit.TextSubmitted += _ => TryAccept(); + CloseRequested += Cancel; + } + + public void Open(string initialText) + { + _completed = false; + _error.Text = ""; + _edit.Text = initialText; + PopupCentered(new Vector2I(430, 160)); + _edit.GrabFocus(); + _edit.SelectAll(); + } + + private void TryAccept() + { + FullwidthTextValidationError error = Cp932Text.ValidateFullwidthName(_edit.Text); + if (error != FullwidthTextValidationError.None) + { + _error.Text = error == FullwidthTextValidationError.TooLong + ? "字数オーバーです" + : "半角文字は使用できません"; + _edit.GrabFocus(); + return; + } + Complete(true); + } + + private void Cancel() => Complete(false); + + private void Complete(bool accepted) + { + if (_completed) return; + _completed = true; + Hide(); + EditCompleted?.Invoke(accepted, _edit.Text); + } +} diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 5f62710..e991f56 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -27,6 +27,9 @@ public sealed class GodotAdvHost : IHost private readonly ScriptPresentationBarrier _presentationBarrier = new(); private readonly AutoResetEvent _presentationRequestConsumed = new(false); private readonly AutoResetEvent _diagnosticMessageCompleted = new(false); + private readonly AutoResetEvent _fullwidthTextEditCompleted = new(false); + private readonly object _fullwidthTextEditLock = new(); + private FullwidthTextEditResult _fullwidthTextEditResult; private readonly bool _synchronizeExplicitPresentation; private long _explicitPresentationRequestGeneration; private long _consumedPresentationRequestGeneration; @@ -132,6 +135,28 @@ public sealed class GodotAdvHost : IHost public void CompleteDiagnosticMessage() => _diagnosticMessageCompleted.Set(); + public FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request) + { + if (_stopping) return new(false, request.CurrentText); + lock (_fullwidthTextEditLock) + _fullwidthTextEditResult = new(false, request.CurrentText); + _timeline?.Event("fullwidth-text-edit", new() + { + ["current"] = request.CurrentText, + ["initial"] = request.InitialText, + }); + _main.CallDeferred("ShowAgeFullwidthTextEditor", request.InitialText); + while (!_stopping && !_fullwidthTextEditCompleted.WaitOne(50)) { } + lock (_fullwidthTextEditLock) return _fullwidthTextEditResult; + } + + public void CompleteFullwidthTextEdit(bool accepted, string text) + { + lock (_fullwidthTextEditLock) + _fullwidthTextEditResult = new(accepted, text); + _fullwidthTextEditCompleted.Set(); + } + private string CurrentScene { get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; } @@ -966,6 +991,7 @@ public sealed class GodotAdvHost : IHost _inputCallbackSignal.Set(); _frameSignal.Set(); _diagnosticMessageCompleted.Set(); + _fullwidthTextEditCompleted.Set(); } public void ResetSceneContext() diff --git a/godot/Main.cs b/godot/Main.cs index c9604ee..84b41f2 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -69,6 +69,7 @@ public partial class Main : Godot.Control private Sys4RegIniStore? _sys4RegIniStore; private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; + private FullwidthTextEditorDialog? _fullwidthTextEditor; private Sys4ScriptProvider? _scripts; private DebugSceneLauncher? _debugSceneLauncher; private IReadOnlyList _debugSceneEntries = System.Array.Empty(); @@ -928,6 +929,26 @@ public partial class Main : Godot.Control } } + public void ShowAgeFullwidthTextEditor(string initialText) + { + if (_fullwidthTextEditor != null) + { + GD.PushWarning("[inputname] replaced an already-open full-width text editor"); + _fullwidthTextEditor.QueueFree(); + } + + var editor = new FullwidthTextEditorDialog(); + _fullwidthTextEditor = editor; + editor.EditCompleted += (accepted, text) => + { + if (_fullwidthTextEditor == editor) _fullwidthTextEditor = null; + _host?.CompleteFullwidthTextEdit(accepted, text); + editor.QueueFree(); + }; + AddChild(editor); + editor.Open(initialText); + } + public override void _ExitTree() { bool vmStopped = true; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index ed1866a..e94ef36 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3034,6 +3034,11 @@ This is INPUTNAME's optional native keyboard-entry button, not the surrounding s character palette. The VM can use a synchronous host seam like the existing diagnostic dialog because the Godot host runs script execution on its worker thread while the main thread owns the modal UI. Compatibility requires the native full-width-only and 16-CP932-byte limits before accepting the result. + +Port status (2026-07-29): implemented. The VM issues an explicit accept/cancel request through IHost; +headless hosts preserve the inout value by default. Godot parks the VM worker, opens a main-thread modal +LineEdit, keeps it open on either native validation error, and resumes the worker only after valid accept +or cancel. Accept replaces operand 1 and cancel leaves it untouched; operand 2 is never modified. """ [[opcode.semantics.args]] @@ -6901,7 +6906,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "The real /v2 dispatch slot registers op_0x2c6_cp932_character_length@0x42a6d0. The handler sets LC_ALL to `japanese`, resolves operand 2, calls MSVC _mbstrlen, and writes the result to operand 1. INPUTNAME's sole site at 0x1002 uses this count as the loop bound before slicing each character with opcode 0x2c8." -details = "This is character count rather than .NET UTF-16 length or raw CP932 byte length. The implementation should use VmOptions.NativeStringCodePage and preserve valid CP932 multibyte boundaries." +details = "This is character count rather than .NET UTF-16 length or raw CP932 byte length. Port status (2026-07-29): implemented by encoding the C-string prefix through VmOptions.NativeStringCodePage and counting valid CP932 lead/trail pairs as one character; focused tests cover mixed single-byte and double-byte characters." [[opcode.semantics.args]] i = 1 @@ -6928,7 +6933,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "The real /v2 dispatch slot registers op_0x2c8_cp932_substring@0x42c420. It copies operand 2 into a 256-byte buffer, sets LC_ALL to `japanese`, obtains _mbstrlen, reads start and count, clamps end=start+count to the character length when end is below 1 or beyond that length, and walks bytes with _mbbtype so CP932 lead/trail pairs are copied together. It writes the selected byte interval back through operand 1. INPUTNAME's sole site at 0x1021 loops substring(name,index,1) into its eight local character cells." -details = "The release call uses nonnegative in-range indices and count 1. A compatible general implementation should reproduce native end clamping (`end = length` when start+count < 1 or > length) and select the half-open character interval [start,end)." +details = "The release call uses nonnegative in-range indices and count 1. Port status (2026-07-29): implemented over encoded byte spans, preserving every valid lead/trail pair and reproducing native end clamping (`end = length` when start+count < 1 or > length) before selecting the half-open character interval [start,end)." [[opcode.semantics.args]] i = 1