diff --git a/docs/engine-re.md b/docs/engine-re.md index 5ea0878..2c6c5e3 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2515,7 +2515,7 @@ matches BUNKI's later conversion: it finds the longest choice/title byte length, multiplies by 21, and divides by two to obtain the full-width glyph-space estimate used for panel width and the shared left edge of the primary labels. -The port currently skips `0x2c5`, leaving BUNKI's maximum-length local at zero. For FIELD's +Before implementation, the port skipped `0x2c5`, leaving BUNKI's maximum-length local at zero. For FIELD's `待機`/`帰還`/`キャンセル` popup, the panel still hits the same 240-pixel minimum but the computed label origin moves from surface x=67 to x=120, a 53-pixel right shift which makes `キャンセル` touch/spill beyond the frame. TITLE's longer developer choices should expand the surface beyond 240 pixels; the skipped result @@ -2531,6 +2531,14 @@ row and advances the choice y cursor by `0x1e` even though the empty title has n `0x195` therefore removes the exact one-row downward shift; this is not a Godot font-baseline discrepancy or an inherited VN cursor indent. +Both operations are now implemented as shared VM semantics. `0x2c5` uses the same configurable native-string +encoding helper as `0x1a6` (CP932 for SYS4), stops at an embedded NUL, and writes the unshifted byte count. +`0x195` is ordinal inequality through the existing literal/global/local/string-pointer resolver and always +writes zero or one, so stale destinations cannot leak into the branch. Focused tests cover mixed-width CP932, +embedded-NUL termination, all observed comparison operand classes, and the exact BUNKI empty-title stale-handle +case. The full 281-test engine suite, zero-warning Godot build, and threaded frontend selftest pass; manual +DEBUGMAP and developer-menu visual rechecks remain. + --- ## Native walls backlog (targets for this loop) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 57896f4..3b673c3 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -242,7 +242,7 @@ Implemented with domain-preserving addressed-array access, native signed 32-bit - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: dispatch slot ctx[0x26c93+0x195] is op_0x195_string_not_equals@0x426f20. It resolves operands 2 and 3 as engine strings, compares their complete byte ranges through the same worker as sibling op 0x194, and writes compare_result!=0 to operand 1. Corpus: 17 sites; 15 immediately branch on the result. BUNKI uses three comparisons against the empty string for its optional title row. -This is the logical inverse of op 0x194 string-equals. Skipping it is stateful: the destination is not cleared. At BUNKI@0x905, local 0x99 still contains a nonzero graphics handle, so the missing write falsely reserves a 30-pixel title row and shifts every choice down. +This is the logical inverse of op 0x194 string-equals. Skipping it is stateful: the destination is not cleared. At BUNKI@0x905, local 0x99 still contains a nonzero graphics handle, so the missing write falsely reserves a 30-pixel title row and shifts every choice down. The C# VM implements ordinal inequality through the shared string resolver; focused tests cover literal/global/local/pointer operands and the exact empty-title stale-handle overwrite. ### 0x1a6 `half-byte-string-length` (halve-strlen, argc 2) - **summary:** Write half the resolved string's byte length, using integer truncation. @@ -261,7 +261,7 @@ Native applies strlen to the NUL-terminated engine byte string and shifts the by - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x2c5_byte_strlen@0x42a690 resolves operand 2, scans byte-by-byte through the terminating NUL, and writes the byte count to operand 1. Corpus: 23 sites in 10 scripts. BUNKI uses two sites to size its temporary menu surface and horizontally place all primary option strings. -This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all option/title byte lengths, adds four bytes of padding, and converts the result to pixels; skipping the opcode leaves its local maximum at zero, forcing the minimum-width menu and shifting every primary label right. +This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all option/title byte lengths, adds four bytes of padding, and converts the result to pixels; skipping the opcode leaves its local maximum at zero, forcing the minimum-width menu and shifting every primary label right. The C# VM shares the configurable native-string byte counter used by op 0x1a6 (CP932 by default), including embedded-NUL termination; focused tests cover literals and local-string pointers. ## control diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 522d600..345af81 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -462,8 +462,10 @@ the destination stays zero; the FIELD popup shifts 53 pixels right and the longe remains at its 240-pixel minimum. Corrected native/port screenshots reveal a separate exact one-row vertical shift: BUNKI uses missing opcode `0x195` (`string-not-equals`) to test its optional title against the empty string. Because a skipped opcode leaves its destination untouched, the final test reuses a nonzero graphics -handle and falsely advances the choice cursor by 30 pixels. The next bounded implementation is therefore the -shared CP932 byte-length opcode `0x2c5` plus inverse string comparison `0x195`, followed by a visual recheck. +handle and falsely advances the choice cursor by 30 pixels. Both shared operations are now implemented with +focused CP932/NUL, operand-resolution, and stale-destination regressions. All 281 engine tests, the zero-warning +Godot build, and threaded selftest pass. The next bounded action is a visual recheck of the DEBUGMAP popup and +TITLE developer menu. ## Later Phase B breadth diff --git a/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs b/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs index efe3556..f82ed74 100644 --- a/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs +++ b/engine/Age.Engine.Tests/NumericGlyphOpsTests.cs @@ -129,4 +129,28 @@ public class NumericGlyphOpsTests Assert.Equal(2, vm.Globals[100]); // four CP932 bytes, not three .NET chars Assert.Equal(3, vm.Globals[101]); // six CP932 bytes } + + [Fact] + public void ByteStringLengthUsesNativeCp932BytesAndStopsAtNul() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "BYTE-LENGTH", new List<(int, Operand[])> + { + (0x2c5, new[] { L(0), S(0) }), + (0x55, new[] { G(100), L(0) }), + (0x55, new[] { LS(0), S(1) }), + (0x2c5, new[] { L(1), LS(0) }), + (0x55, new[] { G(101), L(1) }), + (0x2c5, new[] { L(2), S(2) }), + (0x55, new[] { G(102), L(2) }), + Exit(), + }, new[] { "AB姫", "リリィ", "AB\0姫" }); + var vm = new VirtualMachine(scene, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(4, vm.Globals[100]); // two ASCII + one double-byte CP932 glyph + Assert.Equal(6, vm.Globals[101]); // three double-byte CP932 glyphs through a string pointer + Assert.Equal(2, vm.Globals[102]); // native strlen stops before the embedded NUL + } } diff --git a/engine/Age.Engine.Tests/StringComparisonOpsTests.cs b/engine/Age.Engine.Tests/StringComparisonOpsTests.cs index e6473d4..813839c 100644 --- a/engine/Age.Engine.Tests/StringComparisonOpsTests.cs +++ b/engine/Age.Engine.Tests/StringComparisonOpsTests.cs @@ -94,4 +94,84 @@ public class StringComparisonOpsTests Assert.Equal(1, vm.Globals[0x600]); } + + [Fact] + public void StringNotEquals_HandlesEqualDifferentAndPointerOperands() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + int lookup = table.ByLabel("lookup-array")!.Value; + int move = table.ByLabel("mov")!.Value; + var script = ScriptAssembler.Assemble(table, "STRING_NOT_EQUALS", new List<(int, Operand[])> + { + (move, new[] { new Operand(LocalString, 0), new Operand(InlineString, 0) }), + (lookup, new[] + { + new Operand(LocalStringPointer, 0), new Operand(GlobalString, 0x300), + new Operand(Immediate, 2), + }), + (0x195, new[] + { + new Operand(LocalInt, 0), new Operand(InlineString, 0), new Operand(InlineString, 1), + }), + (0x195, new[] + { + new Operand(LocalInt, 1), new Operand(GlobalString, 0x301), new Operand(InlineString, 0), + }), + (0x195, new[] + { + new Operand(LocalInt, 2), new Operand(LocalString, 0), new Operand(InlineString, 0), + }), + (0x195, new[] + { + new Operand(LocalInt, 3), new Operand(LocalStringPointer, 0), new Operand(InlineString, 0), + }), + (0x195, new[] + { + new Operand(LocalInt, 4), new Operand(GlobalStringPointer, 0x400), + new Operand(InlineString, 0), + }), + (move, new[] { new Operand(GlobalInt, 0x500), new Operand(LocalInt, 0) }), + (move, new[] { new Operand(GlobalInt, 0x501), new Operand(LocalInt, 1) }), + (move, new[] { new Operand(GlobalInt, 0x502), new Operand(LocalInt, 2) }), + (move, new[] { new Operand(GlobalInt, 0x503), new Operand(LocalInt, 3) }), + (move, new[] { new Operand(GlobalInt, 0x504), new Operand(LocalInt, 4) }), + (0x2, Array.Empty()), + }, new[] { "姫狩り", "姫狩り", "姫狩り違い" }); + var vm = new VirtualMachine(script, table, new RecordingHost()); + vm.GlobalStrings[0x301] = "姫狩り違い"; + vm.GlobalStrings[0x302] = "姫狩り"; + vm.Globals[0x400] = 0x302; + + vm.Run(); + + Assert.Equal(new long[] { 0, 1, 0, 0, 0 }, new[] + { + vm.Globals[0x500], vm.Globals[0x501], vm.Globals[0x502], + vm.Globals[0x503], vm.Globals[0x504], + }); + } + + [Fact] + public void StringNotEquals_OverwritesBunkiStaleHandleWhenTitleIsEmpty() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + int move = table.ByLabel("mov")!.Value; + var script = ScriptAssembler.Assemble(table, "BUNKI_EMPTY_TITLE", new List<(int, Operand[])> + { + // BUNKI reuses local 0x99 for frame handles before testing its optional title. + (move, new[] { new Operand(LocalInt, 0x99), new Operand(Immediate, 0xea71) }), + (0x195, new[] + { + new Operand(LocalInt, 0x99), new Operand(GlobalString, 0x7da), + new Operand(InlineString, 0), + }), + (move, new[] { new Operand(GlobalInt, 0x600), new Operand(LocalInt, 0x99) }), + (0x2, Array.Empty()), + }, new[] { "" }); + var vm = new VirtualMachine(script, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(0, vm.Globals[0x600]); + } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 483820c..7312794 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -660,6 +660,9 @@ public sealed class VirtualMachine case "string-equals": Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 1 : 0); return pc + 1; + case "string-not-equals": + Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 0 : 1); + return pc + 1; case "lt": Write(a[0], Read(a[1]) < Read(a[2]) ? 1 : 0); return pc + 1; case "lte": Write(a[0], Read(a[1]) <= Read(a[2]) ? 1 : 0); return pc + 1; case "gr": Write(a[0], Read(a[1]) > Read(a[2]) ? 1 : 0); return pc + 1; @@ -672,6 +675,9 @@ 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 "strlen": // 0x2c5: raw strlen(native encoded bytes) + Write(a[0], NativeStringByteLength(ReadStr(a[1]))); + return pc + 1; case "lookup-array": LookupStore(a[0], BaseAddr(a[1]).Offset(Read(a[2]))); return pc + 1; case "lookup-array-2d": diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 6f06451..4c30069 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3155,7 +3155,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: dispatch slot ctx[0x26c93+0x195] is op_0x195_string_not_equals@0x426f20. It resolves operands 2 and 3 as engine strings, compares their complete byte ranges through the same worker as sibling op 0x194, and writes compare_result!=0 to operand 1. Corpus: 17 sites; 15 immediately branch on the result. BUNKI uses three comparisons against the empty string for its optional title row." -details = "This is the logical inverse of op 0x194 string-equals. Skipping it is stateful: the destination is not cleared. At BUNKI@0x905, local 0x99 still contains a nonzero graphics handle, so the missing write falsely reserves a 30-pixel title row and shifts every choice down." +details = "This is the logical inverse of op 0x194 string-equals. Skipping it is stateful: the destination is not cleared. At BUNKI@0x905, local 0x99 still contains a nonzero graphics handle, so the missing write falsely reserves a 30-pixel title row and shifts every choice down. The C# VM implements ordinal inequality through the shared string resolver; focused tests cover literal/global/local/pointer operands and the exact empty-title stale-handle overwrite." [[opcode.semantics.args]] i = 1 @@ -6780,7 +6780,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0x2c5_byte_strlen@0x42a690 resolves operand 2, scans byte-by-byte through the terminating NUL, and writes the byte count to operand 1. Corpus: 23 sites in 10 scripts. BUNKI uses two sites to size its temporary menu surface and horizontally place all primary option strings." -details = "This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all option/title byte lengths, adds four bytes of padding, and converts the result to pixels; skipping the opcode leaves its local maximum at zero, forcing the minimum-width menu and shifting every primary label right." +details = "This is raw strlen(bytes), not a .NET UTF-16 character count. BUNKI compares all option/title byte lengths, adds four bytes of padding, and converts the result to pixels; skipping the opcode leaves its local maximum at zero, forcing the minimum-width menu and shifting every primary label right. The C# VM shares the configurable native-string byte counter used by op 0x1a6 (CP932 by default), including embedded-NUL termination; focused tests cover literals and local-string pointers." [[opcode.semantics.args]] i = 1