Separate native text effect modes
This commit is contained in:
@@ -2507,12 +2507,20 @@ face so a runtime face switch cannot reuse the preceding family's synthetic-bold
|
|||||||
important Mincho/Gothic distinction on a normal Windows installation without treating the proprietary
|
important Mincho/Gothic distinction on a normal Windows installation without treating the proprietary
|
||||||
fonts as game assets.
|
fonts as game assets.
|
||||||
|
|
||||||
|
Godot presentation now distinguishes the two shipped effect profiles. Mode 1 uses the Label shadow
|
||||||
|
layer as one effect-color glyph at the configured `(x,y)` displacement behind the primary glyph; this
|
||||||
|
also preserves Himegari's zero-offset double-composite profile instead of inventing an outline. Mode 3
|
||||||
|
uses a symmetric outline whose size is the maximum absolute configured radius. That is a good
|
||||||
|
backend-native approximation for every shipped mode-3 call because Himegari always requests equal
|
||||||
|
`(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
|
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
|
`VariationEmbolden=1.2`, and mode 3 does not reproduce AGE's exact sampled coverage at each ellipse
|
||||||
therefore modeled as an outline rather than its native displaced-copy operation. Exact GDI-compatible
|
angle. Exact GDI-compatible advances, grayscale coverage, and edge pixels require a dedicated glyph
|
||||||
advances, grayscale coverage, and mode-3 edge pixels require a dedicated glyph backend (or a deliberately
|
backend (or a deliberately calibrated approximation) behind the existing engine-owned layout/style
|
||||||
calibrated approximation) behind the existing engine-owned layout/style contract. Portable configurable
|
contract. Portable configurable face substitutions/defaults are intentionally deferred to the broader
|
||||||
face substitutions/defaults are intentionally deferred to the broader runtime configuration design.
|
runtime configuration design.
|
||||||
|
|
||||||
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
||||||
|
|
||||||
|
|||||||
@@ -3343,6 +3343,20 @@ design, especially for Linux/macOS where the proprietary Microsoft faces are not
|
|||||||
Focused regressions cover face changes flowing into both live and retained styles; the engine tests and
|
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.
|
Godot C# build pass. User visual acceptance passed on 2026-07-28.
|
||||||
|
|
||||||
|
## ADV text effect modes separated (2026-07-28)
|
||||||
|
|
||||||
|
The shared Godot text-style applicator no longer treats every nonzero render mode as an outline. Mode 1
|
||||||
|
uses the Label shadow layer to draw one effect-color glyph at the configured displacement before the
|
||||||
|
primary glyph; this includes Himegari's 43 zero-offset uses. Mode 3 uses a symmetric outline sized from
|
||||||
|
the configured radii, matching the shape of all 164 paired Himegari sites because they uniformly request
|
||||||
|
`(1,1)`. Mode 0 clears both layers, while native mode 2 remains unused and unimplemented.
|
||||||
|
|
||||||
|
The mapping applies uniformly to live ADV runs, retained History batches, and text drawn onto retained
|
||||||
|
surfaces. A focused VM regression protects mode/color/offset propagation through live and retained style
|
||||||
|
records, and the Godot self-test applies both modes to a real Label and checks its shadow/outline theme
|
||||||
|
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.
|
||||||
|
|
||||||
## 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` now reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` 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 |
|
| 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 |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -35,6 +35,41 @@ public class AdvTextOpsTests
|
|||||||
vm.TextHistory.Records.Select(record => record.Style.FontFace));
|
vm.TextHistory.Records.Select(record => record.Style.FontFace));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TextEffectModeColorAndOffsetsFlowIntoLiveAndRetainedStyles()
|
||||||
|
{
|
||||||
|
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_TEXT_EFFECT",
|
||||||
|
new List<(int, Operand[])>
|
||||||
|
{
|
||||||
|
(0x77, new[] { I(0x123456) }),
|
||||||
|
(0x78, new[] { I(1) }),
|
||||||
|
(0x1a4, new[] { I(2), I(-1) }),
|
||||||
|
(0x6e, new[] { I(0), S(0) }),
|
||||||
|
(0x78, new[] { I(3) }),
|
||||||
|
(0x1a4, new[] { I(1), I(1) }),
|
||||||
|
(0x6e, new[] { I(0), S(1) }),
|
||||||
|
(0x2, Array.Empty<Operand>()),
|
||||||
|
}, new[] { "shadow", "outline" });
|
||||||
|
var host = new RecordingHost();
|
||||||
|
var vm = new VirtualMachine(script, table, host);
|
||||||
|
|
||||||
|
vm.Run();
|
||||||
|
|
||||||
|
Assert.Collection(host.LiveTextRuns,
|
||||||
|
shadow => Assert.Equal((0x123456L, 1, 2, -1),
|
||||||
|
(shadow.Run.Style.EffectColor, shadow.Run.Style.RenderMode,
|
||||||
|
shadow.Run.Style.EffectOffsetX, shadow.Run.Style.EffectOffsetY)),
|
||||||
|
outline => Assert.Equal((0x123456L, 3, 1, 1),
|
||||||
|
(outline.Run.Style.EffectColor, outline.Run.Style.RenderMode,
|
||||||
|
outline.Run.Style.EffectOffsetX, outline.Run.Style.EffectOffsetY)));
|
||||||
|
Assert.Equal(
|
||||||
|
host.LiveTextRuns.Select(run => run.Run.Style),
|
||||||
|
vm.TextHistory.Records.Select(record => record.Style));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TextCursorAndDrawStringReachHostWithLocalStringPointer()
|
public void TextCursorAndDrawStringReachHostWithLocalStringPointer()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1390,13 +1390,36 @@ public partial class Main : Godot.Control
|
|||||||
else label.AddThemeFontOverride("font", regularFont);
|
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)));
|
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", style.LineSpacing);
|
||||||
int outline = style.RenderMode == 0 ? 0 : System.Math.Max(1,
|
label.AddThemeConstantOverride("outline_size", effect.OutlineSize);
|
||||||
System.Math.Max(System.Math.Abs(style.EffectOffsetX), System.Math.Abs(style.EffectOffsetY)));
|
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
|
||||||
label.AddThemeConstantOverride("outline_size", outline);
|
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
|
||||||
|
label.AddThemeConstantOverride("shadow_outline_size", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct AdvTextEffectTheme(
|
||||||
|
int OutlineSize,
|
||||||
|
bool ShadowEnabled,
|
||||||
|
int ShadowOffsetX,
|
||||||
|
int ShadowOffsetY);
|
||||||
|
|
||||||
|
private static AdvTextEffectTheme ResolveAdvTextEffectTheme(AdvTextStyle style)
|
||||||
|
=> style.RenderMode switch
|
||||||
|
{
|
||||||
|
// AGE mode 1 draws one effect-color glyph at (+x,+y), then the primary glyph.
|
||||||
|
1 => new(0, true, style.EffectOffsetX, style.EffectOffsetY),
|
||||||
|
// AGE mode 3 samples an ellipse. Himegari always uses equal (1,1) radii, for which
|
||||||
|
// Godot's symmetric outline is the closest backend-native presentation.
|
||||||
|
3 => new(System.Math.Max(System.Math.Abs(style.EffectOffsetX),
|
||||||
|
System.Math.Abs(style.EffectOffsetY)),
|
||||||
|
false, 0, 0),
|
||||||
|
_ => new(0, false, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
|
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
|
||||||
{
|
{
|
||||||
_presentationFallbackFont ??= _text.GetThemeFont("font");
|
_presentationFallbackFont ??= _text.GetThemeFont("font");
|
||||||
@@ -2043,16 +2066,56 @@ public partial class Main : Godot.Control
|
|||||||
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
|
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
|
||||||
_bgm.Stop();
|
_bgm.Stop();
|
||||||
_bgm.Stream = null;
|
_bgm.Stream = null;
|
||||||
|
AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
||||||
|
{
|
||||||
|
RenderMode = 1,
|
||||||
|
EffectOffsetX = 0,
|
||||||
|
EffectOffsetY = 0,
|
||||||
|
});
|
||||||
|
AdvTextEffectTheme mode3 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
|
||||||
|
{
|
||||||
|
RenderMode = 3,
|
||||||
|
EffectOffsetX = 1,
|
||||||
|
EffectOffsetY = 1,
|
||||||
|
});
|
||||||
|
AdvTextEffectTheme mode0 = ResolveAdvTextEffectTheme(AdvTextStyle.Default);
|
||||||
|
bool textEffectModesOk = mode1 == new AdvTextEffectTheme(0, true, 0, 0)
|
||||||
|
&& mode3 == new AdvTextEffectTheme(1, false, 0, 0)
|
||||||
|
&& mode0 == new AdvTextEffectTheme(0, false, 0, 0);
|
||||||
|
var textEffectSmoke = new Label();
|
||||||
|
AddChild(textEffectSmoke);
|
||||||
|
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||||
|
{
|
||||||
|
RenderMode = 1,
|
||||||
|
EffectColor = 0x123456,
|
||||||
|
EffectOffsetX = 2,
|
||||||
|
EffectOffsetY = -1,
|
||||||
|
});
|
||||||
|
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 0
|
||||||
|
&& textEffectSmoke.GetThemeConstant("shadow_offset_x") == 2
|
||||||
|
&& textEffectSmoke.GetThemeConstant("shadow_offset_y") == -1
|
||||||
|
&& textEffectSmoke.GetThemeColor("font_shadow_color").A > 0.99f;
|
||||||
|
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
|
||||||
|
{
|
||||||
|
RenderMode = 3,
|
||||||
|
EffectColor = 0x123456,
|
||||||
|
EffectOffsetX = 1,
|
||||||
|
EffectOffsetY = 1,
|
||||||
|
});
|
||||||
|
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
|
||||||
|
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
|
||||||
|
textEffectSmoke.QueueFree();
|
||||||
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
|
||||||
&& bgmReplacementCancelsFade;
|
&& bgmReplacementCancelsFade && textEffectModesOk;
|
||||||
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");
|
$"bgm-fade-replacement=ok; text-effect-modes=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}");
|
||||||
GetTree().Quit(ok ? 0 : 1);
|
GetTree().Quit(ok ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user