Apply SYS4INI logical resolution
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user