Apply SYS4INI logical resolution
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
186
engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs
Normal file
186
engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
141
engine/Age.Engine/Sys4/Sys4StartupSettings.cs
Normal file
141
engine/Age.Engine/Sys4/Sys4StartupSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user