Implement BUNKI string layout opcodes

This commit is contained in:
gamer147
2026-07-21 16:32:39 -04:00
parent 8aa58c4c3d
commit a02ea7e0a0
7 changed files with 127 additions and 7 deletions

View File

@@ -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 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 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 `待機`/`帰還`/`キャンセル` 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 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 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 `0x195` therefore removes the exact one-row downward shift; this is not a Godot font-baseline discrepancy or
an inherited VN cursor indent. 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) ## Native walls backlog (targets for this loop)

View File

@@ -242,7 +242,7 @@ Implemented with domain-preserving addressed-array access, native signed 32-bit
- **grounding:** source=investigation, confidence=high - **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. - **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) ### 0x1a6 `half-byte-string-length` (halve-strlen, argc 2)
- **summary:** Write half the resolved string's byte length, using integer truncation. - **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 - **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. - **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 ## control

View File

@@ -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 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 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 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 handle and falsely advances the choice cursor by 30 pixels. Both shared operations are now implemented with
shared CP932 byte-length opcode `0x2c5` plus inverse string comparison `0x195`, followed by a visual recheck. 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 ## Later Phase B breadth

View File

@@ -129,4 +129,28 @@ public class NumericGlyphOpsTests
Assert.Equal(2, vm.Globals[100]); // four CP932 bytes, not three .NET chars Assert.Equal(2, vm.Globals[100]); // four CP932 bytes, not three .NET chars
Assert.Equal(3, vm.Globals[101]); // six CP932 bytes 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
}
} }

View File

@@ -94,4 +94,84 @@ public class StringComparisonOpsTests
Assert.Equal(1, vm.Globals[0x600]); 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<Operand>()),
}, 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<Operand>()),
}, new[] { "" });
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.Run();
Assert.Equal(0, vm.Globals[0x600]);
}
} }

View File

@@ -660,6 +660,9 @@ public sealed class VirtualMachine
case "string-equals": case "string-equals":
Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 1 : 0); Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 1 : 0);
return pc + 1; 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 "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 "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; 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 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 "strlen": // 0x2c5: raw strlen(native encoded bytes)
Write(a[0], NativeStringByteLength(ReadStr(a[1])));
return pc + 1;
case "lookup-array": case "lookup-array":
LookupStore(a[0], BaseAddr(a[1]).Offset(Read(a[2]))); return pc + 1; LookupStore(a[0], BaseAddr(a[1]).Offset(Read(a[2]))); return pc + 1;
case "lookup-array-2d": case "lookup-array-2d":

View File

@@ -3155,7 +3155,7 @@ source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] 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." 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]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -6780,7 +6780,7 @@ source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] 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." 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]] [[opcode.semantics.args]]
i = 1 i = 1