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 <noreply@anthropic.com>
323 lines
16 KiB
C#
323 lines
16 KiB
C#
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();
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>claude-memory/feedback_ui_resolve_zero_magenta.md</c> ("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).
|
|
/// </summary>
|
|
private static readonly ConcurrentDictionary<uint, byte> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode a RenderSurface's pixel bytes into RGBA8. Returns <see cref="DecodedTexture.Magenta"/>
|
|
/// for unsupported formats, null data, or corrupt sizing. This overload does NOT
|
|
/// support PFID_INDEX16 — use <see cref="DecodeRenderSurface(RenderSurface, Palette?)"/>
|
|
/// when a palette is available.
|
|
/// </summary>
|
|
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
|
|
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
|
|
|
|
/// <summary>
|
|
/// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support.
|
|
/// When <paramref name="palette"/> is non-null and the format is PFID_INDEX16, each
|
|
/// 16-bit value in SourceData is treated as an index into <see cref="Palette.Colors"/>.
|
|
/// When <paramref name="isClipMap"/> is true on an indexed surface, palette indices
|
|
/// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention).
|
|
/// When <paramref name="isAdditive"/> 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).
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode PFID_CUSTOM_RAW_JPEG: see the doc comment on the
|
|
/// <see cref="PixelFormat.PFID_CUSTOM_RAW_JPEG"/> branch in
|
|
/// <see cref="DecodeRenderSurface(RenderSurface, Palette?, bool, bool)"/> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a 1x1 RGBA8 texture from a single <see cref="ColorARGB"/> modulated
|
|
/// by a surface translucency value. Used for <c>Surface.Type.HasFlag(Base1Solid)</c>
|
|
/// surfaces that carry a color value instead of a texture chain.
|
|
///
|
|
/// AC's convention: <paramref name="translucency"/> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 (<c>MeshExtractor</c>,
|
|
/// <c>alphaScale = 1 - Surface.Translucency</c>); 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
|
|
/// <see cref="DecodedTexture.Magenta"/>).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8.
|
|
/// When <paramref name="isAdditive"/> 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="isClipMap"/> convention (indices 0..7 → fully transparent)
|
|
/// is identical to the INDEX16 path.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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: <c>byte b = reader.ReadByte(); g = ...; r = ...;</c>).
|
|
/// Output is R,G,B,255 in RGBA8 order for OpenGL PixelFormat.Rgba upload.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|