Apply SYS4INI logical resolution

This commit is contained in:
gamer147
2026-07-28 21:55:53 -04:00
parent 9c7cda1ce6
commit eb9f11c251
13 changed files with 501 additions and 64 deletions

View File

@@ -201,7 +201,7 @@ The meaningful consumer catalog is:
| Setting group | Native Himegari effect | Classification |
|---|---|---|
| `CREATEOBJECT`, `DRAWMODE` | `CreateObject=2` selects only the retained D3D/object backend; `DrawMode=1` selects its matching draw/text path. Startup verifies that bit `1 << DrawMode` exists in the backend mask. | Active renderer selection |
| `SCREENX`, `SCREENY` | Replaces the generic `640x480` fallback with the `800x600` logical canvas before window/surface creation. | Active core profile |
| `SCREENX`, `SCREENY` | Replaces the generic `640x480` fallback with the `800x600` logical canvas before window/surface creation. The port now parses and applies these dimensions to its logical canvas and default windowed client size. | Active core profile; implemented |
| `FONT`, `ENABLEANTIFONT`, `ANTIFONTVERSION` | Seeds MS Mincho, enables `message:UseAntiFont`, selects the grayscale glyph-outline path, and records antialias version 3. | Active text profile |
| `DEPENDMOVIESOUND` | Supplies the default movie-audio dependency/routing policy when movie opcode flags do not force another route. | Active media policy |
| `FULLSCREENBIT`, `ALWAYSBACKUPSURFACE` | These are read only inside the legacy `CreateObject & 1` DirectDraw branch of `engine_initialize_graphics_from_settings@0x406480`. Himegari selects only bit 2, so its 32-bit/zero values do not drive the active renderer. | Valid compatibility settings, inert for this profile |
@@ -222,17 +222,19 @@ The meaningful consumer catalog is:
| `REGKEY` | A nonzero value activates `registration_validate_key_file@0x46fc80`, which opens `SYS4RK.BIN`, validates its header/transformed payload and both CRC variants, and publishes the registration result. | Native registration/key validation |
| `COPYRIGHT`, `RCVERSION` | Both are imported; no later direct consumer of the exact registry keys was found in this image. | Loaded product metadata |
This is intentionally a native-behavior catalog, not a mandate to reproduce every Win32-era switch. The
current port already preserves the shipped result of several settings through profile-specific constants:
the `800x600` canvas, requested Mincho/Gothic faces, save version 3.10, and movie/audio behavior. It
deliberately redirects the native AppData path to Godot `user://SAVE`, and it should not reproduce legacy
DirectDraw selection or native registration/key validation.
This is intentionally a native-behavior catalog, not a mandate to reproduce every Win32-era switch.
`Sys4AssetCatalog` now parses the ordered trailer once, retaining unknown pairs for diagnostics, and projects
`SCREENX`/`SCREENY` into one validated engine-owned logical canvas. Godot uses that value for its content-scale
base, default windowed client, backbuffer/compositor bounds, primary surface, movie/layout fallbacks, and input
coordinates. AGE's independent `640x480` defaults apply for missing or invalid dimensions. The port still
preserves requested Mincho/Gothic faces, save version 3.10, and movie/audio behavior through their existing
paths; those keys have not yet been migrated to the parsed registry.
The actionable generic-profile gap is that `Sys4AssetCatalog` still parses only the directory and the frontend
hard-codes the important results. A future configuration/profile slice should expose the typed trailer values
once and let the runtime apply only cross-platform semantic settings. In particular, click-cancel, Auto/Skip
coexistence, cursor policy, redraw policy, and wheel action ids must be decided from this evidence instead of
AGE's compiled defaults. No runtime behavior changes were made as part of this investigation.
The remaining generic-profile gap is semantic application of further portable settings. In particular,
click-cancel, Auto/Skip coexistence, cursor policy, redraw policy, and wheel action ids must be decided from
this evidence instead of AGE's compiled defaults. The port deliberately redirects the native AppData path to
Godot `user://SAVE`, and it should not reproduce legacy DirectDraw selection or native registration/key
validation.
### ops `0x1a2`/`0x1a3` store and restore shared `SAVE.DAT` integer cells (resolved 2026-07-20)

View File

@@ -3521,6 +3521,30 @@ SYS4INI-sized logical canvas into that window using the aspect-preserving/letter
partial overrides, minimum sizes, fullscreen interaction, integer scaling, and alternate stretch/crop
policies belongs to that second slice's configuration design.
### Result (implemented 2026-07-28)
The first slice is complete. `Sys4AssetCatalog` now consumes the bounded base-S4IC trailer after its
existing directory parse and exposes ordered `Sys4StartupSettings`; lookup is case-insensitive and
last-value-wins while unknown keys remain available for future profiles. `Sys4LogicalCanvas` projects
`SCREENX`/`SCREENY` with independent `640x480` fallbacks and rejects dimensions above the bounded
dimension/pixel allocation policy.
Godot loads the catalog before presentation allocation and reuses that instance for normal script/asset
startup. The selected canvas owns the root content-scale base, initial windowed client request,
backbuffer/pixel allocation, GPU and software destination bounds, whole-screen fills, primary and movie
surface fallbacks, fallback ADV layout, and pointer coordinates. `GodotAdvHost` receives the validated
size explicitly, and the platform-neutral retained-surface fill now derives its zero-size fallback from
the destination image. `project.godot` retains only AGE's generic `640x480` bootstrap fallback. No
runtime production literal `800`/`600` remains; authored Himegari data and unrelated regression fixtures
were left unchanged.
Verification passed with 443 engine tests, including installed 36-pair/`800x600`, synthetic
`1024x576`, independent fallback, duplicate/unknown, malformed/unsafe, and non-800 retained-fill cases.
The Godot C# project builds with zero warnings, and the threaded headless selftest reports
`logical-canvas=800x600`. Its synthetic headless physical window is not a client-size oracle; ordinary
windowed startup directly requests the same validated dimensions. Physical window overrides remain the
second slice described above.
## ADV requested-face selection implemented (2026-07-28)
Opcode `0x1a5` is no longer a presentation no-op. The VM retains its requested face in

View File

@@ -27,7 +27,7 @@ or replaced before claiming portable exports.
| Area | Current dependency | Runtime impact | Portability status / future action |
|---|---|---|---|
| SYS4INI per-game startup profile | `Sys4AssetCatalog` currently parses only the asset directory; the frontend hard-codes Himegari's important results. Native SYS4INI also carries canvas, text, ADV input/skip, save ABI/path, audio, legacy renderer, and Windows registration settings | The port has the correct `800x600`, requested faces, save 3.10, and redirected `user://SAVE` result, but does not source `CancelMesSkipOnClick`, `CoexistMesSkip`, cursor/redraw policy, or wheel action ids from the trailer | First parse `SCREENX`/`SCREENY` into a platform-neutral logical canvas and default the physical window to it; later boot parameters may vary only the physical window while Godot scales/letterboxes the unchanged canvas. Apply further semantic keys explicitly, translate save roots through the host, and classify DirectDraw/fullscreen-bit and registration/key settings as native compatibility metadata. Canonical inventory and consumers: `sys4-format-notes.md` and `engine-re.md` |
| SYS4INI per-game startup profile | `Sys4AssetCatalog` parses and retains the bounded ordered startup trailer; `Sys4LogicalCanvas` applies `SCREENX`/`SCREENY` with AGE's independent `640x480` fallbacks. Native SYS4INI also carries text, ADV input/skip, save ABI/path, audio, legacy renderer, and Windows registration settings | Godot now uses the selected canvas for content scaling, backbuffers/compositor bounds, primary surfaces, layout/input fallbacks, and the matching default windowed client. The port still does not source `CancelMesSkipOnClick`, `CoexistMesSkip`, cursor/redraw policy, or wheel action ids from the trailer | Add later boot parameters that vary only the physical window while Godot scales/letterboxes the unchanged canvas. Apply further semantic keys explicitly, translate save roots through the host, and classify DirectDraw/fullscreen-bit and registration/key settings as native compatibility metadata. Canonical inventory and consumers: `sys4-format-notes.md` and `engine-re.md` |
| Retained graphics presentation | Backend-neutral `GfxState`; accepted default Godot `Sprite2D` GPU stage plus the retained software pixel oracle, using runtime `ImageTexture`, canvas transforms/materials, and no native graphics API | GPU backend caches static/color-key variants, updates dynamic surfaces, handles retained range transitions, and falls back whole-frame for the legacy host screen-transition path | Godot owns D3D/Vulkan/Metal/OpenGL selection; validate shader/blend/filter behavior per target rather than adding a platform renderer |
| 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 |

View File

@@ -681,12 +681,12 @@ scaling, and other game-specific defaults therefore belong in the selected game
surface state rather than in a forked frontend. The source is now known: after its asset directory and
VM-bank metadata, SYS4INI carries a typed per-game startup-settings record. Himegari uses it for the
canvas, text face/raster mode, ADV input/skip policy, save ABI/path, audio initialization, and native
Windows compatibility metadata. A future generic profile loader should parse that record once, preserve
unknown/profile-specific data for diagnostics, apply cross-platform semantic settings through explicit
runtime seams, and classify legacy renderer/registration switches rather than blindly emulating them.
The current frontend's scattered Himegari constants are acceptable while gameplay is the priority, but
they are not the multi-profile architecture. The first bounded implementation slice will source the
logical canvas from `SCREENX`/`SCREENY` and use it as the default windowed size; a second slice will add
Windows compatibility metadata. The runtime now parses that record once and preserves unknown/profile-
specific data for diagnostics; semantic settings are applied through explicit cross-platform seams while
legacy renderer/registration switches remain classified rather than blindly emulated. The first bounded
application slice is complete: `SCREENX`/`SCREENY` select the validated logical canvas, all presentation
allocations and primary bounds consume it, and it is the default windowed size. Other settings remain on
their existing paths while gameplay is the priority. A second slice will add
independent physical `--window-width`/`--window-height` overrides without changing VM coordinates or
surface geometry. The executable task plan and gates live in `phase-a-slice-plan.md`. This experiment was
diagnostic only; no Kamidori support or `0x1be` semantics were implemented.

View File

@@ -21,7 +21,7 @@ per format.
`SYS4INI.BIN` is not only the asset catalog. Its `S4IC` LZSS stream begins with the archive names and
80-byte file records documented in `asset-resolution-re.md`, then continues with VM-bank metadata and a
NUL-delimited per-game engine-settings trailer. `tools/parse_sys4ini.py` currently consumes only the
directory prefix.
directory prefix; the C# runtime catalog continues through the trailer and exposes the ordered pairs.
After the directory prefix, Himegari's decompressed stream has this tail:
@@ -90,6 +90,13 @@ that the authored logical canvas is per-game data. Fullscreen/display-mode setti
these logical dimensions. `USEAPPDATAFOLDER` and `SAVEPATH` select the native Windows save root; fixed
save filenames remain AGE policy rather than trailer or script strings.
`Sys4AssetCatalog` now parses this bounded trailer for base `S4IC` catalogs. It preserves ordered unknown
pairs for diagnostics and offers case-insensitive, last-value-wins lookup; this is intentionally more
permissive than AGE's fixed registry so unimplemented keys in another profile remain inspectable.
`Sys4LogicalCanvas` is the first semantic projection: `SCREENX` and `SCREENY` fall back independently to
AGE's registered `640x480` defaults when absent or nonpositive/nonnumeric, while unsafe RGBA allocations
are rejected before presentation construction. Append `S4AC` catalogs do not own startup settings.
## Native persistence files — Himegari 3.10
This section is the canonical binary-format specification for the persistence files used by Himegari.

View File

@@ -38,4 +38,34 @@ public class RetainedSurfaceRasterizerTests
Assert.Equal(0xff, destination.Pixels[index + 3]);
}
}
[Fact]
public void SurfacelessZeroSizedFillUsesDestinationCanvasRatherThanLegacyProfileSize()
{
var fill = new RenderObject(
Handle: 100, SurfaceResId: 0, ColorKey: 0,
SrcX: 0, SrcY: 0, W: 0, H: 0, DstX: 0, DstY: 0,
Transform: new TransformState(1, 1, 1, 0, 0, 0, 0, 0, 0),
Rotation: new RotationCycleState(false, 0, 0, 0, 0),
Alpha: 255, Tint: 0x336699, TintStrength: 0,
Blend: BlendKind.Alpha, MultiplyTint: true);
var destination = new RgbaImage(1024, 576, new byte[1024 * 576 * 4]);
int rendered = RetainedSurfaceRasterizer.CompositeRange(
destination, [fill], 100, 1, _ => null);
Assert.Equal(1, rendered);
AssertPixel(destination, 0, 0, 0x33, 0x66, 0x99, 0xff);
AssertPixel(destination, 1023, 575, 0x33, 0x66, 0x99, 0xff);
}
private static void AssertPixel(
RgbaImage image, int x, int y, byte red, byte green, byte blue, byte alpha)
{
int offset = (y * image.Width + x) * 4;
Assert.Equal(red, image.Pixels[offset]);
Assert.Equal(green, image.Pixels[offset + 1]);
Assert.Equal(blue, image.Pixels[offset + 2]);
Assert.Equal(alpha, image.Pixels[offset + 3]);
}
}

View File

@@ -0,0 +1,186 @@
using System.Buffers.Binary;
using System.Text;
using Age.Engine.Sys4;
public class Sys4StartupSettingsTests
{
[Fact]
public void InstalledCatalogExposesOrderedStartupSettingsAndLogicalCanvas()
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
Assert.Equal(36, catalog.StartupSettings.Count);
Assert.True(catalog.StartupSettings.TryGetValue("SCREENX", out string? width));
Assert.True(catalog.StartupSettings.TryGetValue("screeny", out string? height));
Assert.Equal("800", width);
Assert.Equal("600", height);
Assert.Equal(new Sys4LogicalCanvas(800, 600), catalog.LogicalCanvas);
}
[Fact]
public void SyntheticCatalogPreservesUnknownKeysAndUsesLastCaseInsensitiveValue()
{
var catalog = Sys4AssetCatalog.Parse(BuildCatalog(
("SCREENX", "320"),
("FutureProfileKey", "preserved"),
("screenx", "1024"),
("SCREENY", "576")));
Assert.Equal(4, catalog.StartupSettings.Count);
Assert.Equal("preserved", catalog.StartupSettings.GetValueOrDefault("futureprofilekey"));
Assert.Equal("1024", catalog.StartupSettings.GetValueOrDefault("SCREENX"));
Assert.Equal(new Sys4LogicalCanvas(1024, 576), catalog.LogicalCanvas);
Assert.Equal("SCREENX", catalog.StartupSettings.Pairs[0].Key);
Assert.Equal("screenx", catalog.StartupSettings.Pairs[2].Key);
}
[Theory]
[InlineData(null, null, 640, 480)]
[InlineData("1024", null, 1024, 480)]
[InlineData(null, "576", 640, 576)]
[InlineData("not-a-number", "0", 640, 480)]
[InlineData("-1", " 768 ", 640, 768)]
public void MissingOrInvalidDimensionsFallBackIndependently(
string? width, string? height, int expectedWidth, int expectedHeight)
{
var pairs = new List<(string Key, string Value)>();
if (width != null) pairs.Add(("SCREENX", width));
if (height != null) pairs.Add(("SCREENY", height));
var catalog = Sys4AssetCatalog.Parse(BuildCatalog(pairs.ToArray()));
Assert.Equal(new Sys4LogicalCanvas(expectedWidth, expectedHeight), catalog.LogicalCanvas);
}
[Fact]
public void AbsentSettingsTrailerUsesAgeFallback()
{
var catalog = Sys4AssetCatalog.Parse(BuildCatalog(includeTrailer: false));
Assert.Empty(catalog.StartupSettings.Pairs);
Assert.Equal(new Sys4LogicalCanvas(640, 480), catalog.LogicalCanvas);
}
[Theory]
[InlineData("16385", "480")]
[InlineData("16384", "16384")]
public void UnsafeCanvasSizesAreRejected(string width, string height)
{
Assert.Throws<InvalidDataException>(() =>
Sys4AssetCatalog.Parse(BuildCatalog(("SCREENX", width), ("SCREENY", height))));
}
[Fact]
public void MalformedSettingsTrailerIsRejected()
{
byte[] truncated = BuildCatalog(("SCREENX", "1024"));
Array.Resize(ref truncated, truncated.Length - 1);
Assert.Throws<InvalidDataException>(() => Sys4AssetCatalog.Parse(truncated));
byte[] expanded = BuildExpanded([("SCREENX", "1024")]);
int trailer = 4 + 256 + 4 + 80;
// Declare one extra string byte; the parser must not silently absorb or ignore malformed data.
BinaryPrimitives.WriteUInt32LittleEndian(expanded.AsSpan(trailer + 4, 4),
ReadU32(expanded, trailer + 4) + 1);
Assert.Throws<InvalidDataException>(() =>
Sys4AssetCatalog.Parse(WrapCatalog(expanded), "malformed-settings"));
byte[] missingNul = BuildExpanded([("SCREENX", "1024")]);
missingNul[^1] = (byte)'x';
Assert.Throws<InvalidDataException>(() =>
Sys4AssetCatalog.Parse(WrapCatalog(missingNul), "unterminated-settings"));
byte[] countMismatch = BuildExpanded([("SCREENX", "1024")]);
BinaryPrimitives.WriteUInt32LittleEndian(countMismatch.AsSpan(trailer + 8, 4), 2);
Assert.Throws<InvalidDataException>(() =>
Sys4AssetCatalog.Parse(WrapCatalog(countMismatch), "miscounted-settings"));
}
private static byte[] BuildCatalog(
params (string Key, string Value)[] pairs)
=> WrapCatalog(BuildExpanded(pairs));
private static byte[] BuildCatalog(bool includeTrailer)
=> WrapCatalog(BuildExpanded(Array.Empty<(string, string)>(), includeTrailer));
private static byte[] BuildExpanded(
(string Key, string Value)[] pairs, bool includeTrailer = true)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding cp932 = Encoding.GetEncoding(932);
var blob = new List<byte>();
AddU32(blob, 1);
AddFixedString(blob, "DATA.ALF", 256, cp932);
AddU32(blob, 1);
AddFixedString(blob, "@", 64, cp932);
AddU32(blob, 0);
AddU32(blob, 0);
AddU32(blob, 0);
AddU32(blob, 0);
if (!includeTrailer) return blob.ToArray();
AddU32(blob, 0); // VM metadata bytes
var strings = new List<byte>();
foreach (var pair in pairs)
{
AddCString(strings, pair.Key, cp932);
AddCString(strings, pair.Value, cp932);
}
AddU32(blob, checked((uint)strings.Count));
if (strings.Count == 0) return blob.ToArray();
AddU32(blob, checked((uint)pairs.Length));
blob.AddRange(strings);
return blob.ToArray();
}
private static byte[] WrapCatalog(byte[] expanded)
{
byte[] packed = LiteralLzss(expanded);
var catalog = new byte[0x138 + packed.Length];
Encoding.ASCII.GetBytes("S4IC422 ").CopyTo(catalog, 0);
Encoding.ASCII.GetBytes("Synthetic").CopyTo(catalog, 8);
BinaryPrimitives.WriteUInt32LittleEndian(catalog.AsSpan(0x12c, 4),
checked((uint)expanded.Length));
BinaryPrimitives.WriteUInt32LittleEndian(catalog.AsSpan(0x134, 4),
checked((uint)packed.Length));
packed.CopyTo(catalog, 0x138);
return catalog;
}
private static byte[] LiteralLzss(byte[] source)
{
var packed = new List<byte>();
for (int offset = 0; offset < source.Length;)
{
int count = Math.Min(8, source.Length - offset);
packed.Add((byte)((1 << count) - 1));
for (int i = 0; i < count; i++) packed.Add(source[offset++]);
}
return packed.ToArray();
}
private static void AddFixedString(List<byte> target, string value, int size, Encoding encoding)
{
byte[] bytes = encoding.GetBytes(value);
if (bytes.Length >= size) throw new ArgumentException("test string is too long");
target.AddRange(bytes);
target.AddRange(new byte[size - bytes.Length]);
}
private static void AddCString(List<byte> target, string value, Encoding encoding)
{
target.AddRange(encoding.GetBytes(value));
target.Add(0);
}
private static void AddU32(List<byte> target, uint value)
{
int offset = target.Count;
target.AddRange(new byte[4]);
BinaryPrimitives.WriteUInt32LittleEndian(
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(target).Slice(offset, 4), value);
}
private static uint ReadU32(byte[] bytes, int offset)
=> BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset, 4));
}

View File

@@ -38,8 +38,8 @@ public static class RetainedSurfaceRasterizer
if (source == null)
{
if (item.SurfaceResId != 0 || item.Blend == BlendKind.Opaque) continue;
int width = item.W > 0 ? item.W : 800;
int height = item.H > 0 ? item.H : 600;
int width = item.W > 0 ? item.W : destination.Width;
int height = item.H > 0 ? item.H : destination.Height;
float fillOpacity = item.MultiplyTint ? opacity : opacity * tintStrength;
SoftwareAffineRasterizer.FillRgba(
destination.Pixels, destination.Width, destination.Height,

View File

@@ -40,9 +40,12 @@ public sealed class Sys4AssetCatalog
public IReadOnlyList<AssetEntry> RawSlots { get; }
public IReadOnlyList<AssetEntry> Files { get; }
public IReadOnlyDictionary<int, Sys4AssetCatalog> AppendPacks => _appendPacks;
public Sys4StartupSettings StartupSettings { get; }
public Sys4LogicalCanvas LogicalCanvas { get; }
private Sys4AssetCatalog(string magic, string title, int packId,
List<string> archives, List<AssetEntry> rawSlots)
List<string> archives, List<AssetEntry> rawSlots,
Sys4StartupSettings startupSettings)
{
Magic = magic;
Title = title;
@@ -52,6 +55,8 @@ public sealed class Sys4AssetCatalog
Files = rawSlots.Where(r => !r.IsPlaceholder).ToArray();
_byName = Files.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
_sceneRanges = BuildSceneRanges(Files);
StartupSettings = startupSettings;
LogicalCanvas = Sys4LogicalCanvas.FromSettings(startupSettings);
}
public static Sys4AssetCatalog Load(string path)
@@ -130,7 +135,10 @@ public sealed class Sys4AssetCatalog
string magic = ReadCString(data.AsSpan(0, Math.Min(8, data.Length)));
string title = ReadCString(data.AsSpan(8, Math.Min(256, data.Length - 8)));
return new Sys4AssetCatalog(magic, title, packId, archives, slots);
Sys4StartupSettings startupSettings = isBase
? Sys4StartupSettings.ParseTrailer(blob, ref p, name)
: Sys4StartupSettings.Empty;
return new Sys4AssetCatalog(magic, title, packId, archives, slots, startupSettings);
}
/// <summary>Mount an append catalog by its native header selector. A later mount of the same

View File

@@ -0,0 +1,141 @@
using System.Buffers.Binary;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
namespace Age.Engine.Sys4;
/// <summary>
/// Ordered per-game key/value pairs stored after the base SYS4INI directory and VM metadata.
/// Unknown keys are retained so newer AGE profiles remain inspectable before they have consumers.
/// </summary>
public sealed class Sys4StartupSettings
{
private readonly IReadOnlyList<KeyValuePair<string, string>> _pairs;
private readonly IReadOnlyDictionary<string, string> _values;
public static Sys4StartupSettings Empty { get; } = new([]);
public IReadOnlyList<KeyValuePair<string, string>> Pairs => _pairs;
public int Count => _pairs.Count;
private Sys4StartupSettings(List<KeyValuePair<string, string>> pairs)
{
_pairs = new ReadOnlyCollection<KeyValuePair<string, string>>(pairs);
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in pairs)
values[pair.Key] = pair.Value;
_values = new ReadOnlyDictionary<string, string>(values);
}
public bool TryGetValue(string key, out string value)
=> _values.TryGetValue(key, out value!);
public string? GetValueOrDefault(string key)
=> _values.GetValueOrDefault(key);
internal static Sys4StartupSettings ParseTrailer(byte[] blob, ref int offset, string source)
{
if (offset == blob.Length) return Empty;
uint vmMetadataBytes = ReadU32(blob, ref offset, source, "VM metadata length");
if (vmMetadataBytes > blob.Length - offset)
throw new InvalidDataException(
$"{source}: VM metadata length {vmMetadataBytes} exceeds {blob.Length - offset} remaining bytes");
offset += checked((int)vmMetadataBytes);
if (offset == blob.Length) return Empty;
uint stringBytes = ReadU32(blob, ref offset, source, "settings string length");
if (stringBytes == 0)
{
if (offset != blob.Length)
throw new InvalidDataException($"{source}: zero-length settings block has trailing data");
return Empty;
}
if (offset > blob.Length - 4)
throw new InvalidDataException($"{source}: settings pair count is truncated");
if (stringBytes > blob.Length - offset - 4)
throw new InvalidDataException(
$"{source}: settings string length {stringBytes} exceeds the remaining block");
uint pairCount = ReadU32(blob, ref offset, source, "settings pair count");
if (pairCount > stringBytes / 2)
throw new InvalidDataException(
$"{source}: settings pair count {pairCount} cannot fit in {stringBytes} string bytes");
int end = checked(offset + (int)stringBytes);
var pairs = new List<KeyValuePair<string, string>>(checked((int)pairCount));
for (int i = 0; i < pairCount; i++)
{
string key = ReadCString(blob, ref offset, end, source, $"settings key {i}");
string value = ReadCString(blob, ref offset, end, source, $"settings value {i}");
pairs.Add(new KeyValuePair<string, string>(key, value));
}
if (offset != end)
throw new InvalidDataException(
$"{source}: settings pair count consumed {offset} bytes through the blob, expected {end}");
if (end != blob.Length)
throw new InvalidDataException($"{source}: settings block has {blob.Length - end} trailing bytes");
return new Sys4StartupSettings(pairs);
}
private static uint ReadU32(byte[] blob, ref int offset, string source, string field)
{
if (offset > blob.Length - 4)
throw new InvalidDataException($"{source}: {field} is truncated");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(offset, 4));
offset += 4;
return value;
}
private static string ReadCString(
byte[] blob, ref int offset, int end, string source, string field)
{
if (offset >= end)
throw new InvalidDataException($"{source}: {field} is missing");
int zero = blob.AsSpan(offset, end - offset).IndexOf((byte)0);
if (zero < 0)
throw new InvalidDataException($"{source}: {field} is not NUL-terminated");
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
string value = Encoding.GetEncoding(932).GetString(blob, offset, zero);
offset += zero + 1;
return value;
}
}
/// <summary>The validated per-game logical canvas selected before presentation allocation.</summary>
public readonly record struct Sys4LogicalCanvas(int Width, int Height)
{
public const int DefaultWidth = 640;
public const int DefaultHeight = 480;
public const int MaximumDimension = 16_384;
public const int MaximumPixels = 67_108_864;
public int PixelCount => checked(Width * Height);
public int RgbaByteCount => checked(PixelCount * 4);
public static Sys4LogicalCanvas FromSettings(Sys4StartupSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
int width = ReadDimension(settings, "SCREENX", DefaultWidth);
int height = ReadDimension(settings, "SCREENY", DefaultHeight);
if (width > MaximumDimension || height > MaximumDimension)
throw new InvalidDataException(
$"SYS4 logical canvas {width}x{height} exceeds maximum dimension {MaximumDimension}");
long pixels = (long)width * height;
if (pixels > MaximumPixels)
throw new InvalidDataException(
$"SYS4 logical canvas {width}x{height} exceeds maximum pixel count {MaximumPixels}");
return new Sys4LogicalCanvas(width, height);
}
private static int ReadDimension(
Sys4StartupSettings settings, string key, int fallback)
{
string? raw = settings.GetValueOrDefault(key);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
&& value > 0
? value
: fallback;
}
}

View File

@@ -38,9 +38,11 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly MovieSurfaceRegistry _movieSurfaces = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the engine's 800x600
// primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0.
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's
// logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0.
private readonly Dictionary<int, (int W, int H)> _slotDims = new();
private readonly int _screenWidth;
private readonly int _screenHeight;
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly AutoResetEvent _inputCallbackSignal = new(false);
private readonly Age.Engine.Hosting.FrameClock _clock;
@@ -99,14 +101,20 @@ public sealed class GodotAdvHost : IHost
public readonly List<(int Offset, string Text)> Captured = new();
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, GodotTimelineLog? timeline = null,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
GodotTimelineLog? timeline = null,
bool synchronizeExplicitPresentation = true)
{
_main = main; _res = res; _rootScene = scene; _clock = clock;
_locator = locator; _timeline = timeline;
_screenWidth = logicalCanvas.Width;
_screenHeight = logicalCanvas.Height;
_slotDims[0] = (_screenWidth, _screenHeight);
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
}
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
private string CurrentScene
@@ -170,7 +178,8 @@ public sealed class GodotAdvHost : IHost
lock (_textLock)
{
layout = new AdvTextLayoutSnapshot(
_currentAdvLayout, 800, 600, 0, 0, _advTextX, _advTextY, 800, 600);
_currentAdvLayout, _screenWidth, _screenHeight, 0, 0,
_advTextX, _advTextY, _screenWidth, _screenHeight);
delay = _messageGlyphDelayMilliseconds;
}
ShowText(new AdvLiveTextRun(
@@ -1276,7 +1285,8 @@ public sealed class GodotAdvHost : IHost
_surfaceImages.Remove(surfaceSlot);
_surfaceColorKeys.Remove(surfaceSlot);
}
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
// Movie surfaces inherit the selected game's primary size until a decoded frame supplies content.
_slotDims[surfaceSlot] = (_screenWidth, _screenHeight);
try
{
var movie = _res.ReadMovie(asset);

View File

@@ -14,16 +14,16 @@ using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
public partial class Main : Godot.Control
{
private const int ScreenWidth = 800;
private const int ScreenHeight = 600;
// Provisional approximation only: AGE asks GDI to synthesize LOGFONT weight 700, grid-fit a
// GGO_GRAY4 mask, and composites that mask itself. Godot instead applies FreeType embolden plus
// spacing. Do not retune these values from screenshots; replace this approximation from the decoded
// native glyph-mask contract. See docs/engine-re.md.
private const float NativeBoldEmbolden = 0.53f;
private const int NativeBoldGlyphSpacing = 1;
private int _screenWidth = Sys4LogicalCanvas.DefaultWidth;
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
private TextureRect _screenView = null!; // shows the composited screen backbuffer
private Image _screen = null!; // 800x600 immediate-mode canvas
private Image _screen = null!; // SYS4INI-sized immediate-mode canvas
private ImageTexture _screenTex = null!;
private GpuRetainedRenderer _gpuRenderer = null!;
private bool _useGpuBackend = true;
@@ -34,7 +34,7 @@ public partial class Main : Godot.Control
private int _waitIndicatorAssetId = -1;
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private readonly byte[] _screenPixels = new byte[ScreenWidth * ScreenHeight * 4];
private byte[] _screenPixels = [];
private Label _text = null!;
private Label _speaker = null!;
private readonly System.Collections.Generic.List<Label> _advTextLabels = new();
@@ -108,8 +108,24 @@ public partial class Main : Godot.Control
public override void _Ready()
{
// Screen backbuffer: one 800x600 canvas that draw-texture blits into, shown behind the dialogue.
_screen = Image.CreateEmpty(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8);
// Resolve the selected game's logical canvas before any presentation allocation. The same catalog
// instance is reused for scripts and assets later in startup.
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var logicalCanvas = catalog.LogicalCanvas;
_screenWidth = logicalCanvas.Width;
_screenHeight = logicalCanvas.Height;
_screenPixels = new byte[logicalCanvas.RgbaByteCount];
Window rootWindow = GetTree().Root;
rootWindow.ContentScaleMode = Window.ContentScaleModeEnum.Viewport;
rootWindow.ContentScaleAspect = Window.ContentScaleAspectEnum.Keep;
rootWindow.ContentScaleSize = new Vector2I(_screenWidth, _screenHeight);
if (rootWindow.Mode == Window.ModeEnum.Windowed)
rootWindow.Size = new Vector2I(_screenWidth, _screenHeight);
GD.Print($"[profile] SYS4INI logical canvas={_screenWidth}x{_screenHeight} " +
$"window={rootWindow.Size.X}x{rootWindow.Size.Y}");
// One logical canvas that draw-texture blits into, shown behind the dialogue.
_screen = Image.CreateEmpty(_screenWidth, _screenHeight, false, Image.Format.Rgba8);
_screenTex = ImageTexture.CreateFromImage(_screen);
_screenView = new TextureRect
{
@@ -123,7 +139,7 @@ public partial class Main : Godot.Control
_gpuRenderer = new GpuRetainedRenderer(this);
// Native ADV wait marker: a tiny independently animated atlas region. Keeping it separate from the
// 800x600 software backbuffer avoids recompositing the entire retained scene throughout static waits.
// logical software backbuffer avoids recompositing the entire retained scene throughout static waits.
_waitIndicator = new TextureRect
{
Visible = false,
@@ -264,7 +280,6 @@ public partial class Main : Godot.Control
if (_selftest) (script, provider) = BuildSelfTestScene(table);
else
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
sharedProfile.ConfigureCatalogUnlockSlots(
catalog.RawSlots.Count,
catalog.AppendPacks.Select(pair =>
@@ -288,7 +303,7 @@ public partial class Main : Godot.Control
? new ResourceMap(scripts.Catalog, trackedAssetStore)
: ResourceMap.Load();
_host = new GodotAdvHost(
this, resources, scene, _clock, _locator, _timeline,
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
synchronizeExplicitPresentation: !_selftest)
{
SleepScale = sleepScale,
@@ -805,10 +820,12 @@ public partial class Main : Godot.Control
private (int X, int Y) ToNativeScreen(Vector2 position)
{
Vector2 size = GetViewportRect().Size;
// Godot reports input in viewport coordinates after content scaling. This ratio is therefore
// normally identity, while remaining correct for any viewport expansion policy.
Vector2 size = GetViewport().GetVisibleRect().Size;
if (size.X <= 0 || size.Y <= 0) return (0, 0);
return ((int)System.Math.Floor(position.X * ScreenWidth / size.X),
(int)System.Math.Floor(position.Y * ScreenHeight / size.Y));
return ((int)System.Math.Floor(position.X * _screenWidth / size.X),
(int)System.Math.Floor(position.Y * _screenHeight / size.Y));
}
public void SetAgeCursor(byte[] rgba, int width, int height, int hotspotX, int hotspotY)
@@ -968,14 +985,14 @@ public partial class Main : Godot.Control
{
if (v.Blend != BlendKind.Opaque)
{
int width = v.W > 0 ? v.W : ScreenWidth;
int height = v.H > 0 ? v.H : ScreenHeight;
int width = v.W > 0 ? v.W : _screenWidth;
int height = v.H > 0 ? v.H : _screenHeight;
float fillOpacity = v.MultiplyTint
? opacity
: opacity * v.TintStrength / 255f;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, v.Tint, fillOpacity))
_perf?.RecordGpuLayer(width, height, affine, ScreenWidth, ScreenHeight,
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
}
else _perf?.RecordSkippedLayer();
@@ -997,7 +1014,7 @@ public partial class Main : Godot.Control
opacity, v.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? v.Handle, v.Blend);
if (drawn)
_perf?.RecordGpuLayer(v.W, v.H, affine, ScreenWidth, ScreenHeight,
_perf?.RecordGpuLayer(v.W, v.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, v.Blend);
}
}
@@ -1060,13 +1077,13 @@ public partial class Main : Godot.Control
_perf?.RecordSkippedLayer();
continue;
}
int width = source.W > 0 ? source.W : ScreenWidth;
int height = source.H > 0 ? source.H : ScreenHeight;
int width = source.W > 0 ? source.W : _screenWidth;
int height = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, source.Tint,
opacity * source.TintStrength / 255f))
{
_perf?.RecordGpuLayer(width, height, affine, ScreenWidth, ScreenHeight,
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
drawn++;
}
@@ -1090,7 +1107,7 @@ public partial class Main : Godot.Control
opacity, source.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? source.Handle, source.Blend))
{
_perf?.RecordGpuLayer(source.W, source.H, affine, ScreenWidth, ScreenHeight,
_perf?.RecordGpuLayer(source.W, source.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, source.Blend);
drawn++;
}
@@ -1125,7 +1142,7 @@ public partial class Main : Godot.Control
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
ref surfaceTextLabelIndex, decisions, false);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
@@ -1145,7 +1162,7 @@ public partial class Main : Godot.Control
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
_screen.SetData(_screenWidth, _screenHeight, false, Image.Format.Rgba8, _screenPixels);
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
@@ -1200,7 +1217,8 @@ public partial class Main : Godot.Control
// objects are render targets — still skipped (slice C).
if (v.Blend != Age.Engine.Model.BlendKind.Opaque)
{
int baseW = v.W > 0 ? v.W : 800, baseH = v.H > 0 ? v.H : 600;
int baseW = v.W > 0 ? v.W : _screenWidth;
int baseH = v.H > 0 ? v.H : _screenHeight;
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
// the tint-strength convention used by the existing effect objects.
float fillA = v.MultiplyTint ? opacity : opacity * strength;
@@ -1558,7 +1576,8 @@ public partial class Main : Godot.Control
_perf?.RecordSkippedLayer();
continue;
}
int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600;
int w = source.W > 0 ? source.W : _screenWidth;
int h = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
}
@@ -1671,9 +1690,9 @@ public partial class Main : Godot.Control
if (sw <= 0 || sh <= 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
_screenPixels, _screenWidth, _screenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
_perf?.RecordRaster(sw, sh, localToDest, ScreenWidth, ScreenHeight, dynamic, blend,
_perf?.RecordRaster(sw, sh, localToDest, _screenWidth, _screenHeight, dynamic, blend,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
@@ -1681,8 +1700,8 @@ public partial class Main : Godot.Control
{
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
_screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
_screenPixels, _screenWidth, _screenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, _screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
@@ -1694,7 +1713,7 @@ public partial class Main : Godot.Control
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screenPixels;
int dw = ScreenWidth, dh = ScreenHeight;
int dw = _screenWidth, dh = _screenHeight;
int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
int y0 = System.Math.Max(0, -dstY), y1 = System.Math.Min(h, dh - dstY);
if (x1 <= x0 || y1 <= y0) return;
@@ -1710,7 +1729,7 @@ public partial class Main : Godot.Control
}
_perf?.RecordFillLayer();
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
_screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
@@ -2133,18 +2152,28 @@ public partial class Main : Godot.Control
&& textEffectSmoke.GetThemeConstant("line_spacing")
== CalibratedLineSpacing(
8, 24, calibratedBold.GetHeight(24));
Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
&& _screen.GetWidth() == _screenWidth
&& _screen.GetHeight() == _screenHeight
&& _screenPixels.Length == checked(_screenWidth * _screenHeight * 4)
&& rootWindow.ContentScaleSize
== new Vector2I(_screenWidth, _screenHeight);
textEffectSmoke.QueueFree();
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
&& bgmReplacementCancelsFade && textEffectModesOk && fontCalibrationOk;
&& bgmReplacementCancelsFade && textEffectModesOk && fontCalibrationOk
&& logicalCanvasOk;
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; font-calibration=ok");
$"bgm-fade-replacement=ok; text-effect-modes=ok; font-calibration=ok; " +
$"logical-canvas={_screenWidth}x{_screenHeight}");
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}; font-calibration={fontCalibrationOk}");
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight})");
GetTree().Quit(ok ? 0 : 1);
}

View File

@@ -5,8 +5,8 @@ config/name="Open Maid Engine"
run/main_scene="res://Main.tscn"
[display]
window/size/viewport_width=800
window/size/viewport_height=600
window/size/viewport_width=640
window/size/viewport_height=480
[dotnet]
project/assembly_name="Himegari"