diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index f3e18c42..4a3ead6f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -40,6 +40,13 @@ public sealed class TextureCache // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. private readonly Dictionary _renderSurfaceGpuTextures = new(); + // Campaign LA gate round 2: the OTHER magenta cause GetOrUploadRenderSurface can + // hit — a non-zero id that simply isn't a RenderSurface in either dat (as opposed + // to SurfaceDecoder's own logged causes for an id that DOES resolve but can't + // decode). Same "loud, not silent" treatment, same log-once-per-id dedup pattern + // already used by EquippedChildRenderController._loggedUnaddressableParentRefusals. + private readonly HashSet _loggedMissingRenderSurfaceIds = new(); + // Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper // (used by IconComposer for composited item icons). These are NOT stored in any // of the keyed caches above, so Dispose must sweep this list to avoid leaking @@ -231,6 +238,12 @@ public sealed class TextureCache } else { + if (_loggedMissingRenderSurfaceIds.Add(renderSurfaceId)) + { + Console.WriteLine( + $"[UI] TextureCache: RenderSurface 0x{renderSurfaceId:X8} was not " + + "found in Portal or HighRes — drawing the 1x1 magenta placeholder."); + } decoded = DecodedTexture.Magenta; } diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 966b25e1..7c0d4903 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -15,6 +15,16 @@ + + diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 6cbd108f..33a1fefb 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -1,8 +1,10 @@ +using System.Collections.Concurrent; using AcDream.Core.Rendering.Wb; using BCnEncoder.Decoder; using BCnEncoder.Shared; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; +using StbImageSharp; namespace AcDream.Core.Textures; @@ -10,6 +12,34 @@ public static class SurfaceDecoder { private static readonly BcDecoder BcDecoder = new(); + /// + /// Campaign LA gate round 2 (character-select screen): a real, DAT-resolved, + /// non-zero-id RenderSurface can still hit the magenta fallback below (unsupported + /// PixelFormat, a paletted format with no palette, or corrupt/undersized + /// SourceData). That is a DIFFERENT trap than the zero-id footgun documented in + /// claude-memory/feedback_ui_resolve_zero_magenta.md ("guard on the id, not + /// the handle") — this one has a real id and a real handle, so that guard cannot + /// catch it. Both traps produce the identical silent 1x1 magenta texture, so this + /// one needs the same "loud, not silent" treatment: log once per surface id so an + /// undecodable asset fails LOUD in diagnostics instead of shipping as a silent + /// magenta wash (this is exactly how LA8's character-select background, + /// RenderSurface 0x06007576/PFID_CUSTOM_RAW_JPEG, went unnoticed — nothing logged + /// when its decode fell through to the unsupported-format arm). + /// + private static readonly ConcurrentDictionary LoggedMagentaIds = new(); + + private static DecodedTexture LogMagentaOnce(RenderSurface rs, string reason) + { + if (LoggedMagentaIds.TryAdd(rs.Id, 0)) + { + Console.WriteLine( + $"[UI] SurfaceDecoder: RenderSurface 0x{rs.Id:X8} decoded to the 1x1 " + + $"magenta placeholder ({reason}; format={rs.Format} " + + $"{rs.Width}x{rs.Height})."); + } + return DecodedTexture.Magenta; + } + /// /// Decode a RenderSurface's pixel bytes into RGBA8. Returns /// for unsupported formats, null data, or corrupt sizing. This overload does NOT @@ -31,8 +61,35 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) { - if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) - return DecodedTexture.Magenta; + if (rs.SourceData is null) + return LogMagentaOnce(rs, "null SourceData"); + + // PFID_CUSTOM_RAW_JPEG carries a complete JFIF-encoded image verbatim in + // SourceData. Retail's RenderSurface::CreateFromSourceData (named-retail + // decomp @0x004440a0) hands this exact byte stream to the Intel JPEG Library + // (`_ijlInit`/`_ijlRead`/`_ijlFree`) at RUNTIME, and the real pixel dimensions + // come from the JPEG's own SOF header — NOT from this RenderSurface's + // Width/Height fields, which are legitimately 0 on disk for this format + // (confirmed against the installed DAT: 0x06007576, the LA8 character-select + // screen's root background, carries Width=0/Height=0 with a 414,230-byte + // FFD8...FFD9 JFIF stream that decodes to 800x600 — exactly the screen's + // LayoutDesc-authored size). Handle it before the generic Width/Height guard + // below, which does not apply to this format and previously made every + // PFID_CUSTOM_RAW_JPEG surface fall straight to magenta. + if (rs.Format == PixelFormat.PFID_CUSTOM_RAW_JPEG) + { + try + { + return DecodeCustomRawJpeg(rs); + } + catch (Exception ex) + { + return LogMagentaOnce(rs, $"JPEG decode failed: {ex.Message}"); + } + } + + if (rs.Width <= 0 || rs.Height <= 0) + return LogMagentaOnce(rs, "non-positive Width/Height"); try { @@ -46,18 +103,40 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), + PixelFormat.PFID_P8 => LogMagentaOnce(rs, "PFID_P8 with no palette"), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), + PixelFormat.PFID_INDEX16 => LogMagentaOnce(rs, "PFID_INDEX16 with no palette"), PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs), PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs), - _ => DecodedTexture.Magenta, + _ => LogMagentaOnce(rs, $"unsupported PixelFormat {rs.Format}"), }; } - catch + catch (Exception ex) { - return DecodedTexture.Magenta; + return LogMagentaOnce(rs, $"decode threw: {ex.Message}"); } } + /// + /// Decode PFID_CUSTOM_RAW_JPEG: see the doc comment on the + /// branch in + /// for the + /// retail mechanism this replaces. JPEG is a standardized (ITU T.81) format, so any + /// conforming decoder reproduces the same pixels the Intel JPEG Library would. + /// StbImageSharp (dual Unlicense/MIT, pure managed, no native dependency) is + /// acdream's decoder so the same code path works on the Linux headless/graphical + /// targets Slice K/L commit to. Throws on any failure; the caller converts that + /// into the logged magenta placeholder — this method never returns Magenta itself. + /// + private static DecodedTexture DecodeCustomRawJpeg(RenderSurface rs) + { + ImageResult image = ImageResult.FromMemory(rs.SourceData!, ColorComponents.RedGreenBlueAlpha); + if (image.Width <= 0 || image.Height <= 0) + throw new InvalidDataException( + $"JPEG surface 0x{rs.Id:X8} decoded to {image.Width}x{image.Height}."); + return new DecodedTexture(image.Data, image.Width, image.Height); + } + private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap) { int expectedBytes = rs.Width * rs.Height * 2; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 88ceb7b0..18266e64 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -2,8 +2,11 @@ using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; +using AcDream.Core.Textures; using DatReaderWriter; using DatReaderWriter.Options; +using Palette = DatReaderWriter.DBObjs.Palette; +using RenderSurface = DatReaderWriter.DBObjs.RenderSurface; using StringTable = DatReaderWriter.DBObjs.StringTable; namespace AcDream.App.Tests.UI.Layout; @@ -122,6 +125,126 @@ public sealed class CharacterManagementLiveDatTests Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables); } + /// + /// Campaign LA gate round 2 regression gate: EVERY non-zero StateMedia image id + /// declared anywhere in the char-select screen's imported element tree (root + + /// descendants + the listbox row template) must resolve to a RenderSurface in + /// Portal/HighRes AND decode to something other than the 1x1 magenta placeholder. + /// This is the class of gap that shipped LA8's full-screen background, listbox + /// interior, and ENTER circular-fill magenta defect: root background 0x06007576 + /// is PFID_CUSTOM_RAW_JPEG (a verbatim JFIF stream — see + /// 's PFID_CUSTOM_RAW_JPEG + /// branch for the retail mechanism), which the decoder previously had no case for + /// and silently fell through to magenta. The listbox (0x1000039D) itself carries + /// no own background media — it is a transparent container, so the fix for the + /// ONE root id also cleared the listbox-interior and ENTER-circle symptoms (both + /// were the broken root bleeding through transparent regions on top of it), which + /// this test's per-id sweep proves by finding no OTHER magenta id. + /// + [InstalledDatFact] + public void EveryDeclaredMediaId_ResolvesToADecodableTexture() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + + uint layoutDid = RetailDataIdResolver.Resolve( + dats, + CharacterManagementUiController.RootEnum, + 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, + layoutDid, + CharacterManagementUiController.RootElementId)); + + var ids = new SortedDictionary(); + CollectMediaIds(rootInfo, "root", ids); + + // Also walk the listbox's row template — it is imported separately by + // AddItemFromTemplateList/TemplateResolver, not as a root descendant. + ElementInfo? listInfo = FindById(rootInfo, CharacterManagementUiController.ListElementId); + Assert.NotNull(listInfo); + Assert.NotEmpty(listInfo!.TemplateList); + foreach (var entry in listInfo.TemplateList) + { + ElementInfo? rowInfo = LayoutImporter.ImportInfos( + dats, entry.TemplateLayoutId, entry.TemplateElementId); + Assert.NotNull(rowInfo); + CollectMediaIds(rowInfo!, "row-template", ids); + } + + Console.WriteLine($"[LA8-DIAG] layout=0x{layoutDid:X8} distinct media ids={ids.Count}"); + Assert.NotEmpty(ids); + // The known root-background id must actually be present in this sweep — + // otherwise the assertions below would vacuously pass without ever having + // exercised the surface that caused the defect. + Assert.Contains(0x06007576u, ids.Keys); + + var unresolved = new List(); + foreach (var (id, where) in ids) + { + bool found = dats.Portal.TryGet(id, out RenderSurface? rs) + || dats.HighRes.TryGet(id, out rs); + if (!found) + { + Console.WriteLine($"[LA8-DIAG] 0x{id:X8} ({where}): NOT FOUND in Portal or HighRes"); + unresolved.Add($"0x{id:X8} ({where}): missing RenderSurface"); + continue; + } + + Palette? palette = rs!.DefaultPaletteId != 0 + ? dats.Get(rs.DefaultPaletteId) + : null; + DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); + bool magenta = decoded.Width == 1 && decoded.Height == 1 + && decoded.Rgba8 is [0xFF, 0x00, 0xFF, 0xFF]; + Console.WriteLine( + $"[LA8-DIAG] 0x{id:X8} ({where}): format={rs.Format} " + + $"{rs.Width}x{rs.Height} defaultPalette=0x{rs.DefaultPaletteId:X8} " + + $"paletteLoaded={(palette is not null)} decoded={decoded.Width}x{decoded.Height} " + + $"magenta={magenta}"); + if (magenta) + unresolved.Add( + $"0x{id:X8} ({where}): format={rs.Format} defaultPalette=0x{rs.DefaultPaletteId:X8} " + + $"paletteLoaded={(palette is not null)}"); + } + + Assert.True( + unresolved.Count == 0, + "Media ids that resolved to the 1x1 magenta placeholder:\n" + + string.Join('\n', unresolved)); + } + + private static ElementInfo? FindById(ElementInfo info, uint id) + { + if (info.Id == id) return info; + foreach (ElementInfo child in info.Children) + { + ElementInfo? found = FindById(child, id); + if (found is not null) return found; + } + return null; + } + + private static void CollectMediaIds(ElementInfo info, string where, SortedDictionary ids) + { + foreach (var (stateName, media) in info.StateMedia) + { + if (media.File == 0) continue; + string label = $"{where} elem=0x{info.Id:X8} type={info.Type} state='{stateName}'"; + if (!ids.ContainsKey(media.File)) + ids[media.File] = label; + else + ids[media.File] += " | " + label; + } + foreach (ElementInfo child in info.Children) + CollectMediaIds(child, where, ids); + } + private static ImportedLayout BuildSelected( IDatReaderWriter dats, uint layoutDid, diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index e50ff372..4ef5949d 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -468,4 +468,127 @@ public class SurfaceDecoderTests Assert.Same(DecodedTexture.Magenta, decoded); } + + // ---- PFID_CUSTOM_RAW_JPEG tests (Campaign LA gate round 2) --------------- + // + // TinyJpeg8x8 is a synthetic, from-scratch-generated 8x8 JFIF image (top-left + // 4x4 quadrant ~RGB(200,30,40), bottom-right 4x4 quadrant ~RGB(20,40,220)) — + // NOT extracted from any retail asset. It exists purely so these tests exercise + // the REAL JPEG codepath end-to-end without embedding copyrighted game art in + // the repo. Generated once with StbImageWriteSharp and round-tripped through + // StbImageSharp to confirm fidelity before being pasted in as a literal. + + private static readonly byte[] TinyJpeg8x8 = + [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x84, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, + 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, + 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, + 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0C, 0x0F, + 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0x01, 0x03, 0x04, 0x04, 0x05, 0x04, 0x05, + 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, + 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x01, + 0xA2, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x10, 0x00, + 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, + 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, + 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, + 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, + 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, + 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x11, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, + 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, + 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, + 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, + 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, + 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, + 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, + 0x37, 0x59, 0xD6, 0x7F, 0xB5, 0xFC, 0x9F, 0xDC, 0xF9, 0x5E, 0x5E, 0x7F, 0x8B, 0x76, 0x73, 0x8F, + 0x6F, 0x6A, 0xCD, 0xA2, 0x8A, 0xFE, 0xE5, 0xCB, 0x32, 0xCC, 0x26, 0x4F, 0x84, 0x86, 0x07, 0x03, + 0x0E, 0x4A, 0x50, 0xBD, 0x95, 0xDB, 0xB5, 0xDB, 0x6F, 0x56, 0xDB, 0xDD, 0xB7, 0xAB, 0x3E, 0x3F, + 0x31, 0xCC, 0x71, 0x59, 0xB6, 0x2A, 0x78, 0xDC, 0x6C, 0xF9, 0xAA, 0x4A, 0xD7, 0x76, 0x4A, 0xF6, + 0x49, 0x2D, 0x12, 0x4B, 0x64, 0xBA, 0x1F, 0xFF, 0xD9, + ]; + + [Fact] + public void Decode_CustomRawJpeg_DecodesRealPixels() + { + // Mirrors the real dat encoding for this format: RenderSurface.Width/Height + // are 0 (confirmed against the installed DAT's LA8 character-select + // background, 0x06007576 — see CharacterManagementLiveDatTests). Dimensions + // and pixels must come from the JPEG's own SOF header instead. + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = TinyJpeg8x8, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.NotSame(DecodedTexture.Magenta, decoded); + Assert.Equal(8, decoded.Width); + Assert.Equal(8, decoded.Height); + Assert.Equal(8 * 8 * 4, decoded.Rgba8.Length); + + // Top-left quadrant was authored ~RGB(200,30,40); bottom-right ~RGB(20,40,220). + // JPEG is lossy, so assert within a generous tolerance rather than exact bytes. + int topLeft = (1 * decoded.Width + 1) * 4; + Assert.InRange(decoded.Rgba8[topLeft + 0], 170, 230); // R + Assert.InRange(decoded.Rgba8[topLeft + 2], 10, 70); // B + Assert.Equal(0xFF, decoded.Rgba8[topLeft + 3]); // JPEG has no alpha channel + + int bottomRight = (6 * decoded.Width + 6) * 4; + Assert.InRange(decoded.Rgba8[bottomRight + 0], 0, 60); // R + Assert.InRange(decoded.Rgba8[bottomRight + 2], 190, 255); // B + Assert.Equal(0xFF, decoded.Rgba8[bottomRight + 3]); + } + + [Fact] + public void Decode_CustomRawJpeg_CorruptData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = [0x01, 0x02, 0x03, 0x04], // not a JPEG stream at all + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_CustomRawJpeg_NullSourceData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = null!, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } }