Implement native text face selection

This commit is contained in:
gamer147
2026-07-28 14:16:24 -04:00
parent a991d5699c
commit f885ac4189
12 changed files with 270 additions and 33 deletions

View File

@@ -19,7 +19,17 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch-
| `0x144e0` | `sfx_channel_resource_ids` | `int` | base of ten packed resource ids retained by the sound-effect facade; op 0xb4 loads a channel, op 0xb6 clears it, and numbered-save layouts restore then reopen every positive id | | `0x144e0` | `sfx_channel_resource_ids` | `int` | base of ten packed resource ids retained by the sound-effect facade; op 0xb4 loads a channel, op 0xb6 clears it, and numbered-save layouts restore then reopen every positive id |
| `0x14d54` | `adv_text_layout_ptr_table` | `void*` | text_manager at ctx+0x14940 plus layout pointer table +0x414; ops 0x212/0x213 configure each layout's wait-indicator handle and retained glyph-object range at layout+0x64/+0x68/+0x6c | | `0x14d54` | `adv_text_layout_ptr_table` | `void*` | text_manager at ctx+0x14940 plus layout pointer table +0x414; ops 0x212/0x213 configure each layout's wait-indicator handle and retained glyph-object range at layout+0x64/+0x68/+0x6c |
| `0x14e08` | `gfx_default_object_slot` | `int` | op 0x80 selected slot; op 0x1d9 substitutes it when its explicit object-slot operand is zero | | `0x14e08` | `gfx_default_object_slot` | `int` | op 0x80 selected slot; op 0x1d9 substitutes it when its explicit object-slot operand is zero |
| `0x14e0c` | `text_primary_font_height` | `int` | primary LOGFONTA.lfHeight at text-manager+0x4cc; stored as a negative pixel height and updated by op 0x75 |
| `0x14e10` | `text_primary_font_width` | `int` | primary LOGFONTA.lfWidth at text-manager+0x4d0; op 0x75 stores negative half the requested font size |
| `0x14e1c` | `text_primary_font_weight` | `int` | primary LOGFONTA.lfWeight at text-manager+0x4dc; op 0x2bd writes 700 for enabled or zero for disabled |
| `0x14e84` | `text_antialias_enabled` | `int` | text-manager+0x544; message:UseAntiFont selects GGO_GRAY4_BITMAP when nonzero and GGO_BITMAP when zero |
| `0x14e88` | `text_antialias_version` | `int` | text-manager+0x548 loaded from set:AntiFontVersion; Himegari SYS4INI supplies version 3 |
| `0x14e8c` | `text_primary_color_bgr` | `uint` | text-manager+0x54c internal GDI BGR/COLORREF primary glyph color; op 0x76 converts script RGB into it |
| `0x14e90` | `text_effect_color_bgr` | `uint` | text-manager+0x550 internal GDI BGR/COLORREF effect glyph color; op 0x77 converts script RGB into it |
| `0x14e98` | `text_render_mode` | `int` | text-manager+0x558; op 0x78 selects primary-only, displaced shadow, reduced-coverage repeat, or sampled ellipse outline |
| `0x14ea0` | `text_line_spacing` | `int` | extra pixel leading between text lines; defaults to 6, op 0x8b writes it | | `0x14ea0` | `text_line_spacing` | `int` | extra pixel leading between text lines; defaults to 6, op 0x8b writes it |
| `0x14ea4` | `text_effect_offset_x` | `int` | text-manager+0x564; op 0x1a4 horizontal displacement for mode 1 or ellipse radius for mode 3 |
| `0x14ea8` | `text_effect_offset_y` | `int` | text-manager+0x568; op 0x1a4 vertical displacement for mode 1 or ellipse radius for mode 3 |
| `0x14f45` | `script_frame_index` | `int` | call-script frame index (0x1e-dword frames) | | `0x14f45` | `script_frame_index` | `int` | call-script frame index (0x1e-dword frames) |
| `0x46188` | `renderer_device_state` | `int` | embedded D3D9 device/render state; op 0x20e passes this base to the target/depth clear worker | | `0x46188` | `renderer_device_state` | `int` | embedded D3D9 device/render state; op 0x20e passes this base to the target/depth clear worker |
| `0x46598` | `d3d9_device` | `void*` | IDirect3DDevice9 pointer at renderer_device_state+0x410; op 0x20e invokes vtable Clear +0xac | | `0x46598` | `d3d9_device` | `void*` | IDirect3DDevice9 pointer at renderer_device_state+0x410; op 0x20e invokes vtable Clear +0xac |
@@ -94,6 +104,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch-
| `0x6f8b4` | `script_launch_queue_high_water` | `int` | highest consumed cursor retained across FIFO compaction | | `0x6f8b4` | `script_launch_queue_high_water` | `int` | highest consumed cursor retained across FIFO compaction |
| `0x6f8b8` | `script_launch_dispatch_active` | `int` | suppresses immediate dispatch while op 0x143 batches mounted append record-zero ids and while a queued script is active | | `0x6f8b8` | `script_launch_dispatch_active` | `int` | suppresses immediate dispatch while op 0x143 batches mounted append record-zero ids and while a queued script is active |
| `0x9928c` | `save_frame_boundary_index` | `int` | highest script-frame index included by numbered-save layouts 2/3; -1 falls back to cur_ctx_index; op 0x1ad marks current frame and op 0x2 clears after unwinding below it | | `0x9928c` | `save_frame_boundary_index` | `int` | highest script-frame index included by numbered-save layouts 2/3; -1 falls back to cur_ctx_index; op 0x1ad marks current frame and op 0x2 clears after unwinding below it |
| `0x99290` | `gfx_draw_mode` | `int` | loaded from set:DrawMode during runtime initialization; Himegari uses mode 1, selecting the cached GetGlyphOutline text path |
| `0x9b24c` | `dispatch_table` | `void*` | opcode->handler table base [0x400]; handler(op) = *(0x9b24c + op*4) | | `0x9b24c` | `dispatch_table` | `void*` | opcode->handler table base [0x400]; handler(op) = *(0x9b24c + op*4) |
| `0x9c658` | `sys4ini_count` | `int` | SYS4INI record count at embedded FileDB+0x40c | | `0x9c658` | `sys4ini_count` | `int` | SYS4INI record count at embedded FileDB+0x40c |
| `0x9c65c` | `archive_name_table` | `void*` | archive-name table base at embedded FileDB+0x410; arc_id*0x100 indexes it | | `0x9c65c` | `archive_name_table` | `void*` | archive-name table base at embedded FileDB+0x410; arc_id*0x100 indexes it |

View File

@@ -2453,6 +2453,67 @@ Matching evidence: `build/native-adv-text-trace.jsonl` and Godot timeline captur
non-overlapping name/dialogue bands at y=447468, 478500, and 507530. A manual run progressed 14 pages: non-overlapping name/dialogue bands at y=447468, 478500, and 507530. A manual run progressed 14 pages:
11 clicks completed active reveals and 14 later clicks released 14 distinct waits through `0xe0c`. 11 clicks completed active reveals and 14 later clicks released 14 distinct waits through `0xe0c`.
#### Native font face, weight, antialiasing, and effects (2026-07-28)
The earlier retained-text work established *where* glyphs appear but deliberately left their final raster
appearance to Godot `Label`. Static reconstruction now identifies the complete native style selected by
Himegari and the specific reasons the port's text looks only approximate.
Himegari's decompressed SYS4INI settings trailer supplies `FONT= 明朝`, `DRAWMODE=1`,
`ENABLEANTIFONT=1`, and `ANTIFONTVERSION=3`. `engine_initialize_subsystems_from_settings@0x415390`
passes the face to `adv_text_manager_initialize@0x456800`, stores draw mode 1 at `ctx+0x99290`, applies
the enabled `message:UseAntiFont` default of 1 through `text_set_antialias_enabled@0x4137e0`, and stores
AntiFont version 3 at text-manager `+0x548`. The ordinary primary `LOGFONTA` begins as:
- `lfHeight=-24`, `lfWidth=-12`, `lfWeight=0`;
- no escapement/orientation, italic, underline, or strikeout;
- `DEFAULT_CHARSET`, default output/clip precision, default quality, and default pitch/family;
- `lfFaceName=" 明朝"`.
Size opcode `0x75` keeps the negative pixel height and a negative half-width in both primary LOGFONT
states. It rounds ordinary odd requested heights down by one and special-cases 32/33 to 31, although every
Himegari call uses an even size from 8 through 24. Bold opcode `0x2bd` sets `lfWeight` to exactly 700, not
an arbitrary stroke expansion. Face opcode `0x1a5` is also effectful:
`op_0x1a5_set_font_face@0x42bbf0` calls `text_set_primary_font_face@0x42b1d0`, which copies the requested
face and constructs an `@%s` vertical-writing companion before
`text_rebuild_primary_font_resources@0x44f7f0` recreates the `CreateFontIndirectA` handles, metrics, and
glyph buffers. The 207 corpus calls use only ` 明朝` (168) and ` ゴシック` (39).
With Himegari's DrawMode 1 and AntiFont enabled, `GetGlyphOutlineA` requests
`GGO_GRAY4_BITMAP` with an identity `MAT2`. `text_raster_string_cached@0x45b600` caches the returned
0..16 coverage bitmap and composites it into the locked 16- or 32-bit destination with ordinary
source-over color/alpha interpolation. Layout measurement separately uses `GetTextExtentPoint32A` against
the selected GDI font, so face, explicit `lfWidth`, and weight affect advances and wrapping as well as
pixels.
The shipped effect profiles use only modes 1 and 3:
- mode 1 draws one effect-color copy at `(x+effect_x,y+effect_y)`, then the primary glyph; all 43
Himegari sites pair it with `(0,0)`;
- mode 3 samples an ellipse around the glyph. `text_compute_outline_ellipse_offset@0x459860` computes
`cos(angle)*effect_x` / `sin(angle)*effect_y`; the caller steps by
`360 / (sqrt(effect_x²+effect_y²)*8)`, blits the effect-colored glyph at every rounded offset, then
draws the primary glyph. All 164 paired sites use `(1,1)`, producing the normal one-pixel outline.
The ordinary ADV preset is therefore 24-pixel ` 明朝`, weight 700, white fill, mode-3 `(1,1)` outline
in `0x606060`, with 8 pixels of line leading. The small menu-description preset is 16-pixel
` ゴシック`, weight 0, white fill, mode 1 with zero displacement, and 9 pixels of leading.
The port now carries opcode `0x1a5` through `AdvTextStyle` into live, retained, History, and surface-text
presentation. `godot/Main.cs` recognizes Himegari's two requested families and loads
`C:/Windows/Fonts/msmincho.ttc` or `msgothic.ttc` when present. Missing or unknown requested faces fall
back to the existing best-effort Japanese font probe, and regular/bold variants are cached per resolved
face so a runtime face switch cannot reuse the preceding family's synthetic-bold font. This restores the
important Mincho/Gothic distinction on a normal Windows installation without treating the proprietary
fonts as game assets.
The remaining raster behavior is still an approximation: weight 700 uses Godot
`VariationEmbolden=1.2`, and every nonzero render mode becomes a symmetric Godot outline. Mode 1 is
therefore modeled as an outline rather than its native displaced-copy operation. Exact GDI-compatible
advances, grayscale coverage, and mode-3 edge pixels require a dedicated glyph backend (or a deliberately
calibrated approximation) behind the existing engine-owned layout/style contract. Portable configurable
face substitutions/defaults are intentionally deferred to the broader runtime configuration design.
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11) #### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN` The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN`

View File

@@ -45,7 +45,7 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que
- **summary:** (pixels) - set the primary text font height and rebuild its native rasterization state. - **summary:** (pixels) - set the primary text font height and rebuild its native rasterization state.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **depended on by:** 0x205 - **depended on by:** 0x205
- **evidence:** Ghidra /v2: op_0x75_handler@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, derives width, and rebuilds font resources. HISTORY.BIN selects 22 pixels while active and restores 24 on exit. - **evidence:** Ghidra /v2: op_0x75_set_font_size@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, sets lfWidth to negative half the requested size, applies AGE's odd/32/33-pixel height adjustment, and rebuilds both GDI font resources plus glyph buffers. All shipped Himegari sizes are even and avoid the adjustment; HISTORY.BIN selects 22 pixels while active and restores 24 on exit.
### 0x76 `set-text-color` (set-text-color, argc 1) ### 0x76 `set-text-color` (set-text-color, argc 1)
- **summary:** (rgb) - set the current primary text color. - **summary:** (rgb) - set the current primary text color.
@@ -60,7 +60,9 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que
### 0x78 `set-text-render-mode` (set-text-render-mode, argc 1) ### 0x78 `set-text-render-mode` (set-text-render-mode, argc 1)
- **summary:** (mode) - select the current text raster/effect mode. - **summary:** (mode) - select the current text raster/effect mode.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x78_handler@0x41eb10 writes operand 1 to text-manager+0x558 and rebuilds font state. Direct draw and text_history_render_records branch on this field (including a distinct mode-3 offset path). HISTORY.BIN selects mode 3 and restores mode 3 with the normal ADV font preset. - **evidence:** Ghidra /v2: op_0x78_set_text_render_mode@0x41eb10 writes operand 1 to text-manager+0x558 and rebuilds font state. Himegari's DrawMode=1 path reaches text_raster_string_cached@0x45b600: mode 1 blits effect color at (+x,+y); mode 2 repeats a quarter-coverage glyph bitmap in primary color; mode 3 samples text_compute_outline_ellipse_offset@0x459860 around 360 degrees before the primary blit. The corpus uses only mode 1 (43 sites) and mode 3 (165 sites).
Mode 0 draws only the primary glyph. Mode 1 draws one effect-colored copy displaced by the configured x/y offset, then the primary glyph. Mode 2 adds a reduced-coverage primary-color bitmap pass after the ordinary glyph and is unused by Himegari scripts. Mode 3 repeatedly draws the effect glyph around an ellipse whose radii are the configured x/y extents, then draws the primary glyph; Himegari's (1,1) profile produces its one-pixel outline.
### 0x79 `set-adv-text-reset-cursor` (set-adv-text-reset-cursor, argc 3) ### 0x79 `set-adv-text-reset-cursor` (set-adv-text-reset-cursor, argc 3)
- **summary:** (layout_slot)(x)(y) - configure the cursor coordinates restored by a later reset of the selected ADV text layout. Slot 0 selects the current layout. This does not move the live cursor immediately; opcode 0x7a does that. - **summary:** (layout_slot)(x)(y) - configure the cursor coordinates restored by a later reset of the selected ADV text layout. Slot 0 selects the current layout. This does not move the live cursor immediately; opcode 0x7a does that.
@@ -101,7 +103,14 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que
### 0x1a4 `set-text-effect-offset` (set-text-effect-offset, argc 2) ### 0x1a4 `set-text-effect-offset` (set-text-effect-offset, argc 2)
- **summary:** (x)(y) - set the horizontal and vertical extent/offset used by the current text effect mode. - **summary:** (x)(y) - set the horizontal and vertical extent/offset used by the current text effect mode.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x1a4_handler@0x41f440 writes operands 1/2 to text-manager+0x564/+0x568. Native raster/bounds paths expand or shift text by these values when an effect mode is active; mode 3 subtracts both. HISTORY.BIN uses (1,1). - **evidence:** Ghidra /v2: op_0x1a4_set_text_effect_offset@0x41f440 writes operands 1/2 to text-manager+0x564/+0x568. Mode 1 uses them as a single displaced effect-color shadow. Mode 3 treats them as ellipse radii and samples x=cos(angle)*offset_x, y=sin(angle)*offset_y around the glyph before the primary draw. The Himegari corpus pairs mode 1 with (0,0) at all 43 sites and mode 3 with (1,1) at all 164 paired sites.
### 0x1a5 `set-font` (set-font, argc 1)
- **summary:** (face) - select the primary GDI font face, derive its vertical-writing companion, and rebuild the native font/glyph state.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x1a5_set_font_face@0x42bbf0 resolves operand 1 and calls text_set_primary_font_face@0x42b1d0 on ctx+0x14940. The worker copies the face to manager+0x4e8, formats @%s into the vertical LOGFONT state at +0x18e74, rebuilds CreateFontIndirectA handles/metrics/glyph buffers through text_rebuild_primary_font_resources@0x44f7f0, and maintains both names in its font-family caches. SYS4INI seeds FONT= 明朝.
The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE also constructs an @-prefixed vertical face. Himegari uses only the full-width Windows family names 明朝 (168 sites) and ゴシック (39 sites). This is live raster state, not a declaration/no-op.
### 0x1b5 `set-message-glyph-delay` (u0041B5F0, argc 1) ### 0x1b5 `set-message-glyph-delay` (u0041B5F0, argc 1)
- **summary:** (milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately. - **summary:** (milliseconds) - set the per-glyph ADV text reveal delay; zero makes retained UI text publish immediately.
@@ -173,7 +182,7 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que
### 0x2bd `set-font-bold` (set-font-bold, argc 1) ### 0x2bd `set-font-bold` (set-font-bold, argc 1)
- **summary:** (enabled) - set the current primary text font weight to 700 when enabled or 0 when disabled, then rebuild the native font state. - **summary:** (enabled) - set the current primary text font weight to 700 when enabled or 0 when disabled, then rebuild the native font state.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x2bd_handler@0x4251c0 writes enabled?700:0 to LOGFONT weight at text-manager+0x4dc and calls the font rebuild worker. It immediately follows set-font throughout the UI corpus; HISTORY.BIN selects the Mincho face with bold enabled. - **evidence:** Ghidra /v2: op_0x2bd_set_font_bold@0x4251c0 writes enabled?700:0 to LOGFONT weight at text-manager+0x4dc and calls text_rebuild_primary_font_resources@0x44f7f0. It immediately follows set-font throughout the UI corpus; HISTORY.BIN selects the Mincho face with bold enabled.
## audio ## audio
@@ -1339,10 +1348,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
### 0x1a5 `set-font` (set-font, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=med
### 0x1a7 `comment` (comment, argc 1) ### 0x1a7 `comment` (comment, argc 1)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med

View File

@@ -3329,6 +3329,20 @@ shared-profile registration.
`G[0x3ebe]` (21 references across CALCREVISE, CHMENU, DRAWTIP, GAMECLEAR, GAMESTART, IMPROVE, and `G[0x3ebe]` (21 references across CALCREVISE, CHMENU, DRAWTIP, GAMECLEAR, GAMESTART, IMPROVE, and
TUNE). TUNE).
## ADV requested-face selection implemented (2026-07-28)
Opcode `0x1a5` is no longer a presentation no-op. The VM retains its requested face in
`AdvTextStyle`, so live text, retained backlog records, History replay, and surface strings all preserve
the active family. Godot resolves Himegari's two authored requests (` 明朝` and ` ゴシック`) to
the corresponding Windows TTC files and caches regular/bold variants independently. If a requested face
is unavailable or unknown, presentation falls back to the existing best-effort Japanese font rather than
dropping the text or inventing a bundled font dependency.
Configurable defaults and per-face substitutions remain deferred to the general runtime configuration
design, especially for Linux/macOS where the proprietary Microsoft faces are not normally installed.
Focused regressions cover face changes flowing into both live and retained styles; the engine tests and
Godot C# build pass. User visual acceptance passed on 2026-07-28.
## Persistence native-format reconnaissance complete (2026-07-24) ## Persistence native-format reconnaissance complete (2026-07-24)
The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native

View File

@@ -31,7 +31,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback | Add target-specific native builds and export packaging | | AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state; managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates | | Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state; managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug | | Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery | | ADV font discovery/raster fidelity | Opcode `0x1a5` now reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback; Godot still supplies shaping and outline synthesis | A normal Windows install now preserves Himegari's Mincho/Gothic distinction and face-specific bold caches; Linux/macOS normally lack these proprietary fonts, while advances, wrapping, boldness, and outline coverage still depend on the substitute/backend | Add configurable default and requested-face substitutions as part of the broader runtime configuration design; for portable distribution bundle licensed metric-compatible faces or define profile substitutions, then decide whether exact AGE grayscale/effect rasterization warrants a custom glyph backend |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
| Save/profile storage | Managed `DirectoryNativeDatStore` under Godot `user://SAVE`; native S3SD/S4SD/S3RT files and 24-bit BMP thumbnails | No Win32 path API at runtime; port saves remain isolated from the original installation | Validate replace/flush, case, permissions, and interrupted-write behavior on each export target | | Save/profile storage | Managed `DirectoryNativeDatStore` under Godot `user://SAVE`; native S3SD/S4SD/S3RT files and 24-bit BMP thumbnails | No Win32 path API at runtime; port saves remain isolated from the original installation | Validate replace/flush, case, permissions, and interrupted-write behavior on each export target |
| Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests | | Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests |

View File

@@ -9,6 +9,32 @@ using Xunit;
public class AdvTextOpsTests public class AdvTextOpsTests
{ {
[Fact]
public void SetFontFaceFlowsIntoLiveAndRetainedTextStyles()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
static Operand I(long value) => new(0, value);
static Operand S(int index) => new(2, index);
var script = ScriptAssembler.Assemble(table, "ADV_FONT_FACE",
new List<(int, Operand[])>
{
(0x1a5, new[] { S(0) }),
(0x6e, new[] { I(0), S(2) }),
(0x1a5, new[] { S(1) }),
(0x6e, new[] { I(0), S(3) }),
(0x2, Array.Empty<Operand>()),
}, new[] { " 明朝", " ゴシック", "mincho", "gothic" });
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal(new[] { " 明朝", " ゴシック" },
host.LiveTextRuns.Select(run => run.Run.Style.FontFace));
Assert.Equal(new[] { " 明朝", " ゴシック" },
vm.TextHistory.Records.Select(record => record.Style.FontFace));
}
[Fact] [Fact]
public void TextCursorAndDrawStringReachHostWithLocalStringPointer() public void TextCursorAndDrawStringReachHostWithLocalStringPointer()
{ {

View File

@@ -17,7 +17,7 @@ public class HistoryPresentationOpsTests
history.DefineLayout(1, 640, 160, 80, 430); history.DefineLayout(1, 640, 160, 80, 430);
history.AppendMetadata(9, 2, AdvTextStyle.Default); history.AppendMetadata(9, 2, AdvTextStyle.Default);
history.AppendText(0, 10, "retained dialogue", new AdvTextStyle(24, 8, false, history.AppendText(0, 10, "retained dialogue", new AdvTextStyle(24, 8, false,
0xffffff, 0x606060, 3, 1, 1, 9)); 0xffffff, 0x606060, 3, 1, 1, 9, " 明朝"));
history.ResetLayout(1); history.ResetLayout(1);
history.AppendText(0, 11, "next group", AdvTextStyle.Default); history.AppendText(0, 11, "next group", AdvTextStyle.Default);
history.SetRecordingEnabled(false); history.SetRecordingEnabled(false);
@@ -42,9 +42,9 @@ public class HistoryPresentationOpsTests
Assert.Equal((4, 0, "retained dialogue"), Assert.Equal((4, 0, "retained dialogue"),
(render.LayoutSlot, render.FirstRecordIndex, render.Text)); (render.LayoutSlot, render.FirstRecordIndex, render.Text));
Assert.Equal(new AdvTextLayoutSnapshot(4, 600, 150, 65, 150, 45, 42, 600, 150), render.Layout); Assert.Equal(new AdvTextLayoutSnapshot(4, 600, 150, 65, 150, 45, 42, 600, 150), render.Layout);
Assert.Equal((24, 0xffffffL, 0x606060L, 8), Assert.Equal((24, 0xffffffL, 0x606060L, 8, " 明朝"),
(render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor, (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor,
render.Style.LineSpacing)); render.Style.LineSpacing, render.Style.FontFace));
} }
[Fact] [Fact]

View File

@@ -27,9 +27,10 @@ public readonly record struct AdvTextStyle(
int RenderMode, int RenderMode,
int EffectOffsetX, int EffectOffsetX,
int EffectOffsetY, int EffectOffsetY,
int LineSpacing) int LineSpacing,
string FontFace)
{ {
public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0, 6); public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0, 6, "");
} }
/// <summary>A stable snapshot of the layout state associated with a retained record.</summary> /// <summary>A stable snapshot of the layout state associated with a retained record.</summary>

View File

@@ -2269,6 +2269,7 @@ public sealed class VirtualMachine
case "end-text-line": case "end-text-line":
TextHistory.EndLine((int)Read(a[0]), _advTextStyle); return pc + 1; TextHistory.EndLine((int)Read(a[0]), _advTextStyle); return pc + 1;
case "set-font": case "set-font":
_advTextStyle = _advTextStyle with { FontFace = ReadStr(a[0]) }; return pc + 1;
case "comment": case "display-furigana": case "dev_ukn": case "comment": case "display-furigana": case "dev_ukn":
return pc + 1; return pc + 1;
case "create-texture": // 0x1f8 (slot)(w)(h) — allocate a blank surface at the slot case "create-texture": // 0x1f8 (slot)(w)(h) — allocate a blank surface at the slot

View File

@@ -34,8 +34,13 @@ public partial class Main : Godot.Control
private readonly System.Collections.Generic.List<Label> _advTextLabels = new(); private readonly System.Collections.Generic.List<Label> _advTextLabels = new();
private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new(); private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new();
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new(); private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
private Font? _presentationRegularFont; private Font? _presentationFallbackFont;
private FontVariation? _presentationBoldFont; private readonly Dictionary<string, Font> _presentationFaceFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FontVariation> _presentationBoldFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _unavailablePresentationFaces =
new(System.StringComparer.OrdinalIgnoreCase);
private Label _status = null!; private Label _status = null!;
private Label _locatorHud = null!; private Label _locatorHud = null!;
private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _bgm = null!; // looping background music
@@ -146,6 +151,7 @@ public partial class Main : Godot.Control
try try
{ {
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) }; var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
_presentationFallbackFont = ff;
_text.AddThemeFontOverride("font", ff); _text.AddThemeFontOverride("font", ff);
_speaker.AddThemeFontOverride("font", ff); _speaker.AddThemeFontOverride("font", ff);
_status.AddThemeFontOverride("font", ff); _status.AddThemeFontOverride("font", ff);
@@ -1367,17 +1373,21 @@ public partial class Main : Godot.Control
private void ApplyAdvTextStyle(Label label, AdvTextStyle style) private void ApplyAdvTextStyle(Label label, AdvTextStyle style)
{ {
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24; int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
_presentationRegularFont ??= _text.GetThemeFont("font"); Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
if (style.Bold) if (style.Bold)
{ {
_presentationBoldFont ??= new FontVariation if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
{ {
BaseFont = _presentationRegularFont, boldFont = new FontVariation
VariationEmbolden = 1.2f, {
}; BaseFont = regularFont,
label.AddThemeFontOverride("font", _presentationBoldFont); VariationEmbolden = 1.2f,
};
_presentationBoldFonts.Add(fontKey, boldFont);
}
label.AddThemeFontOverride("font", boldFont);
} }
else label.AddThemeFontOverride("font", _presentationRegularFont); else label.AddThemeFontOverride("font", regularFont);
label.AddThemeFontSizeOverride("font_size", fontSize); label.AddThemeFontSizeOverride("font_size", fontSize);
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White)); label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
label.AddThemeColorOverride("font_outline_color", RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f))); label.AddThemeColorOverride("font_outline_color", RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f)));
@@ -1387,6 +1397,57 @@ public partial class Main : Godot.Control
label.AddThemeConstantOverride("outline_size", outline); label.AddThemeConstantOverride("outline_size", outline);
} }
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
{
_presentationFallbackFont ??= _text.GetThemeFont("font");
string face = requestedFace?.Trim() ?? "";
if (face.Length == 0)
{
fontKey = "";
return _presentationFallbackFont;
}
if (_presentationFaceFonts.TryGetValue(face, out Font? cached))
{
fontKey = face;
return cached;
}
if (!_unavailablePresentationFaces.Contains(face))
{
foreach (string path in PresentationFontPaths(face))
{
if (!System.IO.File.Exists(path)) continue;
try
{
var loaded = new FontFile { Data = System.IO.File.ReadAllBytes(path) };
_presentationFaceFonts.Add(face, loaded);
fontKey = face;
return loaded;
}
catch
{
// Try the next known file before falling back to the presentation default.
}
}
_unavailablePresentationFaces.Add(face);
}
fontKey = "";
return _presentationFallbackFont;
}
private static IEnumerable<string> PresentationFontPaths(string face)
{
if (face.Equals(" 明朝", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Mincho", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msmincho.ttc";
}
else if (face.Equals(" ゴシック", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Gothic", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msgothic.ttc";
}
}
private static Color RgbColor(long rgb, Color fallback) private static Color RgbColor(long rgb, Color fallback)
{ {
if ((rgb & 0x00ff_ffff) == 0) return fallback; if ((rgb & 0x00ff_ffff) == 0) return fallback;

View File

@@ -63,11 +63,61 @@ name = "gfx_default_object_slot"
type = "int" type = "int"
note = "op 0x80 selected slot; op 0x1d9 substitutes it when its explicit object-slot operand is zero" note = "op 0x80 selected slot; op 0x1d9 substitutes it when its explicit object-slot operand is zero"
[[field]] [[field]]
offset = 0x14e0c
name = "text_primary_font_height"
type = "int"
note = "primary LOGFONTA.lfHeight at text-manager+0x4cc; stored as a negative pixel height and updated by op 0x75"
[[field]]
offset = 0x14e10
name = "text_primary_font_width"
type = "int"
note = "primary LOGFONTA.lfWidth at text-manager+0x4d0; op 0x75 stores negative half the requested font size"
[[field]]
offset = 0x14e1c
name = "text_primary_font_weight"
type = "int"
note = "primary LOGFONTA.lfWeight at text-manager+0x4dc; op 0x2bd writes 700 for enabled or zero for disabled"
[[field]]
offset = 0x14e84
name = "text_antialias_enabled"
type = "int"
note = "text-manager+0x544; message:UseAntiFont selects GGO_GRAY4_BITMAP when nonzero and GGO_BITMAP when zero"
[[field]]
offset = 0x14e88
name = "text_antialias_version"
type = "int"
note = "text-manager+0x548 loaded from set:AntiFontVersion; Himegari SYS4INI supplies version 3"
[[field]]
offset = 0x14e8c
name = "text_primary_color_bgr"
type = "uint"
note = "text-manager+0x54c internal GDI BGR/COLORREF primary glyph color; op 0x76 converts script RGB into it"
[[field]]
offset = 0x14e90
name = "text_effect_color_bgr"
type = "uint"
note = "text-manager+0x550 internal GDI BGR/COLORREF effect glyph color; op 0x77 converts script RGB into it"
[[field]]
offset = 0x14e98
name = "text_render_mode"
type = "int"
note = "text-manager+0x558; op 0x78 selects primary-only, displaced shadow, reduced-coverage repeat, or sampled ellipse outline"
[[field]]
offset = 0x14ea0 offset = 0x14ea0
name = "text_line_spacing" name = "text_line_spacing"
type = "int" type = "int"
note = "extra pixel leading between text lines; defaults to 6, op 0x8b writes it" note = "extra pixel leading between text lines; defaults to 6, op 0x8b writes it"
[[field]] [[field]]
offset = 0x14ea4
name = "text_effect_offset_x"
type = "int"
note = "text-manager+0x564; op 0x1a4 horizontal displacement for mode 1 or ellipse radius for mode 3"
[[field]]
offset = 0x14ea8
name = "text_effect_offset_y"
type = "int"
note = "text-manager+0x568; op 0x1a4 vertical displacement for mode 1 or ellipse radius for mode 3"
[[field]]
offset = 0x14f45 offset = 0x14f45
name = "script_frame_index" name = "script_frame_index"
type = "int" type = "int"
@@ -448,6 +498,11 @@ name = "save_frame_boundary_index"
type = "int" type = "int"
note = "highest script-frame index included by numbered-save layouts 2/3; -1 falls back to cur_ctx_index; op 0x1ad marks current frame and op 0x2 clears after unwinding below it" note = "highest script-frame index included by numbered-save layouts 2/3; -1 falls back to cur_ctx_index; op 0x1ad marks current frame and op 0x2 clears after unwinding below it"
[[field]] [[field]]
offset = 0x99290
name = "gfx_draw_mode"
type = "int"
note = "loaded from set:DrawMode during runtime initialization; Himegari uses mode 1, selecting the cached GetGlyphOutline text path"
[[field]]
offset = 0x9b24c offset = 0x9b24c
name = "dispatch_table" name = "dispatch_table"
type = "void*" type = "void*"

View File

@@ -1116,7 +1116,7 @@ noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] depends_on = []
evidence = "Ghidra /v2: op_0x75_handler@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, derives width, and rebuilds font resources. HISTORY.BIN selects 22 pixels while active and restores 24 on exit." evidence = "Ghidra /v2: op_0x75_set_font_size@0x41ea10 calls text_set_primary_font_size@0x415bd0 on ctx+0x14940. The worker writes the negated pixel height to both primary LOGFONT states, sets lfWidth to negative half the requested size, applies AGE's odd/32/33-pixel height adjustment, and rebuilds both GDI font resources plus glyph buffers. All shipped Himegari sizes are even and avoid the adjustment; HISTORY.BIN selects 22 pixels while active and restores 24 on exit."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -1175,11 +1175,12 @@ abi_source = "kelebek+decode-validated"
name = "set-text-render-mode" name = "set-text-render-mode"
category = "adv" category = "adv"
summary = "(mode) - select the current text raster/effect mode." summary = "(mode) - select the current text raster/effect mode."
details = "Mode 0 draws only the primary glyph. Mode 1 draws one effect-colored copy displaced by the configured x/y offset, then the primary glyph. Mode 2 adds a reduced-coverage primary-color bitmap pass after the ordinary glyph and is unused by Himegari scripts. Mode 3 repeatedly draws the effect glyph around an ellipse whose radii are the configured x/y extents, then draws the primary glyph; Himegari's (1,1) profile produces its one-pixel outline."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] depends_on = []
evidence = "Ghidra /v2: op_0x78_handler@0x41eb10 writes operand 1 to text-manager+0x558 and rebuilds font state. Direct draw and text_history_render_records branch on this field (including a distinct mode-3 offset path). HISTORY.BIN selects mode 3 and restores mode 3 with the normal ADV font preset." evidence = "Ghidra /v2: op_0x78_set_text_render_mode@0x41eb10 writes operand 1 to text-manager+0x558 and rebuilds font state. Himegari's DrawMode=1 path reaches text_raster_string_cached@0x45b600: mode 1 blits effect color at (+x,+y); mode 2 repeats a quarter-coverage glyph bitmap in primary color; mode 3 samples text_compute_outline_ellipse_offset@0x459860 around 360 degrees before the primary blit. The corpus uses only mode 1 (43 sites) and mode 3 (165 sites)."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -3577,7 +3578,7 @@ noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] depends_on = []
evidence = "Ghidra /v2: op_0x1a4_handler@0x41f440 writes operands 1/2 to text-manager+0x564/+0x568. Native raster/bounds paths expand or shift text by these values when an effect mode is active; mode 3 subtracts both. HISTORY.BIN uses (1,1)." evidence = "Ghidra /v2: op_0x1a4_set_text_effect_offset@0x41f440 writes operands 1/2 to text-manager+0x564/+0x568. Mode 1 uses them as a single displaced effect-color shadow. Mode 3 treats them as ellipse radii and samples x=cos(angle)*offset_x, y=sin(angle)*offset_y around the glyph before the primary draw. The Himegari corpus pairs mode 1 with (0,0) at all 43 sites and mode 3 with (1,1) at all 164 paired sites."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
@@ -3597,17 +3598,18 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "set-font" name = "set-font"
category = "unknown" category = "adv"
summary = "" summary = "(face) - select the primary GDI font face, derive its vertical-writing companion, and rebuild the native font/glyph state."
details = "The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE also constructs an @-prefixed vertical face. Himegari uses only the full-width Windows family names 明朝 (168 sites) and ゴシック (39 sites). This is live raster state, not a declaration/no-op."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "med" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra /v2: op_0x1a5_set_font_face@0x42bbf0 resolves operand 1 and calls text_set_primary_font_face@0x42b1d0 on ctx+0x14940. The worker copies the face to manager+0x4e8, formats @%s into the vertical LOGFONT state at +0x18e74, rebuilds CreateFontIndirectA handles/metrics/glyph buffers through text_rebuild_primary_font_resources@0x44f7f0, and maintains both names in its font-family caches. SYS4INI seeds FONT= 明朝."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "CP932 GDI face name"
observed_types = ["string"] observed_types = ["string"]
[[opcode]] [[opcode]]
@@ -6765,7 +6767,7 @@ noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] depends_on = []
evidence = "Ghidra /v2: op_0x2bd_handler@0x4251c0 writes enabled?700:0 to LOGFONT weight at text-manager+0x4dc and calls the font rebuild worker. It immediately follows set-font throughout the UI corpus; HISTORY.BIN selects the Mincho face with bold enabled." evidence = "Ghidra /v2: op_0x2bd_set_font_bold@0x4251c0 writes enabled?700:0 to LOGFONT weight at text-manager+0x4dc and calls text_rebuild_primary_font_resources@0x44f7f0. It immediately follows set-font throughout the UI corpus; HISTORY.BIN selects the Mincho face with bold enabled."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1