Implement INPUTNAME string opcodes
This commit is contained in:
@@ -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
|
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.
|
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,
|
`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;
|
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.
|
durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source.
|
||||||
|
|||||||
@@ -423,14 +423,14 @@ This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all
|
|||||||
- **grounding:** source=investigation, confidence=high
|
- **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.
|
- **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)
|
### 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.
|
- **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
|
- **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.
|
- **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
|
## 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.
|
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.
|
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)
|
### 0x19a `get-message-skip` (u00414E50, argc 1)
|
||||||
- **summary:** (out) - return the current all-message skip state set by op 0x88.
|
- **summary:** (out) - return the current all-message skip state set by op 0x88.
|
||||||
|
|||||||
@@ -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,
|
**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.
|
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
|
## Later Phase B breadth
|
||||||
|
|
||||||
**INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static
|
**INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static
|
||||||
|
|||||||
139
engine/Age.Engine.Tests/InputNameStringOpcodeTests.cs
Normal file
139
engine/Age.Engine.Tests/InputNameStringOpcodeTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ internal class RecordingHost : IHost
|
|||||||
public readonly List<string> Warnings = new();
|
public readonly List<string> Warnings = new();
|
||||||
public readonly List<DiagnosticMessage> Diagnostics = new();
|
public readonly List<DiagnosticMessage> Diagnostics = new();
|
||||||
public System.Action<DiagnosticMessage>? OnDiagnosticMessage;
|
public System.Action<DiagnosticMessage>? OnDiagnosticMessage;
|
||||||
|
public readonly List<FullwidthTextEditRequest> FullwidthTextEdits = new();
|
||||||
|
public System.Func<FullwidthTextEditRequest, FullwidthTextEditResult>? OnFullwidthTextEdit;
|
||||||
public readonly List<long> CursorResources = new();
|
public readonly List<long> CursorResources = new();
|
||||||
public readonly List<bool> AdvPagePresentationSuspended = new();
|
public readonly List<bool> AdvPagePresentationSuspended = new();
|
||||||
public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new();
|
public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new();
|
||||||
@@ -76,6 +78,11 @@ internal class RecordingHost : IHost
|
|||||||
Diagnostics.Add(message);
|
Diagnostics.Add(message);
|
||||||
OnDiagnosticMessage?.Invoke(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(int offset, string text) => Lines.Add((offset, text));
|
||||||
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ public readonly record struct SurfaceRectCopy(
|
|||||||
/// <summary>A synchronous AGE-owned diagnostic prompt after native body/context formatting.</summary>
|
/// <summary>A synchronous AGE-owned diagnostic prompt after native body/context formatting.</summary>
|
||||||
public readonly record struct DiagnosticMessage(string Caption, string Text);
|
public readonly record struct DiagnosticMessage(string Caption, string Text);
|
||||||
|
|
||||||
|
/// <summary>AGERc command 10's synchronous full-width text edit request and result.</summary>
|
||||||
|
public readonly record struct FullwidthTextEditRequest(string CurrentText, string InitialText);
|
||||||
|
public readonly record struct FullwidthTextEditResult(bool Accepted, string Text);
|
||||||
|
|
||||||
public enum SurfaceBlackFadeDirection
|
public enum SurfaceBlackFadeDirection
|
||||||
{
|
{
|
||||||
FromBlack,
|
FromBlack,
|
||||||
@@ -44,6 +48,9 @@ public interface IHost
|
|||||||
/// <summary>Present a modal diagnostic and return only after the user dismisses it.</summary>
|
/// <summary>Present a modal diagnostic and return only after the user dismisses it.</summary>
|
||||||
void ShowDiagnosticMessage(DiagnosticMessage message)
|
void ShowDiagnosticMessage(DiagnosticMessage message)
|
||||||
=> System.Console.Error.WriteLine($"{message.Caption}: {message.Text}");
|
=> System.Console.Error.WriteLine($"{message.Caption}: {message.Text}");
|
||||||
|
/// <summary>Present AGERc's modal full-width editor. Cancel preserves CurrentText.</summary>
|
||||||
|
FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request)
|
||||||
|
=> new(false, request.CurrentText);
|
||||||
// Script context is retained for diagnostics/page location; resource operands are universal packed ids.
|
// Script context is retained for diagnostics/page location; resource operands are universal packed ids.
|
||||||
void EnterScriptContext(string scriptName) { }
|
void EnterScriptContext(string scriptName) { }
|
||||||
void ExitScriptContext() { }
|
void ExitScriptContext() { }
|
||||||
|
|||||||
98
engine/Age.Engine/Sys4/Cp932Text.cs
Normal file
98
engine/Age.Engine/Sys4/Cp932Text.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Age.Engine.Sys4;
|
||||||
|
|
||||||
|
public enum FullwidthTextValidationError
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
TooLong,
|
||||||
|
NonDoubleByteCharacter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AGE's Japanese-locale byte-string operations used by INPUTNAME.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Age.Engine.Diagnostics;
|
|||||||
using Age.Engine.Hosting;
|
using Age.Engine.Hosting;
|
||||||
using Age.Engine.Model;
|
using Age.Engine.Model;
|
||||||
using Age.Engine.Persistence;
|
using Age.Engine.Persistence;
|
||||||
|
using Age.Engine.Sys4;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
namespace Age.Engine.Vm;
|
namespace Age.Engine.Vm;
|
||||||
|
|
||||||
@@ -1237,6 +1238,25 @@ public sealed class VirtualMachine
|
|||||||
case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1
|
case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1
|
||||||
Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1);
|
Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1);
|
||||||
return pc + 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 "u00425790": // upstream ABI label
|
||||||
case "append-diagnostic-value": // 0x1b2: generic operand text -> EngineCtx accumulator
|
case "append-diagnostic-value": // 0x1b2: generic operand text -> EngineCtx accumulator
|
||||||
_diagnosticOutput.Append(FormatDiagnosticOperand(a[0]));
|
_diagnosticOutput.Append(FormatDiagnosticOperand(a[0]));
|
||||||
|
|||||||
87
godot/FullwidthTextEditorDialog.cs
Normal file
87
godot/FullwidthTextEditorDialog.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using Age.Engine.Sys4;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>Godot presentation for AGERc command 10's blocking INPUTNAME editor.</summary>
|
||||||
|
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<bool, string>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private readonly ScriptPresentationBarrier _presentationBarrier = new();
|
private readonly ScriptPresentationBarrier _presentationBarrier = new();
|
||||||
private readonly AutoResetEvent _presentationRequestConsumed = new(false);
|
private readonly AutoResetEvent _presentationRequestConsumed = new(false);
|
||||||
private readonly AutoResetEvent _diagnosticMessageCompleted = 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 readonly bool _synchronizeExplicitPresentation;
|
||||||
private long _explicitPresentationRequestGeneration;
|
private long _explicitPresentationRequestGeneration;
|
||||||
private long _consumedPresentationRequestGeneration;
|
private long _consumedPresentationRequestGeneration;
|
||||||
@@ -132,6 +135,28 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void CompleteDiagnosticMessage() => _diagnosticMessageCompleted.Set();
|
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
|
private string CurrentScene
|
||||||
{
|
{
|
||||||
get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; }
|
get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; }
|
||||||
@@ -966,6 +991,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_inputCallbackSignal.Set();
|
_inputCallbackSignal.Set();
|
||||||
_frameSignal.Set();
|
_frameSignal.Set();
|
||||||
_diagnosticMessageCompleted.Set();
|
_diagnosticMessageCompleted.Set();
|
||||||
|
_fullwidthTextEditCompleted.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetSceneContext()
|
public void ResetSceneContext()
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public partial class Main : Godot.Control
|
|||||||
private Sys4RegIniStore? _sys4RegIniStore;
|
private Sys4RegIniStore? _sys4RegIniStore;
|
||||||
private VirtualMachine _vm = null!;
|
private VirtualMachine _vm = null!;
|
||||||
private GodotAdvHost _host = null!;
|
private GodotAdvHost _host = null!;
|
||||||
|
private FullwidthTextEditorDialog? _fullwidthTextEditor;
|
||||||
private Sys4ScriptProvider? _scripts;
|
private Sys4ScriptProvider? _scripts;
|
||||||
private DebugSceneLauncher? _debugSceneLauncher;
|
private DebugSceneLauncher? _debugSceneLauncher;
|
||||||
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
|
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
|
||||||
@@ -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()
|
public override void _ExitTree()
|
||||||
{
|
{
|
||||||
bool vmStopped = true;
|
bool vmStopped = true;
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
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]]
|
[[opcode.semantics.args]]
|
||||||
@@ -6901,7 +6906,7 @@ source = "investigation"
|
|||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = []
|
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."
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
@@ -6928,7 +6933,7 @@ source = "investigation"
|
|||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = []
|
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."
|
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]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user