From 9ce72925704dbc51d0bcce83827959cf3cad4c47 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 09:12:10 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round=202=20?= =?UTF-8?q?=E2=80=94=20character-select=20screen=20media=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the LA8 character-select screen's root background RenderSurface (0x06007576, LayoutDesc 0x21000004 element 0x1000039A) is PFID_CUSTOM_RAW_JPEG — a complete JFIF byte stream (confirmed live: 414,230 bytes, FFD8...FFD9, Width=0/Height=0 on disk) that SurfaceDecoder.DecodeRenderSurface had no case for, so it fell through the switch's `_ => DecodedTexture.Magenta` default arm with nothing logged. Retail's RenderSurface::CreateFromSourceData (named-retail decomp @0x004440a0) hands this exact byte stream to the Intel JPEG Library (`_ijlInit`/`_ijlRead`/`_ijlFree`) at runtime and reads the real pixel dimensions from the JPEG's own SOF header rather than this RenderSurface's Width/Height fields, which are legitimately 0 for this format — the same reason the decoder's generic non-positive-Width/Height guard was also wrong to apply here. A per-id media sweep of the installed DAT (new EveryDeclaredMediaId_ ResolvesToADecodableTexture test) showed this was the ONLY unresolved id among the screen's 25 distinct media ids — the listbox (0x1000039D) and every button face resolve fine. The listbox interior and the ENTER button's circular fill are both transparent regions layered on top of the root, so the one broken root background bled through everywhere nothing opaque covered it, producing all three symptoms (full-screen background, listbox interior, ENTER circle) from one cause. Fix: SurfaceDecoder now special-cases PFID_CUSTOM_RAW_JPEG before the Width/Height guard and decodes it with StbImageSharp (dual Unlicense/MIT, pure managed, no native dependency — works on the Linux headless/graphical targets Slice K/L commit to). JPEG is ITU T.81-standardized, so any conforming decoder reproduces the pixels IJL would; round-tripped a synthetic fixture through the real decode path to confirm. Verified against the live DAT: 0x06007576 now decodes to 800x600, exactly the screen's LayoutDesc-authored size. Guard: per claude-memory/feedback_ui_resolve_zero_magenta.md, an unresolved id reaching the draw path should be loud. That memory's existing guard ("guard on the id, not the handle") only covers a DIFFERENT trap — a zero/absent id — and could not have caught this one, which has a real, non-zero, DAT-resolved id. No guard existed for "id resolves but can't decode" or "id doesn't exist in either dat" before this change, so both were silent. SurfaceDecoder now logs once per surface id on every magenta-return path (null data, JPEG decode failure, unsupported format, no-palette paletted format, decode exception); TextureCache.GetOrUploadRenderSurface logs once per id when a RenderSurface isn't found in Portal or HighRes at all. Tests: CharacterManagementLiveDatTests.EveryDeclaredMediaId_ ResolvesToADecodableTexture (installed-DAT gate, ACDREAM_PROBE_LIVE_MOUNT=1) sweeps every StateMedia id in the char-select root + listbox row template and asserts none decode to the magenta placeholder — this class of gap now fails the gate instead of shipping silently. SurfaceDecoderTests adds PFID_CUSTOM_RAW_JPEG coverage (real decode via a synthetic from-scratch JPEG fixture — not retail art, generated with StbImageWriteSharp and round-tripped before being pasted in as a literal; corrupt-data and null-SourceData magenta paths) plus PFID_P8/PFID_INDEX16 no-palette cases that now flow through the same logged path. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/TextureCache.cs | 13 ++ src/AcDream.Core/AcDream.Core.csproj | 10 ++ src/AcDream.Core/Textures/SurfaceDecoder.cs | 89 ++++++++++++- .../Layout/CharacterManagementLiveDatTests.cs | 123 ++++++++++++++++++ .../Textures/SurfaceDecoderTests.cs | 123 ++++++++++++++++++ 5 files changed, 353 insertions(+), 5 deletions(-) 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); + } }