Calibrate native text weight and advances
This commit is contained in:
@@ -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
|
`(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.
|
remains unimplemented and is unused by this game.
|
||||||
|
|
||||||
The remaining raster behavior is still an approximation: weight 700 uses Godot
|
A 2026-07-28 Windows calibration compared exact-profile GDI
|
||||||
`VariationEmbolden=1.2`, and mode 3 does not reproduce AGE's exact sampled coverage at each ellipse
|
`CreateFontIndirectW`/`GetTextExtentPoint32W`/`GetGlyphOutlineW(GGO_GRAY4_BITMAP)` results with Godot's
|
||||||
angle. Exact GDI-compatible advances, grayscale coverage, and edge pixels require a dedicated glyph
|
loaded TTCs. Six mixed kanji, kana, punctuation, full-width digit, and Latin samples found exact
|
||||||
backend (or a deliberately calibrated approximation) behind the existing engine-owned layout/style
|
horizontal agreement for regular 24px Mincho and regular 16px Gothic. GDI also produced identical
|
||||||
contract. Portable configurable face substitutions/defaults are intentionally deferred to the broader
|
measurements with the native negative half-height `lfWidth` and `lfWidth=0`; no horizontal scaling is
|
||||||
runtime configuration design.
|
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)
|
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
`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)
|
## 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
|
||||||
|
|||||||
@@ -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/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` 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 `MS 明朝`/`MS ゴシック` 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ public partial class Main : Godot.Control
|
|||||||
{
|
{
|
||||||
private const int ScreenWidth = 800;
|
private const int ScreenWidth = 800;
|
||||||
private const int ScreenHeight = 600;
|
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 TextureRect _screenView = null!; // shows the composited screen backbuffer
|
||||||
private Image _screen = null!; // 800x600 immediate-mode canvas
|
private Image _screen = null!; // 800x600 immediate-mode canvas
|
||||||
private ImageTexture _screenTex = null!;
|
private ImageTexture _screenTex = null!;
|
||||||
@@ -1374,6 +1379,7 @@ public partial class Main : Godot.Control
|
|||||||
{
|
{
|
||||||
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
|
||||||
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
|
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
|
||||||
|
Font presentationFont = regularFont;
|
||||||
if (style.Bold)
|
if (style.Bold)
|
||||||
{
|
{
|
||||||
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
|
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
|
||||||
@@ -1381,26 +1387,31 @@ public partial class Main : Godot.Control
|
|||||||
boldFont = new FontVariation
|
boldFont = new FontVariation
|
||||||
{
|
{
|
||||||
BaseFont = regularFont,
|
BaseFont = regularFont,
|
||||||
VariationEmbolden = 1.2f,
|
VariationEmbolden = NativeBoldEmbolden,
|
||||||
|
SpacingGlyph = NativeBoldGlyphSpacing,
|
||||||
};
|
};
|
||||||
_presentationBoldFonts.Add(fontKey, boldFont);
|
_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.AddThemeFontSizeOverride("font_size", fontSize);
|
||||||
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
|
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
|
||||||
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
|
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
|
||||||
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
|
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
|
||||||
label.AddThemeColorOverride("font_outline_color", effectColor);
|
label.AddThemeColorOverride("font_outline_color", effectColor);
|
||||||
label.AddThemeColorOverride("font_shadow_color", effect.ShadowEnabled ? effectColor : Colors.Transparent);
|
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("outline_size", effect.OutlineSize);
|
||||||
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
|
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
|
||||||
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
|
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
|
||||||
label.AddThemeConstantOverride("shadow_outline_size", 0);
|
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(
|
private readonly record struct AdvTextEffectTheme(
|
||||||
int OutlineSize,
|
int OutlineSize,
|
||||||
bool ShadowEnabled,
|
bool ShadowEnabled,
|
||||||
@@ -2104,18 +2115,35 @@ public partial class Main : Godot.Control
|
|||||||
});
|
});
|
||||||
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
|
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
|
||||||
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
|
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
|
||||||
|
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||||
|
{
|
||||||
|
PrimaryFontSize = 24,
|
||||||
|
Bold = true,
|
||||||
|
FontFace = "MS 明朝",
|
||||||
|
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();
|
textEffectSmoke.QueueFree();
|
||||||
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
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); " +
|
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
|
||||||
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
|
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
|
||||||
$"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " +
|
$"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}; " +
|
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " +
|
||||||
$"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " +
|
$"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " +
|
||||||
$"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " +
|
$"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " +
|
||||||
$"bgm-fade-replacement={bgmReplacementCancelsFade}; " +
|
$"bgm-fade-replacement={bgmReplacementCancelsFade}; " +
|
||||||
$"text-effect-modes={textEffectModesOk}");
|
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}");
|
||||||
GetTree().Quit(ok ? 0 : 1);
|
GetTree().Quit(ok ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user