diff --git a/docs/engine-re.md b/docs/engine-re.md index 273ae18..7ea357c 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index e5485ce..6e6c10a 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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 diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 992178b..9f1d033 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -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 | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 6bdbbe8..1cfe50b 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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. diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index e629a3b..cd46d60 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -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. diff --git a/engine/Age.Engine.Tests/RetainedSurfaceRasterizerTests.cs b/engine/Age.Engine.Tests/RetainedSurfaceRasterizerTests.cs index 48e508f..050c498 100644 --- a/engine/Age.Engine.Tests/RetainedSurfaceRasterizerTests.cs +++ b/engine/Age.Engine.Tests/RetainedSurfaceRasterizerTests.cs @@ -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]); + } } diff --git a/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs b/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs new file mode 100644 index 0000000..57d8480 --- /dev/null +++ b/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs @@ -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(() => + 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(() => 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(() => + Sys4AssetCatalog.Parse(WrapCatalog(expanded), "malformed-settings")); + + byte[] missingNul = BuildExpanded([("SCREENX", "1024")]); + missingNul[^1] = (byte)'x'; + Assert.Throws(() => + Sys4AssetCatalog.Parse(WrapCatalog(missingNul), "unterminated-settings")); + + byte[] countMismatch = BuildExpanded([("SCREENX", "1024")]); + BinaryPrimitives.WriteUInt32LittleEndian(countMismatch.AsSpan(trailer + 8, 4), 2); + Assert.Throws(() => + 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(); + 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(); + 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(); + 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 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 target, string value, Encoding encoding) + { + target.AddRange(encoding.GetBytes(value)); + target.Add(0); + } + + private static void AddU32(List 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)); +} diff --git a/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs b/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs index fe2fba1..5242579 100644 --- a/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs +++ b/engine/Age.Engine/Sys4/RetainedSurfaceRasterizer.cs @@ -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, diff --git a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs index 73bf41b..e49d130 100644 --- a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs +++ b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs @@ -40,9 +40,12 @@ public sealed class Sys4AssetCatalog public IReadOnlyList RawSlots { get; } public IReadOnlyList Files { get; } public IReadOnlyDictionary AppendPacks => _appendPacks; + public Sys4StartupSettings StartupSettings { get; } + public Sys4LogicalCanvas LogicalCanvas { get; } private Sys4AssetCatalog(string magic, string title, int packId, - List archives, List rawSlots) + List archives, List 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); } /// Mount an append catalog by its native header selector. A later mount of the same diff --git a/engine/Age.Engine/Sys4/Sys4StartupSettings.cs b/engine/Age.Engine/Sys4/Sys4StartupSettings.cs new file mode 100644 index 0000000..a1275b8 --- /dev/null +++ b/engine/Age.Engine/Sys4/Sys4StartupSettings.cs @@ -0,0 +1,141 @@ +using System.Buffers.Binary; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace Age.Engine.Sys4; + +/// +/// 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. +/// +public sealed class Sys4StartupSettings +{ + private readonly IReadOnlyList> _pairs; + private readonly IReadOnlyDictionary _values; + + public static Sys4StartupSettings Empty { get; } = new([]); + + public IReadOnlyList> Pairs => _pairs; + public int Count => _pairs.Count; + + private Sys4StartupSettings(List> pairs) + { + _pairs = new ReadOnlyCollection>(pairs); + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + values[pair.Key] = pair.Value; + _values = new ReadOnlyDictionary(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>(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(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; + } +} + +/// The validated per-game logical canvas selected before presentation allocation. +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; + } +} diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index e17ce53..e70f6fc 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -38,9 +38,11 @@ public sealed class GodotAdvHost : IHost private readonly Dictionary _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 _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 _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); diff --git a/godot/Main.cs b/godot/Main.cs index 943c2bb..6ee20a0 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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