Document BUNKI menu opcode contracts

This commit is contained in:
gamer147
2026-07-21 16:26:39 -04:00
parent a93d3b64bf
commit 8aa58c4c3d
5 changed files with 75 additions and 26 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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."),
}

View File

@@ -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]]