Implement INPUTNAME string opcodes
This commit is contained in:
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<DiagnosticMessage> Diagnostics = new();
|
||||
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<bool> 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)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,10 @@ public readonly record struct SurfaceRectCopy(
|
||||
/// <summary>A synchronous AGE-owned diagnostic prompt after native body/context formatting.</summary>
|
||||
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
|
||||
{
|
||||
FromBlack,
|
||||
@@ -44,6 +48,9 @@ public interface IHost
|
||||
/// <summary>Present a modal diagnostic and return only after the user dismisses it.</summary>
|
||||
void ShowDiagnosticMessage(DiagnosticMessage message)
|
||||
=> 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.
|
||||
void EnterScriptContext(string scriptName) { }
|
||||
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.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]));
|
||||
|
||||
Reference in New Issue
Block a user