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; 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 /// support PFID_INDEX16 — use /// when a palette is available. /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs) => DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false); /// /// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support. /// When is non-null and the format is PFID_INDEX16, each /// 16-bit value in SourceData is treated as an index into . /// When is true on an indexed surface, palette indices /// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention). /// When is true, A8/CUSTOM_LSCAPE_ALPHA surfaces /// replicate the byte into all four channels (R=G=B=A=val, for terrain alpha masks /// and additive surfaces). When false, R=G=B=255, A=val (WB FillA8 semantics). /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) { 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 { return rs.Format switch { PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs), PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs), PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap), 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), _ => LogMagentaOnce(rs, $"unsupported PixelFormat {rs.Format}"), }; } catch (Exception ex) { 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; if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0) return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); return new DecodedTexture(rgba, rs.Width, rs.Height); } /// /// Build a 1x1 RGBA8 texture from a single modulated /// by a surface translucency value. Used for Surface.Type.HasFlag(Base1Solid) /// surfaces that carry a color value instead of a texture chain. /// /// AC's convention: 0.0 is fully opaque, 1.0 is /// fully transparent. A surface with Translucency=1.0 should render invisibly, /// which the mesh shader's alpha discard (alpha < 0.5) will honor. /// public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) { // Malformed Base1Solid (or OrigTextureId==0) surface with no color value: // signal undecodable (Magenta) instead of NRE. This method is called // directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's // try/catch, so it must be null-safe itself. if (color is null) return DecodedTexture.Magenta; float opacity = Math.Clamp(1f - translucency, 0f, 1f); byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f); return new DecodedTexture( Rgba8: [color.Red, color.Green, color.Blue, alpha], Width: 1, Height: 1); } /// /// Scale a decoded texture's alpha channel by a surface's authored translucency /// (AC's convention: 0.0 fully opaque, 1.0 fully transparent). This is the same /// bake the shared-atlas extraction applies (MeshExtractor, /// alphaScale = 1 - Surface.Translucency); runtime composite decodes must /// apply it too or an override-carrying surface silently loses its authored /// translucency. Scales IN PLACE and returns the same instance — the caller must /// own the buffer (never pass a shared/cached texture such as /// ). /// public static DecodedTexture ApplyAuthoredTranslucency(DecodedTexture texture, float translucency) { if (translucency <= 0f) return texture; float alphaScale = Math.Clamp(1f - translucency, 0f, 1f); byte[] rgba = texture.Rgba8; for (int i = 3; i < rgba.Length; i += 4) rgba[i] = (byte)(rgba[i] * alphaScale); return texture; } /// /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8. /// When is true: R=G=B=A=val (terrain alpha masks and /// additive entity textures — the shader reads .r for the blend weight). When false: /// R=G=B=255, A=val (WB FillA8 semantics for non-additive entity textures). /// private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive) { int expected = rs.Width * rs.Height; if (rs.SourceData.Length < expected) return DecodedTexture.Magenta; var rgba = new byte[expected * 4]; if (isAdditive) TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); else TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) { int expected = rs.Width * rs.Height * 4; if (rs.SourceData.Length < expected) return DecodedTexture.Magenta; var rgba = new byte[expected]; TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } /// /// Decode PFID_P8 (8-bit palette index, one byte per pixel) into RGBA8. /// This is the 8-bit sibling of PFID_INDEX16: each byte is a palette index. /// The convention (indices 0..7 → fully transparent) /// is identical to the INDEX16 path. /// private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap) { int expectedBytes = rs.Width * rs.Height; if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0) return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap); return new DecodedTexture(rgba, rs.Width, rs.Height); } /// /// Decode PFID_R8G8B8 (24-bit, 3 bytes per pixel) into RGBA8 with alpha=255. /// AC stores R8G8B8 on disk in B,G,R byte order (confirmed by ACE's /// GetImageColorArray: byte b = reader.ReadByte(); g = ...; r = ...;). /// Output is R,G,B,255 in RGBA8 order for OpenGL PixelFormat.Rgba upload. /// private static DecodedTexture DecodeR8G8B8(RenderSurface rs) { int expectedBytes = rs.Width * rs.Height * 3; if (rs.SourceData.Length < expectedBytes) return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } /// /// Decode PFID_X8R8G8B8 (32-bit, 4 bytes per pixel) into RGBA8 with alpha=255. /// AC stores X8R8G8B8 on disk in B,G,R,X byte order (DirectX little-endian /// convention: low byte = B). The X (high) byte is unused padding and is /// discarded — it is NOT treated as alpha. Output is R,G,B,255 for OpenGL. /// private static DecodedTexture DecodeX8R8G8B8(RenderSurface rs) { int expectedBytes = rs.Width * rs.Height * 4; if (rs.SourceData.Length < expectedBytes) return DecodedTexture.Magenta; var rgba = new byte[expectedBytes]; for (int i = 0; i < rs.Width * rs.Height; i++) { int s = i * 4; // On-disk byte order: B, G, R, X (little-endian 32-bit; high byte X is padding) rgba[s + 0] = rs.SourceData[s + 2]; // R rgba[s + 1] = rs.SourceData[s + 1]; // G rgba[s + 2] = rs.SourceData[s + 0]; // B rgba[s + 3] = 0xFF; // A = opaque (X byte discarded) } return new DecodedTexture(rgba, rs.Width, rs.Height); } private static DecodedTexture DecodeR5G6B5(RenderSurface rs) { int expectedBytes = rs.Width * rs.Height * 2; if (rs.SourceData.Length < expectedBytes) return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs) { int expectedBytes = rs.Width * rs.Height * 2; if (rs.SourceData.Length < expectedBytes) return DecodedTexture.Magenta; var rgba = new byte[rs.Width * rs.Height * 4]; TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height); return new DecodedTexture(rgba, rs.Width, rs.Height); } private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap) { var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); var rgba = new byte[rs.Width * rs.Height * 4]; for (int i = 0; i < pixels.Length; i++) { int s = i * 4; rgba[s + 0] = pixels[i].r; rgba[s + 1] = pixels[i].g; rgba[s + 2] = pixels[i].b; rgba[s + 3] = pixels[i].a; if (isClipMap && rgba[s + 0] == 0 && rgba[s + 1] == 0 && rgba[s + 2] == 0) rgba[s + 3] = 0; } return new DecodedTexture(rgba, rs.Width, rs.Height); } }