From 3785706426ddf2a836ee8ffeecd42f4dd72fcd65 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 16:26:39 -0400 Subject: [PATCH] Document BUNKI menu opcode contracts --- docs/engine-re.md | 28 ++++++++++++++++++++++++++ docs/opcode-reference.md | 22 ++++++++++++-------- docs/phase-b-framework.md | 12 +++++++++++ tools/age_opcodes_himegari.py | 1 + vm-map/opcodes.toml | 38 ++++++++++++++++++----------------- 5 files changed, 75 insertions(+), 26 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index c5c3eb2..5ea0878 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2503,6 +2503,34 @@ native-string code page is configurable for other container frontends and defaul calculation also stops at an embedded NUL before shifting. Focused tests cover CP932 mixed-width strings, all three numeric layout flags, zero padding, retained-object replacement, and invalid/unregistered styles. +### BUNKI popup sizing and placement — opcodes `0x2c5` and `0x195` (2026-07-21) + +Field confirmation popups and TITLE's shipped developer menu share `BUNKI.BIN`; neither uses the ordinary +VN text-layout overlay. BUNKI creates surface 200, draws its strings into that surface through opcode +`0x204`, decorates it with the common menu frame, and binds the completed surface as a retained object. + +`op_0x2c5_byte_strlen@0x42a690` takes `(destination, string)`, resolves operand 2 as an engine byte string, +scans to its NUL terminator, and writes the raw byte count. This differs from a Unicode character count but +matches BUNKI's later conversion: it finds the longest choice/title byte length, adds four bytes of padding, +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 +`待機`/`帰還`/`キャンセル` 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 +instead keeps the minimum panel and makes the overflow much larger. + +The corrected native/port comparison also exposes an independent 30-pixel vertical error. Dispatch slot +`ctx[0x26c93+0x195]` resolves to `op_0x195_string_not_equals@0x426f20`, the inverse of sibling opcode +`0x194`: it compares two complete engine strings and writes one when their byte ranges differ. BUNKI invokes +it three times to test whether optional title global string `0x7da` is nonempty. The skipped opcode does not +write zero for an empty title. At the final test (`BUNKI@0x905`), destination local `0x99` still contains a +nonzero graphics handle from frame construction, so the following branch falsely draws/reserves the title +row and advances the choice y cursor by `0x1e` even though the empty title has no visible glyphs. Implementing +`0x195` therefore removes the exact one-row downward shift; this is not a Godot font-baseline discrepancy or +an inherited VN cursor indent. + --- ## Native walls backlog (targets for this loop) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 2b16365..57896f4 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -237,6 +237,13 @@ Implemented with domain-preserving addressed-array access, native signed 32-bit - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 op_0x194_string_equals@0x426e20 fetches operands 2 and 3 through the string resolver, compares their byte ranges through FUN_004017a0, and writes compare_result==0 to integer operand 1. INIT2 and GAMESTART use it as a branch predicate for INPUTNAME/default-name handling; the natural Game Start diagnostic reached one GAMESTART call at 0x134c. The C# VM implements ordinal equality through the shared string resolver, covering literal, global, local, global-string-pointer, and local-string-pointer operands; the traced natural-boot regression proves the reached GAMESTART call no longer falls back. +### 0x195 `string-not-equals` (string-not-equals, argc 3) +- **summary:** (out)(left)(right) - compare two complete SYS4 strings and write 1 when different, otherwise 0. +- **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. + ### 0x1a6 `half-byte-string-length` (halve-strlen, argc 2) - **summary:** Write half the resolved string's byte length, using integer truncation. - **grounding:** source=investigation, confidence=high @@ -249,6 +256,13 @@ Native applies strlen to the NUL-terminated engine byte string and shifts the by - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 op_0x1b0_copy_dwords@0x427060 fetches operand 3, resolves addressable operands 1 and 2 through vm_operand_resolve_address@0x425a50, and calls memcpy(destination, source, count*4). Corpus: 65 calls across direct global/local spans and local pointers; 43 are immediately preceded by take-address 0x63. The C# VM copies resolved integer-cell spans while retaining local/global address domains; focused tests cover direct spans and aliased pointers, and the traced natural boot reaches the UNITECH/CALCCC pair without fallback. +### 0x2c5 `byte-string-length` (strlen, argc 2) +- **summary:** Write the resolved NUL-terminated engine string's raw byte length. +- **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. + ## control ### 0x1 `throw-exit-request` (throw-exit-request, argc 0) @@ -1134,10 +1148,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x195 `u00425580` (u00425580, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x196 `display-furigana` (display-furigana, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=med @@ -1258,10 +1268,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x2c5 `strlen` (strlen, argc 2) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x2c6 `u0042B5E0` (u0042B5E0, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index dbe489c..522d600 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -453,6 +453,18 @@ measures the configured native encoding (CP932 for SYS4). Focused coverage plus suite, zero-warning Godot build, and threaded frontend selftest pass. The next field action is the manual visual recheck of the same `DEBUGMAP` HUD before pursuing any state seeding. +That recheck confirms the numeric HUD and centered unit/weapon strings are restored. The next visible +discrepancy is shared menu text placed too far right, reproduced by both FIELD's three-choice wait/retreat +popup and TITLE's shipped developer menu. Both routes use `BUNKI.BIN`'s temporary-surface renderer rather +than the ordinary VN overlay. Native RE identifies the missing input as opcode `0x2c5`, raw byte-string +length: BUNKI uses it to size the panel and compute a common primary-label x origin. With the opcode skipped, +the destination stays zero; the FIELD popup shifts 53 pixels right and the longer developer menu incorrectly +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. + ## Later Phase B breadth Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 7041296..55637dc 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -77,4 +77,5 @@ INFERRED: dict[int, dict] = { 0x23f: dict(name='query-surface-stop-time-ms', category='draw', noop=False, confidence='high', source='investigation', summary='(out_stop_time_ms)(surface_slot) — query the DirectShow stop position retained by a loaded movie surface, convert seconds to integer milliseconds by truncating toward zero, and write -1 when the movie slot is empty. The port retains this metadata during 0x236 graph initialization; unavailable metadata emits a warning and also returns -1.'), 0x258: dict(name='decl?', category='marker', noop=True, confidence='low', source='harness', summary='2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?'), 0x259: dict(name='script-entry', category='marker', noop=True, confidence='low', source='harness', summary='zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural'), + 0x2c5: dict(name='byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write the resolved NUL-terminated engine string's raw byte length."), } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index d282383..6f06451 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3142,33 +3142,34 @@ observed_types = ["string", "l-str", "l-str-ptr"] [[opcode]] op = 0x195 -label = "u00425580" +label = "string-not-equals" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00425580" -category = "unknown" -summary = "" +name = "string-not-equals" +category = "compute" +summary = "(out)(left)(right) - compare two complete SYS4 strings and write 1 when different, otherwise 0." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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." [[opcode.semantics.args]] i = 1 -role = "" +role = "out" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "left" observed_types = ["g-str", "l-str", "l-str-ptr"] [[opcode.semantics.args]] i = 3 -role = "" +role = "right" observed_types = ["string", "g-str"] [[opcode]] @@ -6771,23 +6772,24 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "strlen" -category = "unknown" -summary = "" +name = "byte-string-length" +category = "compute" +summary = "Write the resolved NUL-terminated engine string's raw byte length." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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." [[opcode.semantics.args]] i = 1 -role = "" +role = "destination" observed_types = ["l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "string" observed_types = ["g-str", "l-str", "l-str-ptr"] [[opcode]]