Calibrate native text weight and advances

This commit is contained in:
gamer147
2026-07-28 16:00:14 -04:00
parent 8b01559cc6
commit 014fd8d78f
4 changed files with 73 additions and 14 deletions

View File

@@ -2515,12 +2515,24 @@ backend-native approximation for every shipped mode-3 call because Himegari alwa
`(1,1)` radii. The same style applicator covers live ADV, retained History, and surface text. Mode 2
remains unimplemented and is unused by this game.
The remaining raster behavior is still an approximation: weight 700 uses Godot
`VariationEmbolden=1.2`, and mode 3 does not reproduce AGE's exact sampled coverage at each ellipse
angle. Exact GDI-compatible advances, grayscale coverage, and 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.
A 2026-07-28 Windows calibration compared exact-profile GDI
`CreateFontIndirectW`/`GetTextExtentPoint32W`/`GetGlyphOutlineW(GGO_GRAY4_BITMAP)` results with Godot's
loaded TTCs. Six mixed kanji, kana, punctuation, full-width digit, and Latin samples found exact
horizontal agreement for regular 24px Mincho and regular 16px Gothic. GDI also produced identical
measurements with the native negative half-height `lfWidth` and `lfWidth=0`; no horizontal scaling is
needed for those profiles on this installation.
The old `VariationEmbolden=1.2` was not a good weight-700 approximation: the sample glyph `` had
coverage sum 6,651 versus native 4,615 (+44%), while complete strings remained as much as 9 pixels
narrower than native. `VariationEmbolden=0.53` plus one pixel of glyph spacing yields coverage 4,600
(-0.3%) and bounds all six complete-string errors to 0..3 pixels. Godot reports a 25px/17px line box
for the native 24px/16px profiles, so presentation subtracts that one-pixel backend excess from the
script's extra line spacing, retaining AGE's requested total line advance. The calibration is applied
per bold font variation and through the common live/History/surface style path.
Mode 3 still does not reproduce AGE's exact sampled coverage at each ellipse angle. Exact per-pixel
parity beyond the calibrated profiles requires a dedicated glyph backend. Portable configurable face
substitutions/defaults are intentionally deferred to the broader runtime configuration design.
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)

View File

@@ -3357,6 +3357,25 @@ records, and the Godot self-test applies both modes to a real Label and checks i
state. Validation: engine **431/431**, zero-warning Godot build, and threaded `SELFTEST OK` with
`text-effect-modes=ok`. User visual acceptance passed on 2026-07-28.
## ADV font weight and advance calibrated (2026-07-28)
A disposable Windows GDI oracle measured six representative strings and the grayscale `` glyph
against Godot's actual `msmincho.ttc`/`msgothic.ttc` presentation. Regular 24px Mincho and 16px Gothic
advances match exactly, so neither face receives speculative horizontal scaling. The former
`VariationEmbolden=1.2` overshot native weight-700 glyph coverage by 44% while remaining up to 9 pixels
narrow across a complete sample string.
The bold variation now uses measured `VariationEmbolden=0.53` plus one pixel of glyph spacing. Its
grayscale coverage is within 0.3% of GDI and the six measured string widths are within 0..3 pixels.
The shared style path also compensates for Godot reporting the two fonts' line boxes one pixel taller
than AGE's explicit requested heights, preserving native total line advance during wrapping. The Godot
self-test checks the calibrated variation and both 24px/16px spacing examples.
Validation: engine **431/431**, zero-warning Godot build, and threaded `SELFTEST OK` with
`font-calibration=ok`. The probes were deliberately disposable; the canonical measurements live in
`docs/engine-re.md` rather than adding a Windows-only runtime/tool dependency. User visual acceptance
passed on 2026-07-28.
## Persistence native-format reconnaissance complete (2026-07-24)
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 |
| 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 |
| ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback; native mode 1 maps to one Godot shadow glyph and shipped `(1,1)` mode 3 maps to a one-pixel outline | A normal Windows install preserves Himegari's face distinction and separate displaced-effect/outline profiles; Linux/macOS normally lack the proprietary fonts, while advances, wrapping, boldness, and exact grayscale 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 rasterization warrants a custom glyph backend |
| ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback; native mode 1 maps to one Godot shadow glyph and shipped `(1,1)` mode 3 maps to a one-pixel outline | On the reference Windows install, regular 24px Mincho/16px Gothic advances match GDI exactly; calibrated bold coverage is within 0.3%, six-sample string drift is at most 3px, and backend line-box excess is compensated. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | 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 broader exact AGE grayscale 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 |
| 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 |

View File

@@ -16,6 +16,11 @@ public partial class Main : Godot.Control
{
private const int ScreenWidth = 800;
private const int ScreenHeight = 600;
// Calibrated against GDI GetGlyphOutlineW(GGO_GRAY4_BITMAP) for Himegari's 24px MS Mincho
// weight-700 profile. This matches native glyph coverage while one pixel of spacing approximates
// GDI's synthetic-bold advance expansion; see docs/engine-re.md.
private const float NativeBoldEmbolden = 0.53f;
private const int NativeBoldGlyphSpacing = 1;
private TextureRect _screenView = null!; // shows the composited screen backbuffer
private Image _screen = null!; // 800x600 immediate-mode canvas
private ImageTexture _screenTex = null!;
@@ -1374,6 +1379,7 @@ public partial class Main : Godot.Control
{
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
Font presentationFont = regularFont;
if (style.Bold)
{
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
@@ -1381,26 +1387,31 @@ public partial class Main : Godot.Control
boldFont = new FontVariation
{
BaseFont = regularFont,
VariationEmbolden = 1.2f,
VariationEmbolden = NativeBoldEmbolden,
SpacingGlyph = NativeBoldGlyphSpacing,
};
_presentationBoldFonts.Add(fontKey, boldFont);
}
label.AddThemeFontOverride("font", boldFont);
presentationFont = boldFont;
}
else label.AddThemeFontOverride("font", regularFont);
label.AddThemeFontOverride("font", presentationFont);
label.AddThemeFontSizeOverride("font_size", fontSize);
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
label.AddThemeColorOverride("font_outline_color", effectColor);
label.AddThemeColorOverride("font_shadow_color", effect.ShadowEnabled ? effectColor : Colors.Transparent);
label.AddThemeConstantOverride("line_spacing", style.LineSpacing);
label.AddThemeConstantOverride("line_spacing",
CalibratedLineSpacing(style.LineSpacing, fontSize, presentationFont.GetHeight(fontSize)));
label.AddThemeConstantOverride("outline_size", effect.OutlineSize);
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
label.AddThemeConstantOverride("shadow_outline_size", 0);
}
private static int CalibratedLineSpacing(int nativeSpacing, int nativeFontHeight, float backendFontHeight)
=> nativeSpacing + nativeFontHeight - System.Math.Max(1, (int)System.MathF.Round(backendFontHeight));
private readonly record struct AdvTextEffectTheme(
int OutlineSize,
bool ShadowEnabled,
@@ -2104,18 +2115,35 @@ public partial class Main : Godot.Control
});
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
PrimaryFontSize = 24,
Bold = true,
FontFace = " 明朝",
LineSpacing = 8,
});
var calibratedBold = textEffectSmoke.GetThemeFont("font") as FontVariation;
bool fontCalibrationOk = calibratedBold != null
&& System.Math.Abs(calibratedBold.VariationEmbolden
- NativeBoldEmbolden) < 0.001f
&& calibratedBold.SpacingGlyph == NativeBoldGlyphSpacing
&& CalibratedLineSpacing(8, 24, 25) == 7
&& CalibratedLineSpacing(9, 16, 17) == 8
&& textEffectSmoke.GetThemeConstant("line_spacing")
== CalibratedLineSpacing(
8, 24, calibratedBold.GetHeight(24));
textEffectSmoke.QueueFree();
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
&& bgmReplacementCancelsFade && textEffectModesOk;
&& bgmReplacementCancelsFade && textEffectModesOk && fontCalibrationOk;
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
$"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " +
$"bgm-fade-replacement=ok; text-effect-modes=ok");
$"bgm-fade-replacement=ok; text-effect-modes=ok; font-calibration=ok");
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " +
$"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " +
$"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " +
$"bgm-fade-replacement={bgmReplacementCancelsFade}; " +
$"text-effect-modes={textEffectModesOk}");
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}");
GetTree().Quit(ok ? 0 : 1);
}