From b0971f20478d95aa274eee672a45d485f0eaa771 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 13:14:02 -0400 Subject: [PATCH] Decode color drag-pan cursor --- docs/asset-resolution-re.md | 14 ++++++++ docs/engine-re.md | 7 ++-- docs/phase-b-framework.md | 7 ++++ engine/Age.Engine.Tests/CurDecoderTests.cs | 29 +++++++++++++++ engine/Age.Engine/Sys4/CurDecoder.cs | 41 +++++++++++++++------- 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 8bec336..8091517 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -282,6 +282,20 @@ A windowed SC0000 run with `extracted/` moved aside crossed both voice sites and recording BGM005 plus `E0808.WAV` load/start/preload on channels 0/0/4 with no Godot OGG/WAV decode errors. Channel, loop, interruption, timing, fade, load/start, and release behavior is unchanged. +### Windows cursor payloads (implemented 2026-07-21) + +Cursor artwork is catalog-backed, not embedded in AGE.EXE. The eight ADV edge cursors at raw ids +`0x3318..0x331f` are 326-byte, uncompressed 1-bpp 32x32 Windows CUR payloads. FIELD's drag-pan callback +instead loads raw id `0x32ce`, catalog entry `CURSOR09.CUR` at `DATA1.ALF:611394962+766`. Its CUR directory +and DIB header specify one 32x32 image, hotspot `(16,14)`, 4 bits per pixel, no compression, and a default +16-entry BGRA palette. The 744-byte image consists of the 40-byte BITMAPINFOHEADER, 64-byte palette, +512-byte color/XOR bitmap, and 128-byte 1-bpp AND mask. The original runtime decoder accepted only 1-bpp +XOR data and therefore rejected this valid color cursor. `CurDecoder` now accepts the installed +uncompressed 1-bpp and 4-bpp variants, derives each palette size from the DIB metadata, calculates separate +DWORD-aligned XOR and AND strides, expands packed high-nibble-first 4-bpp indices through the BGRA palette, +and retains the separate 1-bpp AND transparency mask. The installed-asset regression checks CURSOR09's +dimensions, hotspot, transparent background, grayscale, and a non-gray palette pixel. + ### SC0000 movie payload and presentation (2026-07-11) The scene-local implementation was first validated at SC0000 `0x236@0x13c8`. Resource `0x33` resolves through the diff --git a/docs/engine-re.md b/docs/engine-re.md index cd8fee2..9a173a2 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1807,9 +1807,12 @@ Godot supplies virtual-screen pointer coordinates, left/right mouse bits (`0x1`/ directional input indices (down/left/up/right = 0/1/2/3; accept/cancel = 4/5). The common index-10 release callback is queued on action release. -Raw ids `0x3318..0x331f` resolve through SYS4INI to the game's 32x32 monochrome Windows `.CUR` assets. The +Raw ids `0x3318..0x331f` resolve through SYS4INI to eight 32x32 monochrome Windows `.CUR` assets. The runtime decodes their DIB XOR/AND masks and hotspots to RGBA textures and installs them through Godot's -custom-cursor API. This is asset-backed behavior; no replacement cursor art is authored by the port. +custom-cursor API. FIELD separately selects raw id `0x32ce` (`CURSOR09.CUR`) while drag-panning; that asset +is an uncompressed 4-bpp, 16-color 32x32 cursor with hotspot `(16,14)`, not an EXE-embedded cursor. The +port decoder accepts both installed paletted formats, using their independent DWORD-aligned XOR/color and +1-bpp AND-mask strides. No replacement cursor art is authored by the port. Every ordinary ADV script gates the handler-A call to HIDEWIN on `G[0x62425]`. No script writes that global, and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native scheduler-owned diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 374a929..6c99ef4 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -416,6 +416,13 @@ range-isolation/animation, VM dispatch, full engine tests, and the threaded Godo acceptance confirms that DEBUGMAP now displays the dungeon map correctly; the earlier full-sheet overlay is gone and the field presentation remains operational after the camera-transform correction. +Drag-panning exposes one bounded presentation follow-up. FIELD op `0x86` at `0x2e4d` selects raw cursor +`0x32ce` on pan entry and op `0x87` clears it on exit. That catalog entry is the installed 4-bpp color +`CURSOR09.CUR`, whereas the port originally decoded only the eight 1-bpp ADV cursors. This was a +decoder-format gap, not missing or EXE-embedded artwork. The CUR decoder now handles both installed 1-bpp +and 4-bpp formats with independent XOR/AND strides; archive-backed pixel/hotspot tests pass. Manual +acceptance confirms the gripped cursor now displays correctly during drag-panning. + ## Later Phase B breadth Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: diff --git a/engine/Age.Engine.Tests/CurDecoderTests.cs b/engine/Age.Engine.Tests/CurDecoderTests.cs index 0fc6abb..ef8f37a 100644 --- a/engine/Age.Engine.Tests/CurDecoderTests.cs +++ b/engine/Age.Engine.Tests/CurDecoderTests.cs @@ -22,7 +22,36 @@ public class CurDecoderTests Assert.Contains(cursor.Image.Pixels.Where((_, i) => i % 4 == 3), alpha => alpha == 255); } + [Fact] + public void HimegariPanCursor_DecodesFourBitPixelsAndHotspot() + { + var resources = ResourceMap.Load(); + var entry = resources.ResolveCursor(0x32ce); + + Assert.NotNull(entry); + Assert.Equal("CURSOR09.CUR", entry!.Name); + var cursor = resources.DecodeCursor(entry); + + Assert.Equal(32, cursor.Image.Width); + Assert.Equal(32, cursor.Image.Height); + Assert.Equal(16, cursor.HotspotX); + Assert.Equal(14, cursor.HotspotY); + AssertPixel(cursor.Image, 0, 0, 0, 0, 0, 0); + AssertPixel(cursor.Image, 10, 10, 128, 128, 128, 255); + AssertPixel(cursor.Image, 12, 24, 0, 0, 128, 255); + } + [Fact] public void TruncatedCursor_IsRejected() => Assert.Throws(() => CurDecoder.Decode(new byte[21], "bad.cur")); + + private static void AssertPixel(RgbaImage image, int x, int y, + byte r, byte g, byte b, byte a) + { + int offset = (y * image.Width + x) * 4; + Assert.Equal(r, image.Pixels[offset]); + Assert.Equal(g, image.Pixels[offset + 1]); + Assert.Equal(b, image.Pixels[offset + 2]); + Assert.Equal(a, image.Pixels[offset + 3]); + } } diff --git a/engine/Age.Engine/Sys4/CurDecoder.cs b/engine/Age.Engine/Sys4/CurDecoder.cs index a10c797..71b9c71 100644 --- a/engine/Age.Engine/Sys4/CurDecoder.cs +++ b/engine/Age.Engine/Sys4/CurDecoder.cs @@ -5,7 +5,7 @@ namespace Age.Engine.Sys4; /// A decoded Windows cursor image and its native hotspot. public sealed record CursorImage(RgbaImage Image, int HotspotX, int HotspotY); -/// Decoder for Himegari's monochrome Windows .CUR resources. +/// Decoder for Himegari's paletted Windows .CUR resources. public static class CurDecoder { public static CursorImage Decode(ReadOnlySpan file, string name = "CUR") @@ -31,16 +31,28 @@ public static class CurDecoder int bitsPerPixel = U16(file, imageOffset + 14); int compression = I32(file, imageOffset + 16); if (dibWidth != width || System.Math.Abs(dibHeight) != height * 2 || planes != 1 - || bitsPerPixel != 1 || compression != 0) - throw new InvalidDataException($"{name}: expected an uncompressed 1-bit {width}x{height} cursor"); + || bitsPerPixel is not (1 or 4) || compression != 0) + throw new InvalidDataException( + $"{name}: expected an uncompressed 1-bit or 4-bit {width}x{height} cursor"); int paletteOffset = checked(imageOffset + headerSize); - if (paletteOffset > file.Length - 8) throw new InvalidDataException($"{name}: palette is truncated"); - int xorStride = checked(((width + 31) / 32) * 4); - int maskBytes = checked(xorStride * height); - int xorOffset = checked(paletteOffset + 8); - int andOffset = checked(xorOffset + maskBytes); - if (andOffset > file.Length - maskBytes) throw new InvalidDataException($"{name}: cursor masks are truncated"); + int colorsUsed = I32(file, imageOffset + 32); + int maximumPaletteEntries = 1 << bitsPerPixel; + int paletteEntries = colorsUsed == 0 ? maximumPaletteEntries : colorsUsed; + if (paletteEntries <= 0 || paletteEntries > maximumPaletteEntries) + throw new InvalidDataException($"{name}: cursor palette size is invalid"); + int paletteBytes = checked(paletteEntries * 4); + if (paletteOffset > file.Length - paletteBytes) + throw new InvalidDataException($"{name}: palette is truncated"); + + int xorStride = checked(((width * bitsPerPixel + 31) / 32) * 4); + int andStride = checked(((width + 31) / 32) * 4); + int xorBytes = checked(xorStride * height); + int andBytes = checked(andStride * height); + int xorOffset = checked(paletteOffset + paletteBytes); + int andOffset = checked(xorOffset + xorBytes); + if (andOffset > file.Length - andBytes) + throw new InvalidDataException($"{name}: cursor masks are truncated"); var rgba = new byte[checked(width * height * 4)]; bool bottomUp = dibHeight > 0; @@ -48,12 +60,15 @@ public static class CurDecoder { int sourceY = bottomUp ? height - 1 - y : y; int xorRow = xorOffset + sourceY * xorStride; - int andRow = andOffset + sourceY * xorStride; + int andRow = andOffset + sourceY * andStride; for (int x = 0; x < width; x++) { - int shift = 7 - (x & 7); - int paletteIndex = (file[xorRow + (x >> 3)] >> shift) & 1; - bool transparent = ((file[andRow + (x >> 3)] >> shift) & 1) != 0 && paletteIndex == 0; + int paletteIndex = bitsPerPixel == 1 + ? (file[xorRow + (x >> 3)] >> (7 - (x & 7))) & 1 + : (file[xorRow + (x >> 1)] >> ((1 - (x & 1)) * 4)) & 0xf; + int maskShift = 7 - (x & 7); + bool transparent = ((file[andRow + (x >> 3)] >> maskShift) & 1) != 0 + && paletteIndex == 0; int palette = paletteOffset + paletteIndex * 4; int dst = (y * width + x) * 4; rgba[dst] = file[palette + 2];