diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9527df41..0ddb6582 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -57,7 +57,7 @@ accepted-divergence entries (#96, #49, #50). | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case β€” terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟒/πŸ”΄ split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/cornerβ†’position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800Γ—600 and the controls.ini cascade still lack an oracle β€” layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | | IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | -| IA-18 | Effect overlay tile (enum 0x10000005) is used as a `ReplaceColor` tint SOURCE β€” white pixels in the composited drag icon are replaced with the tile's representative color; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior (`IconData::RenderIcons` 0x0058d180 / `SurfaceWindow::ReplaceColor` 0x00441530). **Anti-regression: do NOT re-implement this as a separate blit layer.** | `src/AcDream.App/UI/IconComposer.cs` (`GetIcon`) | Faithful port of `IconData::RenderIcons` @407575 / `ReplaceColor` @407614; confirmed in `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. Recorded here as a guard against a future dev "fixing" it back to a blit. | A blit-layer re-implementation would show the tile's colors additively over the icon instead of recoloring the base white highlights β€” wrong effect look, masked by the fact that the tile IS mostly the right color but composites differently | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` @407614 (fixed src=white); `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | +| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE β€” pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 β†’ the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) β€” the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | --- @@ -96,7 +96,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) β€” 43 rows +## 3. Documented approximation (AP) β€” 42 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -142,7 +142,6 @@ accepted-divergence entries (#96, #49, #50). | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe β€” retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire β€” no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window β€” no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | -| AP-43 | Effect tint color = the effect tile's mean-opaque RGB (average of non-transparent pixels); the exact retail color is an `effectTile + 0xac` pointer reinterpreted as `RGBAColor` β€” decompiler-ambiguous in the BN pseudo-C (field offset vs pointer). Visual/cdb confirmation pending (D.5.2 lever: DR-2) | `src/AcDream.App/UI/IconComposer.cs` (`TryGetEffectColor`) | The tile IS the per-effect coloring authority (Magical=blue, Poisoned=green, …); its mean-opaque color is the representative color. The ambiguity only affects hue saturation slightly. | Effect tints could be subtly wrong vs retail (too washed-out or oversaturated) if the header field is a precomputed key color rather than the pixel mean β€” visible on items with distinctive effect glows | `IconData::RenderIcons` 0x0058d180 (`effectTile+0xac` usage); `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value β€” item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | --- diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs index 12725e6e..2ff95019 100644 --- a/src/AcDream.App/UI/IconComposer.cs +++ b/src/AcDream.App/UI/IconComposer.cs @@ -51,7 +51,7 @@ public sealed class IconComposer private EnumIDMap? _effectSubMap; private bool _effectResolveTried; private readonly Dictionary _effectDidByIndex = new(); - private readonly Dictionary _effectColorByDid = new(); + private readonly Dictionary _effectTileByDid = new(); public IconComposer(DatCollection dats, TextureCache cache) { @@ -127,46 +127,45 @@ public sealed class IconComposer } /// - /// Retail SurfaceWindow::ReplaceColor (0x00441530) with the icon-composite's - /// fixed source color: replace pixels exactly equal to pure-white-opaque - /// (RGBAColor(1,1,1,1) β†’ 0xFFFFFFFF) with . Mutates in place. + /// Retail SurfaceWindow::ReplaceColor SURFACE overload (0x004415b0): for every + /// pixel in that equals pure-white-opaque (RGBAColor(1,1,1,1) β†’ + /// 0xFFFFFFFF), copy the SAME (x,y) pixel from the source effect tile. This preserves + /// the effect tile's texture/gradient (NOT a flat color). Retail requires the source to + /// cover the dest (it does β€” both are 32x32); out-of-range pixels are left unchanged. + /// Mutates in place. /// - internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest) + internal static void ReplaceWhiteFromSurface(byte[] dst, int dw, int dh, byte[] src, int sw, int sh) { - for (int i = 0; i < w * h; i++) + for (int y = 0; y < dh; y++) + for (int x = 0; x < dw; x++) { - if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 && - rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255) + int di = (y * dw + x) * 4; + if (dst[di] == 255 && dst[di + 1] == 255 && dst[di + 2] == 255 && dst[di + 3] == 255 + && x < sw && y < sh) { - rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g; - rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a; + int si = (y * sw + x) * 4; + dst[di] = src[si]; dst[di + 1] = src[si + 1]; + dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3]; } } } /// - /// The effect tint color for : the effect tile's mean-opaque - /// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a - /// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so - /// its representative color is the faithful equivalent (divergence DR-2). Cached per DID. + /// The decoded effect tile for (enum 0x10000005). The tile is + /// a 32x32 textured RenderSurface whose pixels ARE the per-effect coloring (blue=Magical, + /// green=Poisoned, …; the 0x21 fallback is solid black). Retail copies it per-pixel into + /// the icon's white pixels (gradient), so we need the whole tile, not a representative + /// color. Cached per DID. /// - internal bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color) + internal bool TryGetEffectTile(uint effects, out DecodedTexture tile) { - color = default; + tile = null!; uint did = ResolveEffectDid(effects); if (did == 0) return false; - if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; } + if (_effectTileByDid.TryGetValue(did, out var cached)) { tile = cached; return true; } if (!TryDecode(did, out var d)) return false; - long sr = 0, sg = 0, sb = 0; int n = 0; - for (int i = 0; i < d.Width * d.Height; i++) - { - if (d.Rgba8[i * 4 + 3] == 0) continue; - sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++; - } - if (n == 0) return false; - var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255); - _effectColorByDid[did] = rep; - color = rep; + _effectTileByDid[did] = d; + tile = d; return true; } @@ -235,12 +234,15 @@ public sealed class IconComposer var composed = Compose(dragLayers); // Effect recolor β€” ALWAYS, matching retail IconData::RenderIcons (0x0058d180): // the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null - // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5), so retail recolors - // pure-white pixels to BLACK on mundane items and to the effect hue on magical - // ones. Visually confirmed against retail 2026-06-17: the no-mana scroll's edges - // are BLACK, not white β€” the earlier `effects != 0` gate (AP-44) was wrong. - if (TryGetEffectColor(effects, out var ec)) - ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec); + // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons + // calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying + // the textured effect tile per-pixel into the icon's pure-white pixels β€” so + // magical items take the tile's GRADIENT hue and mundane items go solid black. + // (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a + // gradient, not a flat tint, and the no-mana scroll's edges are black.) + if (TryGetEffectTile(effects, out var tile)) + ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h, + tile.Rgba8, tile.Width, tile.Height); drag = composed; } diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs index 003dec5a..ba35d60d 100644 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ b/tests/AcDream.App.Tests/UI/IconComposerTests.cs @@ -128,7 +128,7 @@ public class IconComposerTests } [Fact] - public void TryGetEffectColor_noEffect_resolvesToBlackFallback() + public void TryGetEffectTile_noEffectBlack_magicalTextured() { var datDir = ResolveDatDir(); if (datDir is null) return; // dats absent (CI) β€” skip cleanly @@ -136,43 +136,62 @@ public class IconComposerTests using var dats = new DatCollection(datDir, DatAccessType.Read); var composer = new IconComposer(dats, null!); - // effects==0 resolves to the 0x21 solid-black fallback tile (0x060011C5), so the - // ALWAYS-on recolor blackens an icon's pure-white edge pixels on mundane items β€” - // retail-faithful (the no-mana scroll's edges are BLACK, not white). Confirmed - // visually against retail 2026-06-17. - Assert.True(composer.TryGetEffectColor(0u, out var c)); - Assert.True(c.r <= 8 && c.g <= 8 && c.b <= 8, $"expected ~black, got ({c.r},{c.g},{c.b})"); - Assert.Equal(255, c.a); + // effects==0 β†’ 0x21 fallback β†’ 0x060011C5, a 32x32 SOLID-BLACK tile. Copying it + // per-pixel blackens an icon's pure-white pixels (retail-faithful no-mana scroll edge). + Assert.True(composer.TryGetEffectTile(0u, out var black)); + Assert.Equal(32, black.Width); + Assert.Equal(32, black.Height); + Assert.True(black.Rgba8[0] <= 8 && black.Rgba8[1] <= 8 && black.Rgba8[2] <= 8); + + // Magical (0x1) β†’ 0x060011CA, a TEXTURED blue tile (NOT a flat color) β€” this is the + // gradient retail copies per-pixel into the icon's white pixels (the Energy Crystal + // blue). Assert the tile is non-uniform so a future flat-color regression fails here. + Assert.True(composer.TryGetEffectTile(0x1u, out var magic)); + bool uniform = true; + for (int i = 4; i < magic.Width * magic.Height * 4 && uniform; i += 4) + if (magic.Rgba8[i] != magic.Rgba8[0] || magic.Rgba8[i + 1] != magic.Rgba8[1] || + magic.Rgba8[i + 2] != magic.Rgba8[2]) + uniform = false; + Assert.False(uniform); // textured β†’ gradient, not flat } [Fact] - public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque() + public void ReplaceWhiteFromSurface_copiesSourcePixelForPureWhiteOpaque() { - // 2x2: [white-opaque, red-opaque, white-transparent, white-opaque] - var px = new byte[] + // 2x2 dest: [white-opaque, red-opaque, white-transparent, white-opaque] + var dst = new byte[] { - 255,255,255,255, // pure white opaque β†’ replaced + 255,255,255,255, // pure white opaque β†’ takes src(0,0) 255, 0, 0,255, // red β†’ untouched 255,255,255, 0, // white but alpha 0 β†’ untouched (not 0xFFFFFFFF) - 255,255,255,255, // pure white opaque β†’ replaced + 255,255,255,255, // pure white opaque β†’ takes src(1,1) }; - IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255)); - Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced - Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched - Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched - Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced + // 2x2 src β€” distinct per-pixel colors (a "gradient"). + var src = new byte[] + { + 10, 20, 30,255, // (0,0) + 40, 50, 60,255, // (1,0) + 70, 80, 90,255, // (0,1) + 100,110,120,255, // (1,1) + }; + IconComposer.ReplaceWhiteFromSurface(dst, 2, 2, src, 2, 2); + Assert.Equal(new byte[] { 10, 20, 30, 255 }, dst[0..4]); // copied src(0,0) + Assert.Equal(new byte[] { 255, 0, 0, 255 }, dst[4..8]); // untouched (not white) + Assert.Equal(new byte[] { 255, 255, 255, 0 }, dst[8..12]); // untouched (transparent) + Assert.Equal(new byte[] { 100, 110, 120, 255 }, dst[12..16]); // copied src(1,1) β€” per-pixel } [Fact] - public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay() + public void TwoStageWithEffect_copiesTilePixelBeforeUnderlay() { - // drag = base (white pixel) over overlay (none); recolor whiteβ†’blue; then over - // an opaque tawny underlay. The white pixel must become blue in the final. + // drag = base (white pixel); copy the effect tile's pixel into the white; then over + // an opaque tawny underlay. The white pixel must become the tile's pixel in the final. var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque var drag = IconComposer.Compose(new[] { baseIcon }); - IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue + var tile = new byte[] { 0, 0, 255, 255 }; // 1x1 blue tile pixel + IconComposer.ReplaceWhiteFromSurface(drag.rgba, drag.w, drag.h, tile, 1, 1); var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) }); - Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top + Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // tile pixel on top } }