feat: mosswart client icon and Asheron's Call-inspired launcher icon

acdream had no application icon on either executable. Two marks now ship,
built from the game's own material rather than drawn freehand:

* Client - the retail mosswart head. Not an illustration of one: the actual
  creature mesh (Setup 0x02000B4F part 14, skin atlas 0x05001E11,
  ClothingBase 0x10000344) read out of client_portal.dat through acdream's
  own GfxObjMesh/SetupMesh port, then smoothed, lit and graded. Palette
  values are sampled from that texture, including the mustard belly the
  Mosswart lore calls a "foul yellow".

* Launcher - a forged ring enclosing a barbed crescent, rebuilt from
  measurements of the retail wordmark and the acclient.exe icon resource.
  An original construction in the same visual language, not a copy of the
  trademarked logo. Its warm field matches the retail client icon.

Three techniques carry the render quality, all in tools/IconForge:

* PN-triangle tessellation (smooth.py). The retail head is 104 triangles
  and renders faceted. Each triangle becomes a cubic Bezier patch built
  from its own corner positions and normals, so the silhouette genuinely
  rounds rather than merely shading smoothly - and it needs no mesh
  connectivity, which matters because UV seams would otherwise pull apart.
  Normals are welded across coincident positions first, but only within a
  crease angle, so ear fins and tusk edges stay sharp.

* Matcaps (ring.py). A Lambert rasterizer cannot produce chrome, because
  chrome is almost entirely reflection and there is nothing here to
  reflect. Sampling a lit-sphere image by the camera-space normal is the
  standard stand-in for an environment map.

* Distance-transform bevelling (chisel.py). Flat shapes become chiselled
  metal by treating distance-to-edge as height. The height field is
  blurred before differentiating; without that the medial axis of each
  stroke shows through as a hatched ridge.

Two facts worth recording, both discovered the hard way. Creature Setups
define no upright pose in PlacementFrames, so the exporter must be handed
the weenie's MotionTable id or all 17 parts stack on the origin. And a
mosswart's eyes sit on the sides of the skull like a frog's, so a dead-on
frontal turns them edge-on and the face stops reading as a mosswart at all;
the hero angle is az 266 / el 32.

Wiring: <ApplicationIcon> gives each executable its PE icon. The client's
runtime window icon is embedded rather than copied beside the binary - a
window icon has no sensible fallback if the file goes missing, and
embedding survives single-file publish. WindowIconLoaderTests guards the
resource names, which are coupled to LogicalName in the csproj by string
alone and would otherwise fail only as a silently icon-less window.

Both halves of the pipeline are deterministic and reproduce the committed
PNGs byte-for-byte, so an accidental edit shows up as a diff.

Solution builds clean; 14,378 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 14:42:10 +02:00
parent 4d84456c21
commit a1ffe77af4
43 changed files with 2210 additions and 0 deletions

4
.gitignore vendored
View file

@ -114,3 +114,7 @@ studio-shots/
# Campaign V capture/evidence output - session-local, never tracked (423 MB lesson, 2026-07-29)
artifacts/
341-slope-capture.jsonl
# IconForge DAT extraction scratch (geometry + textures dumped from the
# installed client dats; regenerate with tools/MosswartArt, never commit).
tools/IconForge/work/

View file

@ -18,6 +18,7 @@
<Folder Name="/tools/">
<Project Path="tools/A8CellAudit/A8CellAudit.csproj" />
<Project Path="tools/dump-keymap/dump-keymap.csproj" />
<Project Path="tools/MosswartArt/MosswartArt.csproj" />
<Project Path="tools/PesChainAudit/PesChainAudit.csproj" />
<Project Path="tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj" />
<Project Path="tools/RainMeshProbe/RainMeshProbe.csproj" />

86
assets/icons/README.md Normal file
View file

@ -0,0 +1,86 @@
# acdream application icons
Two marks, one family.
| Mark | Files | Used by |
|---|---|---|
| **Client** — the mosswart head | `acdream-client-*.png`, `acdream-client.ico` | `AcDream.App` (PE icon + runtime window icon) |
| **Launcher** — the ring and crescent | `acdream-launcher-*.png`, `acdream-launcher.ico` | `AcDream.Launcher` (PE icon + Avalonia `Window.Icon`) |
Each ships PNGs at 16/24/32/48/64/128/256/512/1024 plus a multi-size `.ico`
carrying 16 through 256.
## Where the art comes from
**The client mark is the retail mosswart**, not a drawing of one. It is the
actual creature head — `Setup 0x02000B4F` part 14, skin atlas `0x05001E11`,
`ClothingBase 0x10000344` — pulled from `client_portal.dat`, smoothed, lit and
graded. Palette values throughout both marks are sampled from that texture:
| | |
|---|---|
| `#ACB820` | chartreuse upper skin |
| `#A09800` | mustard belly — the "foul yellow" the lore names |
| `#485010` | deep olive shadow |
| `#F2ECD2` | tusk bone |
| `#AC7438` | ear membrane / hide |
**The launcher mark is inspired by the Asheron's Call sigil** — a forged ring
enclosing a hooked crescent — rebuilt from measurements of the retail wordmark
and the `acclient.exe` icon resource. It is an original construction in the
same visual language, not a copy of the logo. Its warm field matches the retail
client icon's dark-to-gold interior.
> **Note on rights.** "Asheron's Call" and its logo are trademarks of their
> owners, and the client mark is rendered from copyrighted game art. Unlike DAT
> content — which stays on the user's own disk — these icons are compiled into
> the shipped binaries. If acdream is ever distributed broadly, both marks
> should be reviewed, and the client mark is the one most likely to want an
> original redraw using these renders as reference.
## Regenerating
The launcher mark is fully procedural and rebuilds anywhere:
```bash
py tools/IconForge/forge.py launcher
```
That is byte-for-byte deterministic — it reproduces the committed PNGs exactly,
so an accidental edit is visible as a diff.
The client mark renders real game geometry, so it needs the installed DATs.
One command extracts both halves — the posed geometry and the surfaces it
references — into `tools/IconForge/work/`:
```bash
dotnet run --project tools/MosswartArt -- 0x02000B4F 0x10000344 tools/IconForge/work/mosswart_mesh.json 0x09000009
```
The trailing MotionTable id is required. Creatures do not define an upright pose
in `Setup.PlacementFrames`; without it every part stacks on the origin.
Then:
```bash
py tools/IconForge/forge.py client
```
This is deterministic too — given the same DATs it reproduces the committed
PNGs byte-for-byte.
Requires Python with `numpy`, `pillow` and `scipy`.
## How they are wired in
Neither icon is loaded from disk at runtime.
- **PE icon**`<ApplicationIcon>` in each `.csproj`, pointing at the `.ico`
here. This is what Explorer and the taskbar shortcut show.
- **Client window icon**`AcDream.App.Rendering.WindowIconLoader` hands GLFW
four sizes at startup. The PNGs are *embedded resources* linked from this
directory, so there is one source of truth for the art and no missing-file
case at runtime. `WindowIconLoaderTests` guards the resource names, which are
otherwise coupled to `LogicalName` in the csproj by string only.
- **Launcher window icon**`AvaloniaResource` linked from here, referenced as
`avares://acdream-launcher/Assets/acdream-launcher.png`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -8,6 +8,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>AcDream.App</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Windows shell/Explorer icon for the client executable. The runtime
window icon is a separate mechanism (WindowIconLoader) because the exe
icon is baked into the PE and never reaches GLFW. -->
<ApplicationIcon>..\..\assets\icons\acdream-client.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Tests" />
@ -81,6 +85,21 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- Runtime window icon (WindowIconLoader). Embedded rather than copied:
a window icon has no sensible fallback if the file goes missing, and
embedding survives single-file publish. Included from assets/icons
with an explicit LogicalName so the art has ONE source of truth and is
not duplicated into this project's tree. -->
<EmbeddedResource Include="..\..\assets\icons\acdream-client-16.png"
LogicalName="AcDream.App.Rendering.Icons.acdream-client-16.png" />
<EmbeddedResource Include="..\..\assets\icons\acdream-client-32.png"
LogicalName="AcDream.App.Rendering.Icons.acdream-client-32.png" />
<EmbeddedResource Include="..\..\assets\icons\acdream-client-48.png"
LogicalName="AcDream.App.Rendering.Icons.acdream-client-48.png" />
<EmbeddedResource Include="..\..\assets\icons\acdream-client-256.png"
LogicalName="AcDream.App.Rendering.Icons.acdream-client-256.png" />
</ItemGroup>
<ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
<ProjectReference Include="..\AcDream.Plugins.Smoke\AcDream.Plugins.Smoke.csproj">

View file

@ -800,6 +800,10 @@ public sealed class GameWindow :
_startupQuality = startup.Quality;
_window = Window.Create(options);
// Before any callback binding: the icon is pure window decoration and
// has no ordering relationship with the render loop, so it belongs at
// the earliest point the native window exists.
WindowIconLoader.Apply(_window);
IWindow window = _window;
_lifetime.PublishNativeWindow(
window,

View file

@ -0,0 +1,107 @@
using Silk.NET.Core;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace AcDream.App.Rendering;
/// <summary>
/// Applies acdream's window icon to the native window.
/// </summary>
/// <remarks>
/// <para>
/// The icon art is the retail mosswart head (Setup <c>0x02000B4F</c> part 14,
/// skin <c>0x05001E11</c>) rendered offline by <c>tools/IconForge</c>; only the
/// baked PNGs ship. See <c>assets/icons/README.md</c>.
/// </para>
/// <para>
/// The PNGs are <b>embedded</b> rather than copied next to the binary, unlike
/// the shader/markup assets in this project. Two reasons: a window icon has no
/// sensible runtime fallback if the file is missing, and embedding keeps it
/// intact under single-file publish. The cost is a few tens of KB in the
/// assembly.
/// </para>
/// <para>
/// Several sizes are handed over at once because the window manager picks the
/// closest match per surface — the title bar wants ~16px while Alt-Tab and the
/// taskbar want 32-256px, and letting the WM choose beats shipping one size and
/// having it resampled badly.
/// </para>
/// </remarks>
internal static class WindowIconLoader
{
// Ordered small -> large purely for readability; the window manager selects
// by size, not by position.
private static readonly string[] ResourceNames =
{
"AcDream.App.Rendering.Icons.acdream-client-16.png",
"AcDream.App.Rendering.Icons.acdream-client-32.png",
"AcDream.App.Rendering.Icons.acdream-client-48.png",
"AcDream.App.Rendering.Icons.acdream-client-256.png",
};
/// <summary>
/// Decode the embedded icon set and hand it to the window.
/// </summary>
public static void Apply(IWindow window)
{
ArgumentNullException.ThrowIfNull(window);
RawImage[] images;
try
{
images = Decode();
}
catch (Exception failure)
{
// A missing or corrupt embedded resource is a build defect, not a
// runtime condition — say so loudly rather than shipping a silent
// catch, but do not take the client down over cosmetics.
Console.Error.WriteLine($"window icon: could not decode embedded icons — {failure}");
return;
}
if (images.Length == 0)
{
Console.Error.WriteLine("window icon: no embedded icon resources found");
return;
}
try
{
window.SetWindowIcon(images.AsSpan());
}
catch (Exception failure)
{
// Wayland has no window-icon protocol and GLFW reports the request
// as unsupported there. That is a platform fact, not a bug, and it
// must not be fatal — but it is still worth printing so an
// unexpectedly icon-less window on a supported platform is
// traceable rather than mysterious.
Console.Error.WriteLine($"window icon: platform rejected the icon — {failure.Message}");
}
}
private static RawImage[] Decode()
{
var assembly = typeof(WindowIconLoader).Assembly;
var decoded = new List<RawImage>(ResourceNames.Length);
foreach (string name in ResourceNames)
{
using Stream? stream = assembly.GetManifestResourceStream(name);
if (stream is null)
{
Console.Error.WriteLine($"window icon: embedded resource missing — {name}");
continue;
}
using var image = Image.Load<Rgba32>(stream);
var pixels = new byte[image.Width * image.Height * 4];
image.CopyPixelDataTo(pixels);
decoded.Add(new RawImage(image.Width, image.Height, pixels));
}
return decoded.ToArray();
}
}

View file

@ -8,6 +8,10 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Windows shell/Explorer icon for acdream-launcher.exe. The window icon
is set separately via Window.Icon in MainWindow.axaml — the PE icon
never reaches Avalonia. -->
<ApplicationIcon>..\..\assets\icons\acdream-launcher.ico</ApplicationIcon>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
@ -62,6 +66,14 @@
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Window icon for the Avalonia shell. Linked from assets/icons so the
art has one source of truth; the Link is what fixes the avares path
MainWindow.axaml resolves. -->
<AvaloniaResource Include="..\..\assets\icons\acdream-launcher-256.png"
Link="Assets/acdream-launcher.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />

View file

@ -4,6 +4,7 @@
x:Class="AcDream.Launcher.MainWindow"
x:DataType="vm:LauncherWindowViewModel"
Title="acdream launcher"
Icon="avares://acdream-launcher/Assets/acdream-launcher.png"
Width="1180"
Height="760"
MinWidth="900"

View file

@ -0,0 +1,79 @@
using System.Reflection;
using AcDream.App.Rendering;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The icon resource names are coupled to <c>LogicalName</c> in
/// AcDream.App.csproj by string alone, so a rename on either side compiles
/// cleanly and only fails as a silently icon-less window at runtime. These
/// tests are that coupling's only guard.
/// </summary>
public class WindowIconLoaderTests
{
private static IReadOnlyList<string> ExpectedResourceNames()
{
var field = typeof(WindowIconLoader).GetField(
"ResourceNames", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(field);
var names = (string[]?)field!.GetValue(null);
Assert.NotNull(names);
return names!;
}
[Fact]
public void EveryDeclaredIconResource_IsActuallyEmbedded()
{
var assembly = typeof(WindowIconLoader).Assembly;
string[] embedded = assembly.GetManifestResourceNames();
foreach (string name in ExpectedResourceNames())
{
Assert.True(
embedded.Contains(name),
$"WindowIconLoader expects embedded resource '{name}', but the "
+ "assembly does not contain it. Check the EmbeddedResource "
+ "LogicalName entries in AcDream.App.csproj.");
}
}
[Fact]
public void EmbeddedIcons_DecodeToSquareRgbaImagesOfTheDeclaredSize()
{
var assembly = typeof(WindowIconLoader).Assembly;
foreach (string name in ExpectedResourceNames())
{
using Stream? stream = assembly.GetManifestResourceStream(name);
Assert.NotNull(stream);
using var image = Image.Load<Rgba32>(stream!);
Assert.Equal(image.Width, image.Height);
// The trailing "-<size>.png" must match the actual pixel size, or
// the window manager picks the wrong image for a surface.
string stem = Path.GetFileNameWithoutExtension(name);
string declared = stem[(stem.LastIndexOf('-') + 1)..];
Assert.Equal(int.Parse(declared), image.Width);
}
}
[Fact]
public void IconSet_CoversBothSmallAndLargeSurfaces()
{
var sizes = new List<int>();
foreach (string name in ExpectedResourceNames())
{
string stem = Path.GetFileNameWithoutExtension(name);
sizes.Add(int.Parse(stem[(stem.LastIndexOf('-') + 1)..]));
}
// Title bars want ~16px and Alt-Tab/taskbar want a large one; shipping
// only one size leaves the window manager resampling badly.
Assert.Contains(16, sizes);
Assert.True(sizes.Max() >= 128, "icon set has no large size for Alt-Tab/taskbar");
}
}

62
tools/IconForge/README.md Normal file
View file

@ -0,0 +1,62 @@
# IconForge
Generates acdream's application icons into `assets/icons/`. Provenance,
palette, rights notes and the wiring live in
[`assets/icons/README.md`](../../assets/icons/README.md); this file covers how
the pipeline works.
```bash
py tools/IconForge/forge.py launcher # procedural, no game data needed
py tools/IconForge/forge.py client # needs a DAT export in work/
py tools/IconForge/forge.py all
```
## Modules
| File | Purpose |
|---|---|
| `render.py` | Software rasterizer: z-buffer, perspective camera, Lambert key/fill/rim, Blinn specular, bilinear texture sampling, matcap materials. |
| `smooth.py` | Crease-aware normal welding + PN-triangle tessellation. |
| `ring.py` | Forged 3-D ring and hook meshes, plus the chrome and verdigris matcaps. |
| `chisel.py` | Distance-transform bevelling: turns any 2-D mask into chiselled metal. |
| `ac_glyph.py` | The ring-and-crescent sigil geometry. |
| `compose.py` | Badging: fields, masks, drop shadows, contact sheets, size strips. |
| `launcher.py` | Portal vortex, placement helpers. |
| `forge.py` | Entry point. |
## The three techniques worth knowing
**PN-triangle tessellation** (`smooth.py`). The retail mosswart head is 104
triangles and renders faceted. Each flat triangle becomes a cubic Bézier patch
built from its own corner positions and normals, with quadratically interpolated
normals — so the silhouette genuinely rounds instead of merely shading smoothly.
It needs no mesh connectivity, which matters because UV seams would otherwise
pull apart. Normals are welded across coincident positions first, but only
within a crease angle, so ear fins and tusk edges stay sharp while the skull
rounds off.
**Matcaps** (`ring.py`). A Lambert rasterizer cannot produce chrome, because
chrome is almost entirely reflection and there is nothing here to reflect. A
matcap — one image of a lit sphere, sampled by the camera-space normal — is the
standard cheap stand-in for an environment map. The sharp horizon band in
`chrome_matcap` is what makes the eye read "metal" rather than "grey plastic".
**Distance-transform bevelling** (`chisel.py`). For flat shapes — the crescent,
the ring, letterforms — take the distance transform of the mask, treat
distance-to-edge as height, and derive normals from the height gradient. Shading
those through the *same* matcap the 3-D meshes use keeps everything lit by one
imaginary environment. The height field is blurred before differentiating:
without that, the medial axis of each stroke shows through as a hatched ridge.
## Gotchas
- **Creature poses need the MotionTable.** `Setup.PlacementFrames` has no
upright pose for creatures; pass the weenie's MotionTable id to the exporter
or every part stacks on the origin.
- **Build a mask and its decorations in one frame.** The crescent's barb and
tail attach at cusp angles solved from the circle intersection. Rotating the
crescent and the spikes separately mixes sign conventions and leaves the tail
floating clear of the cusp.
- **The launcher path is deterministic** and must stay that way: it reproduces
the committed PNGs byte-for-byte, which is what makes an accidental edit show
up as a diff. No RNG without a fixed seed.

141
tools/IconForge/ac_glyph.py Normal file
View file

@ -0,0 +1,141 @@
"""The Asheron's Call sigil geometry, rebuilt from the retail wordmark.
Measured off the logo rather than guessed:
* the ring is a **thin, complete** circle -- roughly 1/16 of its diameter in
thickness -- not a thick band with a gap. It passes *behind* the glyph.
* both ring and glyph are chiselled relief with a raised centre ridge, which is
why every stroke shows a bright spine and two darker chamfers.
* the glyph is a crescent opening to the right. Its upper terminal extends into
a barbed hook that crosses over the ring; its lower terminal tapers to a fine
point.
Everything here produces masks; chisel.shade() turns them into metal.
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
SS = 4 # supersample factor for all mask drawing
def _canvas(size):
return Image.new("L", (size * SS, size * SS), 0)
def _down(img, size):
return np.asarray(img.resize((size, size), Image.LANCZOS), dtype=np.float32) / 255.0
def _px(size, v):
"""normalised (-1..1) -> pixel on the supersampled canvas"""
return (v * 0.5 + 0.5) * size * SS
def thin_ring(size=1024, R=0.86, thick=0.055, wobble=0.0, seed=3):
"""Complete slender circle, the AC ring proportion."""
W = size * SS
y, x = np.mgrid[0:W, 0:W].astype(np.float32)
x = (x / (W - 1)) * 2 - 1
y = (y / (W - 1)) * 2 - 1
r = np.sqrt(x * x + y * y)
if wobble > 0:
th = np.arctan2(y, x)
rng = np.random.default_rng(seed)
r = r - wobble * (np.sin(6 * th + rng.uniform(0, 6)) * 0.6
+ np.sin(11 * th + rng.uniform(0, 6)) * 0.4)
m = (np.abs(r - R) < thick / 2).astype(np.float32)
im = Image.fromarray((m * 255).astype(np.uint8), "L")
return _down(im, size)
def crescent(size=1024, R=0.66, inner_r=0.60, offset=0.30, cy=0.0,
rot_deg=-18.0):
"""Classic two-circle crescent: outer disc minus an offset inner disc.
The two intersection points give naturally sharp cusps -- exactly the
terminals the AC glyph has, before the barb is added on top.
"""
W = size * SS
y, x = np.mgrid[0:W, 0:W].astype(np.float32)
x = (x / (W - 1)) * 2 - 1
y = (y / (W - 1)) * 2 - 1
a = np.radians(rot_deg)
xr = x * np.cos(a) - y * np.sin(a)
yr = x * np.sin(a) + y * np.cos(a)
outer = (xr * xr + (yr - cy) ** 2) < R * R
inner = ((xr - offset) ** 2 + (yr - cy) ** 2) < inner_r * inner_r
m = (outer & ~inner).astype(np.float32)
return _down(Image.fromarray((m * 255).astype(np.uint8), "L"), size)
def _tapered_arc(draw, size, cx, cy, r0, r1, a0_deg, a1_deg,
w0, w1, steps=90, curl=0.0):
"""Draw a tapering curved stroke as a polygon strip."""
t = np.linspace(0, 1, steps)
ang = np.radians(a0_deg + (a1_deg - a0_deg) * t)
rad = r0 + (r1 - r0) * t + curl * np.sin(np.pi * t)
cxs = cx + np.cos(ang) * rad
cys = cy + np.sin(ang) * rad
w = w0 + (w1 - w0) * (t ** 1.35)
dx = np.gradient(cxs); dy = np.gradient(cys)
ln = np.sqrt(dx * dx + dy * dy); ln = np.where(ln < 1e-9, 1, ln)
nx, ny = -dy / ln, dx / ln
left = [( _px(size, cxs[i] + nx[i] * w[i]), _px(size, cys[i] + ny[i] * w[i]) )
for i in range(steps)]
right = [( _px(size, cxs[i] - nx[i] * w[i]), _px(size, cys[i] - ny[i] * w[i]) )
for i in range(steps)][::-1]
draw.polygon(left + right, fill=255)
def cusp_angles(R, inner_r, offset):
"""Where the two circles meet -- the crescent's two sharp terminals.
Solving the circle intersection gives the exact attachment points, so the
barb and tail grow out of the cusps instead of floating near them.
"""
x = (R * R - inner_r * inner_r + offset * offset) / (2 * offset)
y2 = R * R - x * x
if y2 <= 0:
return None
y = np.sqrt(y2)
return np.degrees(np.arctan2(-y, x)), np.degrees(np.arctan2(y, x))
def ac_glyph(size=1024, R=0.66, inner_r=0.60, offset=0.30, rot_deg=-18.0,
barb=True, tail=True, fork=True):
"""Crescent + barbed upper hook + tapering lower tail, as one mask."""
# Build crescent and spikes in ONE unrotated frame, then rotate the combined
# mask once. Rotating them separately mixed two sign conventions and left the
# tail floating clear of the cusp it is supposed to grow out of.
m = crescent(size, R, inner_r, offset, 0.0, 0.0)
cu = cusp_angles(R, inner_r, offset)
if cu is None:
return m
upper, lower = cu
img = _canvas(size)
d = ImageDraw.Draw(img)
if barb:
# main spike: leaves the cusp thin, swells, then needles out past the ring
_tapered_arc(d, size, 0.0, 0.0, R, R * 1.34, upper, upper - 62.0,
0.030, 0.004, curl=0.085)
if fork:
# the second, shorter prong that makes the terminal read as barbed
_tapered_arc(d, size, 0.0, 0.0, R * 1.02, R * 1.16, upper - 26.0,
upper - 54.0, 0.022, 0.003, curl=-0.055)
if tail:
_tapered_arc(d, size, 0.0, 0.0, R, R * 1.14, lower, lower + 34.0,
0.028, 0.003, curl=0.030)
spikes = _down(img, size)
return np.clip(_rot(np.maximum(m, spikes), rot_deg, size), 0, 1)
def _rot(mask, deg, size):
im = Image.fromarray((mask * 255).astype(np.uint8), "L")
im = im.rotate(-deg, resample=Image.BICUBIC, center=(size / 2, size / 2))
return np.asarray(im, dtype=np.float32) / 255.0
def soften(mask, px=1.0):
im = Image.fromarray((np.clip(mask, 0, 1) * 255).astype(np.uint8), "L")
return np.asarray(im.filter(ImageFilter.GaussianBlur(px)), dtype=np.float32) / 255.0

168
tools/IconForge/chisel.py Normal file
View file

@ -0,0 +1,168 @@
"""Turn any flat 2D shape into chiselled metal.
The Asheron's Call wordmark is bevelled chrome letterforms. The cheap, accurate
way to fake that from a silhouette: take the distance transform of the mask,
treat distance-to-edge as height with a bevel profile, derive surface normals
from the height gradient, then shade those normals through the same matcap the
3D ring uses -- so letter and ring are lit by the same imaginary environment.
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
from scipy.ndimage import distance_transform_edt, gaussian_filter
def bevel_normals(mask, bevel_px=26, profile="chisel", plateau=0.0):
"""mask: HxW float 0..1. Returns (normals HxWx3, height HxW)."""
inside = mask > 0.5
d = distance_transform_edt(inside).astype(np.float32)
t = np.clip(d / max(bevel_px, 1e-6), 0, 1)
if profile == "round":
h = np.sqrt(np.clip(1 - (1 - t) ** 2, 0, 1))
elif profile == "flat":
h = t
else: # chisel: linear ramp to a flat top -- sharp facet break
h = np.clip(t / max(1e-6, (1 - plateau)), 0, 1)
# The distance transform has a crease along the medial axis of every stroke;
# differentiating it raw produces a visible hatched ridge. Smooth the height
# field first -- it costs nothing and removes the artefact entirely.
h = gaussian_filter(h, sigma=max(bevel_px * 0.16, 0.8))
gy, gx = np.gradient(h * bevel_px)
nx, ny, nz = -gx, -gy, np.ones_like(h)
ln = np.sqrt(nx * nx + ny * ny + nz * nz)
n = np.stack([nx / ln, ny / ln, nz / ln], axis=-1)
return n, h
def shade(mask, matcap, bevel_px=26, profile="chisel", plateau=0.35,
ao=0.35, exposure=1.0, tilt=(0.0, 0.0)):
"""Shade a mask as metal. Returns an RGBA image."""
n, h = bevel_normals(mask, bevel_px, profile, plateau)
nx = np.clip(n[..., 0] + tilt[0], -1, 1)
ny = np.clip(n[..., 1] + tilt[1], -1, 1)
mh, mw = matcap.shape[0], matcap.shape[1]
mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32)
mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32)
rgb = matcap[mv, mu][..., :3] * exposure
# darken the bevel skirt so the form reads as raised
rgb = rgb * (1.0 - ao * (1.0 - h))[..., None]
a = np.clip(mask, 0, 1)
out = np.concatenate([np.clip(rgb, 0, 1), a[..., None]], axis=2)
return Image.fromarray((out * 255).astype(np.uint8), "RGBA")
# --------------------------------------------------------------- letterforms
def letter_from_font(ch="A", size=1024, font_path=r"C:\Windows\Fonts\palab.ttf",
fill=0.86):
"""Render a glyph to a mask. A real serif gives correct counters and serifs;
the chisel pass supplies the forged-metal reading."""
from PIL import ImageFont
S = 3
W = size * S
lo, hi = 10, W * 3
best = None
while lo <= hi:
mid = (lo + hi) // 2
f = ImageFont.truetype(font_path, mid)
box = f.getbbox(ch)
w, h = box[2] - box[0], box[3] - box[1]
if max(w, h) <= W * fill:
best = (mid, box); lo = mid + 1
else:
hi = mid - 1
px, box = best
f = ImageFont.truetype(font_path, px)
img = Image.new("L", (W, W), 0)
d = ImageDraw.Draw(img)
w, h = box[2] - box[0], box[3] - box[1]
d.text(((W - w) / 2 - box[0], (W - h) / 2 - box[1]), ch, font=f, fill=255)
return np.asarray(img.resize((size, size), Image.LANCZOS), dtype=np.float32) / 255.0
def letter_A(size=1024, weight=1.0, serif=1.0):
"""A hand-built chiselled 'A' -- angular, sharp apex, flared serif feet.
Hand-built rather than set from a system font: full control over the barbed
terminals that make the AC wordmark look forged, and no font licence riding
along in a project logo.
"""
S = 4 # supersample
W = size * S
img = Image.new("L", (W, W), 0)
d = ImageDraw.Draw(img)
def P(pts):
return [(int(x / 1000 * W), int(y / 1000 * W)) for x, y in pts]
w = 105 * weight
apex_y, foot_y = 70, 880
# two tapered strokes meeting at the apex
left = [(500 - w * 0.28, apex_y), (500 + w * 0.42, apex_y),
(330 + w * 0.55, foot_y), (330 - w * 0.62, foot_y)]
right = [(500 + w * 0.28, apex_y), (500 - w * 0.42, apex_y),
(670 - w * 0.55, foot_y), (670 + w * 0.62, foot_y)]
d.polygon(P(left), fill=255)
d.polygon(P(right), fill=255)
# crossbar, dipped in the middle like the AC wordmark's angular bar
d.polygon(P([(300, 646), (700, 646), (700, 646 + 74), (300, 646 + 74)]), fill=255)
# flared serif feet -- barbed, wider on the outside
sf = 96 * serif
d.polygon(P([(330 - w * 0.62, foot_y), (330 + w * 0.55, foot_y),
(330 + w * 0.55 + sf * 0.35, foot_y + 62),
(330 - w * 0.62 - sf, foot_y + 62),
(330 - w * 0.62 - sf * 1.45, foot_y + 20)]), fill=255)
d.polygon(P([(670 + w * 0.62, foot_y), (670 - w * 0.55, foot_y),
(670 - w * 0.55 - sf * 0.35, foot_y + 62),
(670 + w * 0.62 + sf, foot_y + 62),
(670 + w * 0.62 + sf * 1.45, foot_y + 20)]), fill=255)
# apex spur
d.polygon(P([(500, 34), (500 + w * 0.60, 158), (500 - w * 0.60, 158)]), fill=255)
img = img.resize((size, size), Image.LANCZOS)
return np.asarray(img, dtype=np.float32) / 255.0
def arch_mask(size=1024, R=0.66, thick=0.155, leg_bottom=0.86, wob_seed=5):
"""A standing portal arch: half-ring on two legs that thicken toward the floor."""
y, x = np.mgrid[0:size, 0:size].astype(np.float32)
x = (x / (size - 1)) * 2 - 1
y = (y / (size - 1)) * 2 - 1
cy = -0.10
r = np.sqrt(x * x + (y - cy) ** 2)
th = np.arctan2(y - cy, x)
wob = 0.018 * np.sin(7.0 * th + 1.3) + 0.012 * np.sin(13.0 * th + 0.4)
band = np.abs(r - (R + wob)) < thick / 2
upper = band & (y <= cy)
# legs flare as they descend, like a forged post set into a plinth
flare = thick / 2 * (1.0 + 0.55 * np.clip((y - cy) / max(leg_bottom - cy, 1e-6), 0, 1) ** 1.6)
legs = (np.abs(np.abs(x) - R) < flare) & (y > cy) & (y < leg_bottom)
plinth = (np.abs(y - (leg_bottom + thick * 0.34)) < thick * 0.42) & (np.abs(x) < R + thick * 1.5)
m = (upper | legs | plinth).astype(np.float32)
im = Image.fromarray((m * 255).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(1.2))
return np.asarray(im, dtype=np.float32) / 255.0
def bone_matcap(size=512):
"""Ivory tusk: warm, waxy, low-frequency highlight rather than mirror."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
x = (xx / (size - 1)) * 2 - 1
y = 1 - (yy / (size - 1)) * 2
r2 = x * x + y * y
inside = (r2 <= 1.0).astype(np.float32)
lam = np.clip(0.30 + 0.72 * (y * 0.55 + 0.55), 0, 1.4)
warm = np.array([1.00, 0.965, 0.865], dtype=np.float32)
base = lam[..., None] * warm
base = base + 0.42 * np.exp(-(((x + 0.30) ** 2 + (y - 0.42) ** 2)) / 0.075)[..., None]
# subsurface warmth low down
base = base + 0.20 * np.clip(-y, 0, 1)[..., None] * np.array([0.62, 0.46, 0.28], dtype=np.float32)
base = base * (1.0 - 0.30 * np.clip((r2 - 0.60) / 0.40, 0, 1))[..., None]
return np.concatenate([np.clip(base, 0, 1.4), inside[..., None]], axis=2).astype(np.float32)

112
tools/IconForge/compose.py Normal file
View file

@ -0,0 +1,112 @@
"""Compose the rendered mosswart head into an app-icon badge."""
import math, os
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageChops
HERE = os.path.dirname(os.path.abspath(__file__))
def rounded_mask(size, radius_frac=0.225, ss=4):
W = size * ss
m = Image.new("L", (W, W), 0)
d = ImageDraw.Draw(m)
d.rounded_rectangle([0, 0, W - 1, W - 1], radius=int(W * radius_frac), fill=255)
return m.resize((size, size), Image.LANCZOS)
def circle_mask(size, ss=4):
W = size * ss
m = Image.new("L", (W, W), 0)
ImageDraw.Draw(m).ellipse([0, 0, W - 1, W - 1], fill=255)
return m.resize((size, size), Image.LANCZOS)
def field(size, inner="#2C3A1A", outer="#0B1006", cx=0.5, cy=0.36):
"""Radial swamp-light gradient behind the head."""
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1)
r = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 1.05) ** 2) / 0.78
r = np.clip(r, 0, 1) ** 1.15
ci = np.array([int(inner[i:i + 2], 16) for i in (1, 3, 5)], dtype=np.float32)
co = np.array([int(outer[i:i + 2], 16) for i in (1, 3, 5)], dtype=np.float32)
img = ci[None, None, :] * (1 - r[..., None]) + co[None, None, :] * r[..., None]
return Image.fromarray(img.astype(np.uint8), "RGB").convert("RGBA")
def drop_shadow(subject, blur=26, dy=18, opacity=0.55, spread=1.03):
a = subject.split()[3]
w, h = a.size
s = a.resize((int(w * spread), int(h * spread)), Image.LANCZOS)
canvas = Image.new("L", (w, h), 0)
canvas.paste(s, ((w - s.width) // 2, (h - s.height) // 2 + dy))
canvas = canvas.filter(ImageFilter.GaussianBlur(blur))
canvas = canvas.point(lambda v: int(v * opacity))
sh = Image.new("RGBA", (w, h), (0, 0, 0, 0))
sh.putalpha(canvas)
return sh
def fit_subject(sub, size, occupancy=0.86, offset_y=0.0):
"""Trim to content, scale so the longest side hits `occupancy`, centre it."""
bbox = sub.split()[3].getbbox()
sub = sub.crop(bbox)
scale = (size * occupancy) / max(sub.width, sub.height)
sub = sub.resize((max(1, int(sub.width * scale)), max(1, int(sub.height * scale))),
Image.LANCZOS)
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.paste(sub, ((size - sub.width) // 2,
int((size - sub.height) / 2 + size * offset_y)), sub)
return out
def badge(subject, size=1024, shape="rounded", occupancy=0.84, offset_y=0.02,
inner="#2C3A1A", outer="#0B1006", shadow=True, rim=True):
bg = field(size, inner, outer)
if rim:
# faint inner rim so the badge has an edge in dark UI
ring = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(ring)
d.rounded_rectangle([2, 2, size - 3, size - 3],
radius=int(size * 0.225) if shape == "rounded" else size // 2,
outline=(150, 172, 92, 46), width=max(2, size // 220))
bg = Image.alpha_composite(bg, ring)
sub = fit_subject(subject, size, occupancy, offset_y)
if shadow:
bg = Image.alpha_composite(bg, drop_shadow(sub, blur=int(size * 0.028),
dy=int(size * 0.018)))
out = Image.alpha_composite(bg, sub)
mask = rounded_mask(size) if shape == "rounded" else circle_mask(size)
out.putalpha(ImageChops.multiply(out.split()[3], mask))
return out
def free(subject, size=1024, occupancy=0.94):
"""No field -- the head alone on transparency."""
return fit_subject(subject, size, occupancy)
def contact(images, labels, cell=280, pad=14, bg=(22, 24, 20)):
sheet = Image.new("RGB", (len(images) * cell, cell + 24), bg)
d = ImageDraw.Draw(sheet)
for i, (im, lb) in enumerate(zip(images, labels)):
t = im.copy().resize((cell - 2 * pad, cell - 2 * pad), Image.LANCZOS)
base = Image.new("RGBA", t.size, bg + (255,))
sheet.paste(Image.alpha_composite(base, t).convert("RGB"), (i * cell + pad, pad))
d.text((i * cell + pad, cell + 4), lb, fill=(200, 210, 170))
return sheet
def size_strip(icon, sizes=(128, 64, 48, 32, 24, 16), bg=(22, 24, 20)):
W = sum(s + 18 for s in sizes) + 20
H = max(sizes) + 34
sheet = Image.new("RGB", (W, H), bg)
d = ImageDraw.Draw(sheet)
x = 12
for s in sizes:
t = icon.resize((s, s), Image.LANCZOS)
base = Image.new("RGBA", t.size, bg + (255,))
sheet.paste(Image.alpha_composite(base, t).convert("RGB"), (x, 10 + (max(sizes) - s)))
d.text((x, H - 18), "%d" % s, fill=(200, 210, 170))
x += s + 18
return sheet

144
tools/IconForge/forge.py Normal file
View file

@ -0,0 +1,144 @@
"""Regenerate acdream's application icons into assets/icons/.
py tools/IconForge/forge.py launcher # procedural, no game data needed
py tools/IconForge/forge.py client # needs a mosswart mesh export
py tools/IconForge/forge.py all
The launcher mark is entirely procedural: a forged ring and the crescent glyph
are generated from geometry in ring.py / ac_glyph.py and shaded through a
matcap, so it rebuilds anywhere.
The client mark renders the retail mosswart head, so it needs two inputs that
come out of the installed DATs (see README.md):
mosswart_mesh.json geometry export, Setup 0x02000B4F
textures/0x05001E11... the skin atlas and its siblings
Both default to --work, which is where those extraction steps write.
"""
import argparse
import os
import sys
import numpy as np
from PIL import Image, ImageChops, ImageFilter
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import ac_glyph as ag # noqa: E402
import chisel # noqa: E402
import compose # noqa: E402
import launcher as lz # noqa: E402
import render # noqa: E402
import ring # noqa: E402
import smooth # noqa: E402
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
ASSETS = os.path.join(REPO, "assets", "icons")
# Sizes written as loose PNGs, and the subset baked into the .ico.
PNG_SIZES = (1024, 512, 256, 128, 64, 48, 32, 24, 16)
ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
# The head is part 14 of the mosswart Setup; see assets/icons/README.md.
HEAD_PART = 14
def _emit(master, stem):
os.makedirs(ASSETS, exist_ok=True)
master.save(os.path.join(ASSETS, f"{stem}-1024.png"))
for s in PNG_SIZES:
if s == 1024:
continue
master.resize((s, s), Image.LANCZOS).save(os.path.join(ASSETS, f"{stem}-{s}.png"))
master.save(os.path.join(ASSETS, f"{stem}.ico"), sizes=ICO_SIZES)
print(f" wrote {stem}-*.png and {stem}.ico into assets/icons/")
def build_launcher(size=1024):
"""Forged ring + barbed crescent, on the retail icon's warm field."""
chrome = ring.chrome_matcap(768)
def shade(mask, bevel, ao, exposure=1.0):
return chisel.shade(ag.soften(mask, 1.2), chrome,
bevel_px=int(size * bevel), profile="chisel",
plateau=0.0, ao=ao, exposure=exposure)
ring_img = shade(ag.thin_ring(size, R=0.86, thick=0.058), 0.026, 0.42)
glyph_img = shade(ag.ac_glyph(size, R=0.68, inner_r=0.605, offset=0.275,
rot_deg=-14), 0.034, 0.38, 1.06)
# Glyph over ring with a soft cast shadow, so the ring reads as passing
# behind the crescent the way it does in the retail wordmark.
def over(base, top):
a = top.split()[3].filter(ImageFilter.GaussianBlur(int(size * 0.012)))
a = ImageChops.offset(a, 0, int(size * 0.006)).point(lambda v: int(v * 0.55))
sh = Image.new("RGBA", base.size, (0, 0, 0, 0))
sh.putalpha(a)
return Image.alpha_composite(Image.alpha_composite(base, sh), top)
sigil = over(Image.new("RGBA", (size, size), (0, 0, 0, 0)), ring_img)
sigil = over(sigil, glyph_img)
out, _ = lz.place(compose.field(size, "#3A3418", "#070803"), sigil, 0.94)
out.putalpha(ImageChops.multiply(out.split()[3], compose.rounded_mask(size)))
return out
def build_client(work, size=1024):
"""The retail mosswart head, smoothed, lit, and badged."""
mesh_path = os.path.join(work, "mosswart_mesh.json")
if not os.path.exists(mesh_path):
raise SystemExit(
f"missing {mesh_path}\n"
"Export it first (see assets/icons/README.md) -- the client mark is\n"
"rendered from the installed DATs, not from committed geometry.")
textures = render.load_textures(render.all_texture_ids(mesh_path))
if not textures:
raise SystemExit(
"no textures resolved. Dump the mosswart surfaces first "
"(see assets/icons/README.md).")
raw = render.load_mesh(mesh_path, parts_filter={HEAD_PART}, ignore_transform=True)
head = smooth.smooth_mesh(raw, crease_deg=52, level=6)
# az/el chosen because a mosswart's eyes sit on the sides of the skull like
# a frog's: dead-on frontal turns them edge-on and the face stops reading.
hero = render.render(
head, textures, size=size, ss=3, fit=0.90, fov=30, az=266, el=32,
key_col=(0.92, 0.94, 0.76), amb_top=(0.17, 0.22, 0.14),
amb_bot=(0.03, 0.04, 0.02), fill_col=(0.13, 0.19, 0.10),
rim_col=(0.58, 0.80, 0.28), spec_amt=0.46, spec_pow=20.0,
exposure=0.88, gamma=1.28,
key_cam=(-0.45, 0.55, 0.70), fill_cam=(0.80, 0.05, 0.35),
rim_cam=(0.50, 0.35, -0.62))
return compose.badge(hero, size, "rounded", occupancy=0.74, offset_y=0.02,
inner="#243014", outer="#070A04")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("target", choices=("launcher", "client", "all"))
ap.add_argument("--work", default=os.path.join(HERE, "work"),
help="directory holding the DAT export inputs (client only)")
ap.add_argument("--size", type=int, default=1024)
args = ap.parse_args()
if args.target in ("launcher", "all"):
print("forging launcher icon...")
_emit(build_launcher(args.size), "acdream-launcher")
if args.target in ("client", "all"):
print("forging client icon...")
render.TEXDIR = os.path.join(args.work, "textures")
_emit(build_client(args.work, args.size), "acdream-client")
print("done.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,76 @@
"""Launcher icon studies: the Asheron's Call ring motif, made acdream's own."""
import numpy as np
from PIL import Image, ImageFilter, ImageChops
import render, ring, compose, smooth
def portal_swirl(size=1024, arms=2.0, twist=2.9,
core=(0.92, 0.95, 1.00), mid=(0.36, 0.52, 0.95),
outer=(0.30, 0.14, 0.52)):
"""Procedural log-spiral vortex -- AC's other signature image."""
y, x = np.mgrid[0:size, 0:size].astype(np.float32)
x = (x / (size - 1)) * 2 - 1
y = (y / (size - 1)) * 2 - 1
r = np.sqrt(x * x + y * y)
th = np.arctan2(y, x)
safe = np.clip(r, 1e-3, None)
spiral = np.sin(arms * th + twist * np.log(safe) * 3.2)
spiral = (spiral * 0.5 + 0.5) ** 1.15
# gentler falloff + a much wider core, so the vortex fills the ring rather
# than sitting in the middle of it as a speck
falloff = np.clip(1.0 - r / 1.05, 0, 1) ** 0.85
corebloom = np.exp(-(r ** 2) / 0.075)
t = np.clip(r / 0.9, 0, 1)[..., None]
ramp = (np.array(mid, dtype=np.float32)[None, None, :] * (1 - t)
+ np.array(outer, dtype=np.float32)[None, None, :] * t)
rgb = ramp * (0.42 + 1.25 * spiral)[..., None] * falloff[..., None]
rgb = rgb + np.array(core, dtype=np.float32)[None, None, :] * corebloom[..., None]
a = np.clip(falloff * (0.55 + 0.95 * spiral) + corebloom, 0, 1)
img = np.concatenate([np.clip(rgb, 0, 1), a[..., None]], axis=2)
return Image.fromarray((img * 255).astype(np.uint8), "RGBA")
def inner_shadow(subject_alpha, size, blur=22, opacity=0.6, dy=10):
"""Soft shadow cast by the ring onto whatever sits inside it."""
s = subject_alpha.filter(ImageFilter.GaussianBlur(blur))
s = ImageChops.offset(s, 0, dy)
s = s.point(lambda v: int(v * opacity))
sh = Image.new("RGBA", (size, size), (0, 0, 0, 0))
sh.putalpha(s)
return sh
def render_ring(size=1024, matcap=None, gap_deg=26.0, el=0.0, exposure=1.0,
gap_center_deg=90.0, R=1.0, r=0.135):
mc = {"RING": matcap if matcap is not None else ring.chrome_matcap(768)}
rp, rn, ruv, rt = ring.forged_ring(R=R, r=r, gap_deg=gap_deg,
gap_center_deg=gap_center_deg)
mesh = (rp, rn, ruv, rt, ["RING"] * len(rt))
return render.render(mesh, {}, size=size, ss=3, az=270, el=el, fov=30,
fit=1.0, matcaps=mc, exposure=exposure)
def render_hook(size=1024, matcap=None, exposure=1.0, scale=0.60):
mc = {"RING": matcap if matcap is not None else ring.chrome_matcap(768)}
gp, gn, guv, gt = ring.hook_glyph(scale=scale)
mesh = (gp, gn, guv, gt, ["RING"] * len(gt))
return render.render(mesh, {}, size=size, ss=3, az=270, el=0, fov=30,
fit=1.0, matcaps=mc, exposure=exposure)
def place(canvas, sub, occupancy, offset=(0.0, 0.0)):
"""Scale `sub` to `occupancy` of the canvas and paste it centred + offset."""
size = canvas.size[0]
bbox = sub.split()[3].getbbox()
s = sub.crop(bbox)
k = (size * occupancy) / max(s.width, s.height)
s = s.resize((max(1, int(s.width * k)), max(1, int(s.height * k))), Image.LANCZOS)
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
layer.paste(s, (int((size - s.width) / 2 + size * offset[0]),
int((size - s.height) / 2 + size * offset[1])), s)
return Image.alpha_composite(canvas, layer), layer

236
tools/IconForge/render.py Normal file
View file

@ -0,0 +1,236 @@
"""Software rasterizer for the exported mosswart mesh.
Loads the JSON dumped by tools/MosswartArt, textures dumped by tools/IconExtract,
and renders a lit, shaded, supersampled image. Deliberately simple: z-buffer,
perspective camera, Lambert key/fill/rim + Blinn specular for wet skin.
"""
import json, glob, os, math
import numpy as np
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
TEXDIR = os.path.join(ROOT, "tools", "IconExtract", "bin", "Release", "net10.0", "out")
# ---------------------------------------------------------------- data loading
def load_textures(ids):
out = {}
for tid in ids:
hits = glob.glob(os.path.join(TEXDIR, tid + "_*.png"))
if not hits:
continue
im = Image.open(hits[0]).convert("RGBA")
out[tid] = np.asarray(im, dtype=np.float32) / 255.0
return out
def load_mesh(path, parts_filter=None, ignore_transform=False):
doc = json.load(open(path))
P, N, UV, TRI, TEX = [], [], [], [], []
for part in doc["parts"]:
if parts_filter is not None and part["index"] not in parts_filter:
continue
m = np.array(part["m"], dtype=np.float64).reshape(4, 4)
if ignore_transform:
m = np.eye(4)
# System.Numerics Matrix4x4 is row-vector convention: v' = v * M
rot = m[:3, :3]
trans = m[3, :3]
for sub in part["sub"]:
v = np.array(sub["v"], dtype=np.float64)
if len(v) == 0:
continue
base = len(P)
pos = v[:, 0:3] @ rot + trans
nrm = v[:, 3:6] @ rot
ln = np.linalg.norm(nrm, axis=1, keepdims=True)
nrm = np.divide(nrm, np.where(ln == 0, 1, ln))
P.append(pos)
N.append(nrm)
UV.append(v[:, 6:8])
idx = np.array(sub["i"], dtype=np.int64).reshape(-1, 3) + base
TRI.append(idx)
TEX += [sub["tex"]] * len(idx)
return (np.concatenate(P), np.concatenate(N), np.concatenate(UV),
np.concatenate(TRI), TEX)
# ---------------------------------------------------------------- math helpers
def look_at(eye, target, up=np.array([0.0, 0.0, 1.0])):
f = target - eye
f = f / np.linalg.norm(f)
s = np.cross(f, up)
if np.linalg.norm(s) < 1e-9:
s = np.cross(f, np.array([0.0, 1.0, 0.0]))
s = s / np.linalg.norm(s)
u = np.cross(s, f)
return np.stack([s, u, -f]) # rows: right, up, back
def orbit_eye(target, radius, az_deg, el_deg):
az, el = math.radians(az_deg), math.radians(el_deg)
return target + radius * np.array([
math.cos(el) * math.cos(az),
math.cos(el) * math.sin(az),
math.sin(el)])
# ---------------------------------------------------------------- rasterizer
def render(mesh, textures, size=512, ss=3, az=90.0, el=8.0, fov=26.0,
target=None, radius=None, fit=1.0,
key=(-0.45, -0.85, 0.45), rim=(0.7, 0.75, -0.15),
bg=(0, 0, 0, 0), key_col=(1.06, 1.04, 0.92), amb_top=(0.30, 0.34, 0.24),
amb_bot=(0.10, 0.10, 0.06), rim_col=(0.55, 0.72, 0.30), spec_pow=26.0,
spec_amt=0.42, key_cam=None, rim_cam=None, fill_cam=None,
fill_col=(0.0, 0.0, 0.0), exposure=1.0, gamma=1.0, matcaps=None):
P, N, UV, TRI, TEX = mesh
W = size * ss
if target is None:
lo, hi = P.min(axis=0), P.max(axis=0)
target = (lo + hi) / 2.0
if radius is None:
ext = np.linalg.norm(P.max(axis=0) - P.min(axis=0))
radius = ext / (2.0 * math.tan(math.radians(fov) / 2.0)) * fit
eye = orbit_eye(target, radius, az, el)
V = look_at(eye, target)
cam = (P - eye) @ V.T
ncam = N @ V.T
f = 1.0 / math.tan(math.radians(fov) / 2.0)
z = -cam[:, 2]
z = np.where(z < 1e-6, 1e-6, z)
sx = (cam[:, 0] * f / z * 0.5 + 0.5) * W
sy = (1.0 - (cam[:, 1] * f / z * 0.5 + 0.5)) * W
color = np.zeros((W, W, 3), dtype=np.float32)
alpha = np.zeros((W, W), dtype=np.float32)
depth = np.full((W, W), 1e30, dtype=np.float32)
keyd = np.array(key, dtype=np.float64); keyd /= np.linalg.norm(keyd)
rimd = np.array(rim, dtype=np.float64); rimd /= np.linalg.norm(rimd)
keyc = V @ keyd
rimc = V @ rimd
# Camera-relative light directions make art direction repeatable across
# camera angles -- the key stays on the same side of the face regardless
# of where the model happens to be facing in world space.
if key_cam is not None:
keyc = np.array(key_cam, dtype=np.float64); keyc /= np.linalg.norm(keyc)
if rim_cam is not None:
rimc = np.array(rim_cam, dtype=np.float64); rimc /= np.linalg.norm(rimc)
fillc = None
if fill_cam is not None:
fillc = np.array(fill_cam, dtype=np.float64); fillc /= np.linalg.norm(fillc)
viewd = np.array([0.0, 0.0, 1.0])
half = keyc + viewd
half = half / np.linalg.norm(half)
for t in range(len(TRI)):
i0, i1, i2 = TRI[t]
x0, y0 = sx[i0], sy[i0]
x1, y1 = sx[i1], sy[i1]
x2, y2 = sx[i2], sy[i2]
area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0)
if abs(area) < 1e-9:
continue
minx = max(int(math.floor(min(x0, x1, x2))), 0)
maxx = min(int(math.ceil(max(x0, x1, x2))), W - 1)
miny = max(int(math.floor(min(y0, y1, y2))), 0)
maxy = min(int(math.ceil(max(y0, y1, y2))), W - 1)
if minx > maxx or miny > maxy:
continue
xs = np.arange(minx, maxx + 1)
ys = np.arange(miny, maxy + 1)
gx, gy = np.meshgrid(xs + 0.5, ys + 0.5)
w0 = ((x1 - gx) * (y2 - gy) - (x2 - gx) * (y1 - gy)) / area
w1 = ((x2 - gx) * (y0 - gy) - (x0 - gx) * (y2 - gy)) / area
w2 = 1.0 - w0 - w1
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
if not inside.any():
continue
zt = w0 * z[i0] + w1 * z[i1] + w2 * z[i2]
sub = depth[miny:maxy + 1, minx:maxx + 1]
closer = inside & (zt < sub)
if not closer.any():
continue
u = w0 * UV[i0, 0] + w1 * UV[i1, 0] + w2 * UV[i2, 0]
v = w0 * UV[i0, 1] + w1 * UV[i1, 1] + w2 * UV[i2, 1]
nx = w0 * ncam[i0, 0] + w1 * ncam[i1, 0] + w2 * ncam[i2, 0]
ny = w0 * ncam[i0, 1] + w1 * ncam[i1, 1] + w2 * ncam[i2, 1]
nz = w0 * ncam[i0, 2] + w1 * ncam[i1, 2] + w2 * ncam[i2, 2]
nl = np.sqrt(nx * nx + ny * ny + nz * nz)
nl = np.where(nl == 0, 1, nl)
nx, ny, nz = nx / nl, ny / nl, nz / nl
# two-sided: AC meshes are frequently single-sided but authored either way
flip = nz < 0
nx = np.where(flip, -nx, nx); ny = np.where(flip, -ny, ny); nz = np.where(flip, -nz, nz)
tex = textures.get(TEX[t])
if tex is None:
rgb = np.ones(u.shape + (3,), dtype=np.float32) * 0.6
ta = np.ones(u.shape, dtype=np.float32)
else:
th, tw = tex.shape[0], tex.shape[1]
fu = (u % 1.0) * (tw - 1)
fv = (v % 1.0) * (th - 1)
u0 = np.clip(np.floor(fu).astype(np.int32), 0, tw - 1)
v0 = np.clip(np.floor(fv).astype(np.int32), 0, th - 1)
u1 = np.clip(u0 + 1, 0, tw - 1)
v1 = np.clip(v0 + 1, 0, th - 1)
du = (fu - u0)[..., None]
dv = (fv - v0)[..., None]
texel = (tex[v0, u0] * (1 - du) * (1 - dv) + tex[v0, u1] * du * (1 - dv)
+ tex[v1, u0] * (1 - du) * dv + tex[v1, u1] * du * dv)
rgb = texel[..., :3]
ta = texel[..., 3]
ndl = np.clip(nx * keyc[0] + ny * keyc[1] + nz * keyc[2], 0, 1)
ndr = np.clip(nx * rimc[0] + ny * rimc[1] + nz * rimc[2], 0, 1) ** 2.4
up = np.clip(nz * 0.35 + 0.65, 0, 1)
amb = (np.array(amb_bot)[None, None, :] +
(np.array(amb_top) - np.array(amb_bot))[None, None, :] * up[..., None])
ndh = np.clip(nx * half[0] + ny * half[1] + nz * half[2], 0, 1)
spec = (ndh ** spec_pow) * spec_amt * ndl
lightsum = amb + np.array(key_col)[None, None, :] * ndl[..., None]
if fillc is not None:
ndf = np.clip(nx * fillc[0] + ny * fillc[1] + nz * fillc[2], 0, 1)
lightsum = lightsum + np.array(fill_col)[None, None, :] * ndf[..., None]
lit = (rgb * lightsum
+ np.array(rim_col)[None, None, :] * ndr[..., None]
+ spec[..., None]) * exposure
if gamma != 1.0:
lit = np.clip(lit, 0, None) ** gamma
# Metal: a Lambert term cannot produce chrome, because chrome is almost
# entirely reflection. Sample a matcap by the camera-space normal instead.
mc = matcaps.get(TEX[t]) if matcaps else None
if mc is not None:
mh, mw = mc.shape[0], mc.shape[1]
mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32)
mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32)
lit = mc[mv, mu][..., :3] * exposure
ta = np.ones(u.shape, dtype=np.float32)
m = closer & (ta > 0.35)
if not m.any():
continue
ys_i, xs_i = np.nonzero(m)
color[miny + ys_i, minx + xs_i] = lit[ys_i, xs_i]
alpha[miny + ys_i, minx + xs_i] = 1.0
depth[miny + ys_i, minx + xs_i] = zt[ys_i, xs_i]
rgba = np.concatenate([np.clip(color, 0, 1), alpha[..., None]], axis=2)
img = Image.fromarray((rgba * 255).astype(np.uint8), "RGBA")
return img.resize((size, size), Image.LANCZOS)
def all_texture_ids(path):
doc = json.load(open(path))
return sorted({s["tex"] for p in doc["parts"] for s in p["sub"]})

170
tools/IconForge/ring.py Normal file
View file

@ -0,0 +1,170 @@
"""A forged metal ring, plus the matcap needed to shade it as chrome.
The Asheron's Call mark is a broken, hand-forged silver band enclosing a hooked
glyph. A plain torus reads as a donut, so the tube radius is noise-modulated
along the major angle and tapered to points at the break.
Chrome needs environment reflection, which a Lambert rasterizer cannot give.
A matcap (material capture) solves it: one image of a lit sphere, sampled by the
camera-space normal. It is the standard cheap stand-in for a full env map.
"""
import numpy as np
def _fbm(theta, seed=7, octaves=4):
rng = np.random.default_rng(seed)
out = np.zeros_like(theta)
amp, freq = 1.0, 3.0
for _ in range(octaves):
phase = rng.uniform(0, 2 * np.pi)
out += amp * np.sin(freq * theta + phase)
amp *= 0.5
freq *= 2.0
return out / 1.9
def forged_ring(R=1.0, r=0.135, nu=320, nv=44, gap_deg=26.0, gap_center_deg=90.0,
rough=0.30, taper=2.2, seed=7, end_frac=0.16):
"""Broken forged band in the XZ plane (so it faces a -Y camera)."""
span = 360.0 - gap_deg
start = gap_center_deg + gap_deg / 2.0
u = np.radians(start + np.linspace(0.0, span, nu))
v = np.linspace(0.0, 2 * np.pi, nv)
t = np.linspace(0.0, 1.0, nu)
# taper both ends of the band to points, and rough up the middle
# only taper the last end_frac at each end, so the band stays a band
ramp = np.clip(np.minimum(t, 1.0 - t) / max(end_frac, 1e-6), 0.0, 1.0)
ends = ramp ** (1.0 / taper)
tube = r * ends * (1.0 + rough * _fbm(u * 1.7, seed))
tube = np.maximum(tube, r * 0.05)
U, V = np.meshgrid(u, v, indexing="ij")
T = np.broadcast_to(tube[:, None], U.shape)
# slight out-of-plane wobble so it reads hand-made, not machined
wob = 0.035 * _fbm(u * 2.3, seed + 3)[:, None]
cx, cy = np.cos(U), np.sin(U)
px = (R + T * np.cos(V)) * cx
pz = (R + T * np.cos(V)) * cy
py = T * np.sin(V) + wob
P = np.stack([px, py, pz], axis=-1).reshape(-1, 3)
# analytic-ish normals: outward from the tube centreline
ccx = R * cx
ccz = R * cy
ccy = np.zeros_like(ccx) + wob
C = np.stack([ccx, ccy, ccz], axis=-1).reshape(-1, 3)
N = P - C
ln = np.linalg.norm(N, axis=1, keepdims=True)
N = N / np.where(ln < 1e-9, 1, ln)
UV = np.stack([U / (2 * np.pi), V / (2 * np.pi)], axis=-1).reshape(-1, 2)
tri = []
for i in range(nu - 1):
for j in range(nv - 1):
a = i * nv + j
b = (i + 1) * nv + j
c = i * nv + (j + 1)
d = (i + 1) * nv + (j + 1)
tri.append((a, b, c))
tri.append((b, d, c))
return P, N, UV, np.array(tri, dtype=np.int64)
def hook_glyph(scale=0.62, thick=0.115, nu=200, nv=28, seed=11):
"""A tapering crescent hook, echoing the glyph inside the AC ring."""
t = np.linspace(0.0, 1.0, nu)
ang = np.radians(118.0 + t * 250.0)
rad = scale * (1.0 - 0.30 * t)
cx = np.cos(ang) * rad
cz = np.sin(ang) * rad
# taper: fat at the shoulder, needle at the tip
tube = thick * (np.sin(np.pi * (0.18 + 0.82 * t)) ** 0.85) * (1.0 - 0.55 * t)
tube = np.maximum(tube, thick * 0.04)
v = np.linspace(0.0, 2 * np.pi, nv)
U, V = np.meshgrid(t, v, indexing="ij")
T = np.broadcast_to(tube[:, None], U.shape)
# local frame along the curve
dx = np.gradient(cx); dz = np.gradient(cz)
tl = np.sqrt(dx * dx + dz * dz); tl = np.where(tl < 1e-9, 1, tl)
tx, tz = dx / tl, dz / tl
nx_, nz_ = -tz, tx # in-plane normal
P = np.stack([
(cx[:, None] + T * np.cos(V) * nx_[:, None]),
(T * np.sin(V)),
(cz[:, None] + T * np.cos(V) * nz_[:, None]),
], axis=-1).reshape(-1, 3)
C = np.stack([
np.broadcast_to(cx[:, None], U.shape),
np.zeros_like(U),
np.broadcast_to(cz[:, None], U.shape),
], axis=-1).reshape(-1, 3)
N = P - C
ln = np.linalg.norm(N, axis=1, keepdims=True)
N = N / np.where(ln < 1e-9, 1, ln)
UV = np.stack([U, V / (2 * np.pi)], axis=-1).reshape(-1, 2)
tri = []
for i in range(nu - 1):
for j in range(nv - 1):
a = i * nv + j; b = (i + 1) * nv + j
c = i * nv + (j + 1); d = (i + 1) * nv + (j + 1)
tri.append((a, b, c)); tri.append((b, d, c))
return P, N, UV, np.array(tri, dtype=np.int64)
def chrome_matcap(size=512, tint=(1.0, 1.0, 1.06), warm=(0.62, 0.55, 0.42)):
"""Polished-silver matcap: bright sky above, dark horizon, warm ground."""
y, x = np.mgrid[0:size, 0:size].astype(np.float32)
x = (x / (size - 1)) * 2 - 1
y = 1 - (y / (size - 1)) * 2
r2 = x * x + y * y
inside = r2 <= 1.0
z = np.sqrt(np.clip(1 - r2, 0, 1))
sky = np.clip(y * 0.5 + 0.5, 0, 1)
# sharp horizon band -- what makes metal read as metal
horizon = np.exp(-((y + 0.06) ** 2) / 0.0026)
ground = np.clip(-y * 0.9 + 0.15, 0, 1)
base = (0.16 + 0.72 * sky ** 1.7)[..., None] * np.array(tint, dtype=np.float32)
base = base + 0.55 * horizon[..., None] * np.array([0.85, 0.90, 1.0], dtype=np.float32)
base = base + 0.42 * (ground ** 1.6)[..., None] * np.array(warm, dtype=np.float32)
# key specular + a secondary glint
spec = np.exp(-(((x + 0.36) ** 2 + (y - 0.46) ** 2)) / 0.020)
spec2 = np.exp(-(((x - 0.44) ** 2 + (y - 0.16) ** 2)) / 0.055)
base = base + 1.5 * spec[..., None] + 0.40 * spec2[..., None]
# rim brightening at grazing angles
base = base + 0.55 * np.clip((r2 - 0.72) / 0.28, 0, 1)[..., None]
rgb = np.clip(base, 0, 1.6)
a = inside.astype(np.float32)
return np.concatenate([rgb, a[..., None]], axis=2).astype(np.float32)
def verdigris_matcap(size=512):
"""Same form, aged bronze-green -- ties the ring to the mosswart palette."""
m = chrome_matcap(size, tint=(0.72, 0.86, 0.58), warm=(0.50, 0.44, 0.18))
m[..., 0] *= 0.78
m[..., 1] *= 0.96
m[..., 2] *= 0.62
return m
def merge(*meshes):
"""Concatenate (P,N,UV,TRI,TEX) tuples into one mesh."""
P, N, UV, TRI, TEX = [], [], [], [], []
base = 0
for p, n, uv, tri, tex in meshes:
P.append(p); N.append(n); UV.append(uv)
TRI.append(tri + base)
TEX += tex
base += len(p)
return (np.concatenate(P), np.concatenate(N), np.concatenate(UV),
np.concatenate(TRI), TEX)

124
tools/IconForge/smooth.py Normal file
View file

@ -0,0 +1,124 @@
"""Turn the retail low-poly head into something that can carry a 3D-realistic icon.
Two steps, both standard:
1. Crease-aware normal welding. The dat mesh stores one normal per (position,uv)
pair, so shared corners come back faceted. We average normals across
coincident positions, but only between faces whose normals are within a
crease angle -- so the ear fins and tusk edges stay sharp while the skull
rounds off.
2. PN-triangle tessellation (Vlachos et al. 2001). Each flat triangle becomes a
cubic Bezier patch built from its own corner positions and normals, with
quadratically-interpolated normals. It rounds the silhouette without needing
mesh connectivity, so UV seams cannot pull apart.
"""
import numpy as np
def weld_normals(P, N, crease_deg=52.0, decimals=5):
"""Average normals across coincident positions within a crease angle."""
key = np.round(P, decimals)
_, inv = np.unique(key, axis=0, return_inverse=True)
inv = inv.ravel()
out = N.copy()
cos_t = np.cos(np.radians(crease_deg))
order = np.argsort(inv, kind="stable")
inv_sorted = inv[order]
bounds = np.flatnonzero(np.diff(inv_sorted)) + 1
for grp in np.split(order, bounds):
if len(grp) < 2:
continue
n = N[grp]
for a in range(len(grp)):
sel = n @ n[a] >= cos_t
acc = n[sel].sum(axis=0)
ln = np.linalg.norm(acc)
if ln > 1e-9:
out[grp[a]] = acc / ln
return out
def _pn_patch(p1, p2, p3, n1, n2, n3):
def edge(pa, pb, na):
w = np.einsum("ij,ij->i", pb - pa, na)
return (2 * pa + pb - w[:, None] * na) / 3.0
b210 = edge(p1, p2, n1); b120 = edge(p2, p1, n2)
b021 = edge(p2, p3, n2); b012 = edge(p3, p2, n3)
b102 = edge(p3, p1, n3); b201 = edge(p1, p3, n1)
E = (b210 + b120 + b021 + b012 + b102 + b201) / 6.0
V = (p1 + p2 + p3) / 3.0
b111 = E + (E - V) / 2.0
def nedge(pa, pb, na, nb):
d = pb - pa
denom = np.einsum("ij,ij->i", d, d)
denom = np.where(denom < 1e-12, 1e-12, denom)
v = 2.0 * np.einsum("ij,ij->i", d, na + nb) / denom
r = na + nb - v[:, None] * d
ln = np.linalg.norm(r, axis=1, keepdims=True)
return r / np.where(ln < 1e-9, 1, ln)
n110 = nedge(p1, p2, n1, n2)
n011 = nedge(p2, p3, n2, n3)
n101 = nedge(p3, p1, n3, n1)
return (b210, b120, b021, b012, b102, b201, b111, n110, n011, n101)
def pn_tessellate(P, N, UV, TRI, level=4):
"""Subdivide every triangle into level^2 sub-triangles on its PN patch."""
i1, i2, i3 = TRI[:, 0], TRI[:, 1], TRI[:, 2]
p1, p2, p3 = P[i1], P[i2], P[i3]
n1, n2, n3 = N[i1], N[i2], N[i3]
t1, t2, t3 = UV[i1], UV[i2], UV[i3]
(b210, b120, b021, b012, b102, b201, b111,
n110, n011, n101) = _pn_patch(p1, p2, p3, n1, n2, n3)
# barycentric lattice
lat, lat_index = [], {}
for i in range(level + 1):
for j in range(level + 1 - i):
lat_index[(i, j)] = len(lat)
lat.append((i / level, j / level))
lat = np.array(lat)
T = len(TRI)
L = len(lat)
newP = np.empty((T, L, 3)); newN = np.empty((T, L, 3)); newUV = np.empty((T, L, 2))
for k, (u, v) in enumerate(lat):
w = 1.0 - u - v
pos = (p1 * (w ** 3) + p2 * (u ** 3) + p3 * (v ** 3)
+ b210 * (3 * w * w * u) + b120 * (3 * w * u * u)
+ b021 * (3 * u * u * v) + b012 * (3 * u * v * v)
+ b102 * (3 * w * v * v) + b201 * (3 * w * w * v)
+ b111 * (6 * w * u * v))
nrm = (n1 * (w * w) + n2 * (u * u) + n3 * (v * v)
+ n110 * (2 * w * u) + n011 * (2 * u * v) + n101 * (2 * w * v))
ln = np.linalg.norm(nrm, axis=1, keepdims=True)
newP[:, k] = pos
newN[:, k] = nrm / np.where(ln < 1e-9, 1, ln)
newUV[:, k] = t1 * w + t2 * u + t3 * v
tris = []
for i in range(level):
for j in range(level - i):
a = lat_index[(i, j)]; b = lat_index[(i + 1, j)]; c = lat_index[(i, j + 1)]
tris.append((a, b, c))
if i + j < level - 1:
dd = lat_index[(i + 1, j + 1)]
tris.append((b, dd, c))
tris = np.array(tris, dtype=np.int64)
offs = (np.arange(T) * L)[:, None, None]
TRI2 = (tris[None, :, :] + offs).reshape(-1, 3)
return (newP.reshape(-1, 3), newN.reshape(-1, 3), newUV.reshape(-1, 2), TRI2)
def smooth_mesh(mesh, crease_deg=52.0, level=4):
P, N, UV, TRI, TEX = mesh
N2 = weld_normals(P, N, crease_deg)
P3, N3, UV3, TRI3 = pn_tessellate(P, N2, UV, TRI, level)
per = len(TRI3) // len(TRI)
TEX3 = [t for t in TEX for _ in range(per)]
return (P3, N3, UV3, TRI3, TEX3)

143
tools/MosswartArt/Export.cs Normal file
View file

@ -0,0 +1,143 @@
// Export a creature Setup's geometry (per part, with UVs + normals + the
// ClothingBase texture substitutions applied) to JSON so an offline rasterizer
// can render it. Uses acdream's own tested GfxObjMesh/SetupMesh port rather than
// re-deriving the dat mesh format.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using AcDream.Core.Content;
using AcDream.Core.Meshing;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
namespace MosswartArt;
internal sealed class DatSource(DatCollection dats) : IDatObjectSource
{
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj
=> dats.TryGet<T>(fileId, out var v) ? v : default;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
=> dats.TryGet(fileId, out value!);
}
internal sealed class AnimLoader(DatCollection dats) : AcDream.Core.Physics.IAnimationLoader
{
public Animation? LoadAnimation(uint id)
=> dats.TryGet<Animation>(id, out var a) ? a : null;
}
internal static class Export
{
private static string F(float v) => v.ToString("R", CultureInfo.InvariantCulture);
public static int Run(DatCollection dats, uint setupId, uint clothingId, string outPath, uint motionTableId = 0)
{
if (!dats.TryGet<Setup>(setupId, out var setup) || setup is null)
{
Console.Error.WriteLine($"Setup 0x{setupId:X8} not found");
return 1;
}
// ClothingBase substitution map, at SurfaceTexture (0x05) level.
var texSwap = new Dictionary<uint, uint>();
if (clothingId != 0 && dats.TryGet<ClothingTable>(clothingId, out var ct) && ct is not null)
{
foreach (var baseEffect in ct.ClothingBaseEffects.Values)
foreach (var objEffect in baseEffect.CloObjectEffects)
foreach (var te in objEffect.CloTextureEffects)
texSwap[(uint)te.OldTexture] = (uint)te.NewTexture;
}
var referencedTextures = new HashSet<uint>();
var src = new DatSource(dats);
// Creatures do not define an upright pose in Setup.PlacementFrames --
// the idle frame has to come from the MotionTable, or every part lands
// stacked on the origin.
var idle = MotionResolver.GetIdleFrame(setup, src, new AnimLoader(dats),
motionTableId == 0 ? null : motionTableId);
Console.WriteLine(idle is null ? "idle frame: NONE (falling back to placement frame)"
: $"idle frame: {idle.Frames.Count} part frames");
var refs = SetupMesh.Flatten(setup, idle);
var sb = new StringBuilder();
sb.Append("{\"setup\":\"0x").Append(setupId.ToString("X8")).Append("\",\"parts\":[");
for (int i = 0; i < refs.Count; i++)
{
var mr = refs[i];
if (!dats.TryGet<GfxObj>(mr.GfxObjId, out var gfx) || gfx is null) continue;
var subs = GfxObjMesh.Build(gfx, src);
var m = mr.PartTransform;
if (i > 0) sb.Append(',');
sb.Append("{\"index\":").Append(i)
.Append(",\"gfx\":\"0x").Append(mr.GfxObjId.ToString("X8")).Append('"')
.Append(",\"m\":[")
.Append(F(m.M11)).Append(',').Append(F(m.M12)).Append(',').Append(F(m.M13)).Append(',').Append(F(m.M14)).Append(',')
.Append(F(m.M21)).Append(',').Append(F(m.M22)).Append(',').Append(F(m.M23)).Append(',').Append(F(m.M24)).Append(',')
.Append(F(m.M31)).Append(',').Append(F(m.M32)).Append(',').Append(F(m.M33)).Append(',').Append(F(m.M34)).Append(',')
.Append(F(m.M41)).Append(',').Append(F(m.M42)).Append(',').Append(F(m.M43)).Append(',').Append(F(m.M44))
.Append("],\"sub\":[");
for (int s = 0; s < subs.Count; s++)
{
var sm = subs[s];
// Surface (0x08) -> OrigTextureId (0x05) -> ClothingBase swap.
uint texId = sm.SurfaceId;
if ((texId >> 24) == 0x08 && dats.TryGet<Surface>(texId, out var surf) && surf is not null)
texId = (uint)surf.OrigTextureId;
if (texSwap.TryGetValue(texId, out var swapped)) texId = swapped;
referencedTextures.Add(texId);
if (s > 0) sb.Append(',');
sb.Append("{\"surface\":\"0x").Append(sm.SurfaceId.ToString("X8")).Append('"')
.Append(",\"tex\":\"0x").Append(texId.ToString("X8")).Append('"')
.Append(",\"v\":[");
for (int v = 0; v < sm.Vertices.Length; v++)
{
var vert = sm.Vertices[v];
if (v > 0) sb.Append(',');
sb.Append('[').Append(F(vert.Position.X)).Append(',').Append(F(vert.Position.Y)).Append(',').Append(F(vert.Position.Z))
.Append(',').Append(F(vert.Normal.X)).Append(',').Append(F(vert.Normal.Y)).Append(',').Append(F(vert.Normal.Z))
.Append(',').Append(F(vert.TexCoord.X)).Append(',').Append(F(vert.TexCoord.Y)).Append(']');
}
sb.Append("],\"i\":[");
for (int k = 0; k < sm.Indices.Length; k++)
{
if (k > 0) sb.Append(',');
sb.Append(sm.Indices[k]);
}
sb.Append("]}");
}
sb.Append("]}");
}
sb.Append("]}");
// The documented destination (tools/IconForge/work/) does not exist in a
// fresh checkout, so create it rather than making the caller mkdir first.
string outFull = Path.GetFullPath(outPath);
Directory.CreateDirectory(Path.GetDirectoryName(outFull) ?? ".");
File.WriteAllText(outFull, sb.ToString());
Console.WriteLine($"wrote {outPath} ({new FileInfo(outFull).Length / 1024} KB), {refs.Count} parts");
// Dump the surfaces alongside the geometry. Keeping both halves in one
// command means the render pipeline has a single documented input step
// and cannot be handed geometry whose textures were never extracted.
string textureDir = Path.Combine(Path.GetDirectoryName(outFull) ?? ".", "textures");
Console.WriteLine($"dumping {referencedTextures.Count} referenced surfaces -> {textureDir}");
int written = Textures.Dump(dats, referencedTextures, textureDir);
if (written != referencedTextures.Count)
{
Console.Error.WriteLine(
$"WARNING: {referencedTextures.Count - written} surface(s) did not resolve; "
+ "the render will fall back to flat grey for those.");
}
return 0;
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MosswartArt</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" />
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,59 @@
// One-off: walk a creature Setup -> parts -> GfxObj -> Surface ids, and walk its
// ClothingBase texture/palette overrides, so we can dump the real skin textures.
using System.Globalization;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
static uint Hex(string s) => uint.Parse(s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s[2..] : s,
NumberStyles.HexNumber, CultureInfo.InvariantCulture);
uint setupId = Hex(args[0]);
string? exportPath = args.Length > 2 ? args[2] : null;
uint motionTableId = args.Length > 3 ? Hex(args[3]) : 0;
uint clothingId = args.Length > 1 ? Hex(args[1]) : 0;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), "Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
if (exportPath is not null)
return MosswartArt.Export.Run(dats, setupId, clothingId, exportPath, motionTableId);
if (!dats.TryGet<Setup>(setupId, out var setup) || setup is null) { Console.Error.WriteLine("no setup"); return 1; }
Console.WriteLine($"Setup 0x{setupId:X8}: {setup.Parts.Count} parts, radius {setup.Radius:F2} height {setup.Height:F2}");
var allSurfaces = new SortedSet<uint>();
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gid = setup.Parts[i];
if (!dats.TryGet<GfxObj>(gid, out var g) || g is null) { Console.WriteLine($" part[{i}] gfx 0x{gid:X8} MISSING"); continue; }
var surfs = string.Join(", ", g.Surfaces.Select(s => $"0x{(uint)s:X8}"));
Console.WriteLine($" part[{i}] gfx 0x{gid:X8} verts={g.VertexArray?.Vertices?.Count ?? 0} surfaces=[{surfs}]");
foreach (var s in g.Surfaces) allSurfaces.Add((uint)s);
}
if (clothingId != 0 && dats.TryGet<ClothingTable>(clothingId, out var ct) && ct is not null)
{
Console.WriteLine($"\nClothingBase 0x{clothingId:X8}: {ct.ClothingBaseEffects.Count} base effects, {ct.ClothingSubPalEffects.Count} subpal effects");
foreach (var kv in ct.ClothingBaseEffects)
{
Console.WriteLine($" setup 0x{kv.Key:X8}:");
foreach (var ce in kv.Value.CloObjectEffects)
{
Console.WriteLine($" part {ce.Index} gfx 0x{ce.ModelId:X8}");
foreach (var te in ce.CloTextureEffects)
{
Console.WriteLine($" tex 0x{te.OldTexture:X8} -> 0x{te.NewTexture:X8}");
allSurfaces.Add((uint)te.NewTexture);
}
}
}
foreach (var kv in ct.ClothingSubPalEffects)
Console.WriteLine($" subpal key {kv.Key}: icon 0x{kv.Value.Icon:X8} ranges={kv.Value.CloSubPalettes.Count}");
}
Console.WriteLine("\nALL SURFACE IDS:");
Console.WriteLine(string.Join(" ", allSurfaces.Select(s => $"0x{s:X8}")));
return 0;

View file

@ -0,0 +1,164 @@
// Dump the surfaces a geometry export references, as PNGs named the way
// tools/IconForge's loader globs for them: <0xID>_<W>x<H>.png.
//
// PNG is hand-rolled on ZLibStream so this tool needs no image package; the
// same approach is used by tools/IconExtract.
using System.IO.Compression;
using System.Text;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace MosswartArt;
internal static class Textures
{
/// <summary>
/// Resolve each id to pixels and write it out. Ids may be Surface (0x08),
/// SurfaceTexture (0x05) or RenderSurface (0x06/0x07); the chain is walked
/// down to pixels either way.
/// </summary>
public static int Dump(DatCollection dats, IEnumerable<uint> ids, string outDir)
{
Directory.CreateDirectory(outDir);
int written = 0;
foreach (uint id in ids.Distinct().OrderBy(v => v))
{
RenderSurface? rs = Resolve(dats, id);
if (rs is null || rs.Width <= 0 || rs.Height <= 0)
{
Console.Error.WriteLine($" 0x{id:X8} UNRESOLVED");
continue;
}
Palette? palette = null;
if (rs.DefaultPaletteId != 0
&& dats.TryGet<Palette>(rs.DefaultPaletteId, out var pal) && pal is not null)
{
palette = pal;
}
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
if (decoded.Rgba8 is null || decoded.Rgba8.Length < decoded.Width * decoded.Height * 4)
{
Console.Error.WriteLine($" 0x{id:X8} DECODE FAILED");
continue;
}
string path = Path.Combine(
outDir, $"0x{id:X8}_{decoded.Width}x{decoded.Height}.png");
WritePng(path, decoded.Rgba8, decoded.Width, decoded.Height);
Console.WriteLine($" 0x{id:X8} {decoded.Width}x{decoded.Height} -> {Path.GetFileName(path)}");
written++;
}
return written;
}
// DatReaderWriter's TryGet<T> does not validate the file type -- it just
// deserializes the bytes as T -- so dispatch on the id range rather than
// trying each type in turn.
private static RenderSurface? Resolve(DatCollection dats, uint id)
{
uint lookupId = id;
uint type = id >> 24;
if (type == 0x08)
{
if (dats.TryGet<Surface>(id, out var surface) && surface is not null)
lookupId = (uint)surface.OrigTextureId;
type = lookupId >> 24;
}
if (type == 0x05)
{
if (dats.TryGet<SurfaceTexture>(lookupId, out var st) && st is not null
&& st.Textures.Count > 0
&& dats.TryGet<RenderSurface>((uint)st.Textures[0], out var inner))
{
return inner;
}
return null;
}
if (type is 0x06 or 0x07)
return dats.TryGet<RenderSurface>(lookupId, out var direct) ? direct : null;
return null;
}
private static void WritePng(string path, byte[] rgba, int w, int h)
{
using var fs = File.Create(path);
fs.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
var ihdr = new byte[13];
WriteBE(ihdr, 0, (uint)w);
WriteBE(ihdr, 4, (uint)h);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
WriteChunk(fs, "IHDR", ihdr);
using var ms = new MemoryStream();
using (var z = new ZLibStream(ms, CompressionLevel.Optimal, leaveOpen: true))
{
var row = new byte[1 + w * 4];
for (int y = 0; y < h; y++)
{
row[0] = 0; // filter: none
Array.Copy(rgba, y * w * 4, row, 1, w * 4);
z.Write(row, 0, row.Length);
}
}
WriteChunk(fs, "IDAT", ms.ToArray());
WriteChunk(fs, "IEND", Array.Empty<byte>());
}
private static void WriteBE(byte[] b, int o, uint v)
{
b[o] = (byte)(v >> 24);
b[o + 1] = (byte)(v >> 16);
b[o + 2] = (byte)(v >> 8);
b[o + 3] = (byte)v;
}
private static void WriteChunk(Stream s, string type, byte[] data)
{
Span<byte> len = stackalloc byte[4];
WriteBE(len, (uint)data.Length);
s.Write(len);
byte[] t = Encoding.ASCII.GetBytes(type);
s.Write(t);
s.Write(data);
uint crc = Crc32(t, data);
Span<byte> c = stackalloc byte[4];
WriteBE(c, crc);
s.Write(c);
}
private static void WriteBE(Span<byte> b, uint v)
{
b[0] = (byte)(v >> 24);
b[1] = (byte)(v >> 16);
b[2] = (byte)(v >> 8);
b[3] = (byte)v;
}
private static uint Crc32(byte[] a, byte[] b)
{
uint crc = 0xFFFFFFFFu;
foreach (byte[] arr in new[] { a, b })
{
foreach (byte by in arr)
{
crc ^= by;
for (int k = 0; k < 8; k++)
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
}
}
return crc ^ 0xFFFFFFFFu;
}
}

View file

@ -0,0 +1,285 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Chorizite.DatReaderWriter": {
"type": "Direct",
"requested": "[2.1.7, )",
"resolved": "2.1.7",
"contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==",
"dependencies": {
"DotNet.Standard.Common": "2.0.1",
"ZLibDotNet": "0.1.1"
}
},
"Autofac": {
"type": "Transitive",
"resolved": "8.4.0",
"contentHash": "XMWHyO6fXTv8rwCfhm6+64mQS6CyL0rve/hWSODsUrVuEGtq1fjxSOlVTBqCRsW6L8K3OQDskJaPB1boVMI2eQ=="
},
"Chorizite.ACProtocol": {
"type": "Transitive",
"resolved": "1.0.1",
"contentHash": "PVDw/KRu4WPxT+2MzHwOQ9UFqYlOgpIRswSnco/EgHSHnbQyxOqxwiOwSK0Il+cI6dTSXTW34QNmL7iH0lXLKw==",
"dependencies": {
"Chorizite.Common": "1.0.0",
"Medo.PcapRW": "1.2.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"System.CodeDom": "9.0.0"
}
},
"Chorizite.Common": {
"type": "Transitive",
"resolved": "1.0.3",
"contentHash": "KqI0su7UY2diiSQuq11gF/NztqR6orZLr/e5UKTQ91XM8OsZGgBGHmhf2/jopX9VhwpXOTTLIeFBYTBc30cK8w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "9.0.0"
}
},
"CommunityToolkit.HighPerformance": {
"type": "Transitive",
"resolved": "8.4.0",
"contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg=="
},
"Cyotek.Drawing.BitmapFont": {
"type": "Transitive",
"resolved": "2.0.4",
"contentHash": "iA6WehGVdMUuNbfsQQDq/Bt+mMd/OqHjiMUtKFLIQd/0pyYh4ehT7FEjTxN9/4OXNKQZsp9bAJgltP2nnswUJg=="
},
"DotNet.Standard.Common": {
"type": "Transitive",
"resolved": "2.0.1",
"contentHash": "zW0m0ytHi43ccbEOTNDa10cDDnT7BAzY1R1Rb1dlhbdkiyglsALjrsyTPSEjbdnTmCOAvAvl4kkbvBLoYhC6dQ=="
},
"FontStashSharp": {
"type": "Transitive",
"resolved": "1.3.10",
"contentHash": "7JTrihTt3DR8LYbb4L1eZcnbwOUOu/mvY+PJoZ3WVWiKjA6xNUk93GSW3OC/kZBD3iYzrXK8QlmKyYY5Lef/Rg==",
"dependencies": {
"Cyotek.Drawing.BitmapFont": "2.0.4",
"FontStashSharp.Base": "1.1.9",
"FontStashSharp.Rasterizers.StbTrueTypeSharp": "1.1.9",
"StbImageSharp": "2.30.15"
}
},
"FontStashSharp.Base": {
"type": "Transitive",
"resolved": "1.1.9",
"contentHash": "/AjkOcPNijs8vyNgcCj3FfBJbVWmsSH744hqkhLfBt8qspDz/tEoD+U09my5u9eRBX6zX+RLQ/gdCvwy+ZBOtg=="
},
"FontStashSharp.Rasterizers.StbTrueTypeSharp": {
"type": "Transitive",
"resolved": "1.1.9",
"contentHash": "yi5iuTERem46uyHC5p+jRi3Jh8dKWzgNWLqcvHciGlyVHD1cWFdERgnxshZU4xWB2hRGnogxaCudCirbMpg4eQ==",
"dependencies": {
"FontStashSharp.Base": "1.1.9",
"StbTrueTypeSharp": "1.26.12"
}
},
"Medo.PcapRW": {
"type": "Transitive",
"resolved": "1.2.0",
"contentHash": "vgwcHDg60Q9LJfry7twA78pUFio1P4EypI2IIlk+7mEuySsIRInm+Gx2OINDICyocbuyQZdS/zABjbmejAObeg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Diagnostics.NETCore.Client": {
"type": "Transitive",
"resolved": "0.2.410101",
"contentHash": "I4hMjlbPcM5R+M4ThD2Zt1z58M8uZnWkDbFLXHntOOAajajEucrw4XYNSaoi5rgoqksgxQ3g388Vof4QzUNwdQ==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "1.1.0",
"Microsoft.Extensions.Logging": "2.1.1"
}
},
"Microsoft.Diagnostics.Runtime": {
"type": "Transitive",
"resolved": "3.1.512801",
"contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==",
"dependencies": {
"Microsoft.Diagnostics.NETCore.Client": "0.2.410101"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "LjVKO6P2y52c5ZhTLX/w8zc5H4Y3J/LJsgqTBj49TtFq/hAtVNue/WA0F6/7GMY90xhD7K0MDZ4qpOeWXbLvzg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "2.1.1"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "VfuZJNa0WUshZ/+8BFZAhwFKiKuu/qOUCFntfdLpHj7vcRnsGHqd3G2Hse78DM+pgozczGM63lGPRLmy+uhUOA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.1.1"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "fcLCTS03poWE4v9tSNBr3pWn0QwGgAn1vzqHXlXgvqZeOc7LvQNzaWcKRQZTdEc3+YhQKwMsOtm3VKSA2aWQ8w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "2.1.1"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "hh+mkOAQDTp6XH80xJt3+wwYVzkbwYQl9XZRCz4Um0JjP/o7N9vHM3rZ6wwwtr+BBe/L6iBO2sz0px6OWBzqZQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "2.1.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1",
"Microsoft.Extensions.Logging.Abstractions": "2.1.1",
"Microsoft.Extensions.Options": "2.1.1"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "V7lXCU78lAbzaulCGFKojcCyG8RTJicEbiBkPJjFqiqXwndEBBIehdXRMWEVU3UtzQ1yDvphiWUL9th6/4gJ7w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1",
"Microsoft.Extensions.Primitives": "2.1.1"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "scJ1GZNIxMmjpENh0UZ8XCQ6vzr/LzeF9WvEA51Ix2OQGAs9WPgPu8ABVUdvpKPLuor/t05gm6menJK3PwqOXg=="
},
"Namotion.Reflection": {
"type": "Transitive",
"resolved": "3.4.3",
"contentHash": "KLk2gLR9f8scM82EiL+p9TONXXPy9+IAZVMzJOA/Wsa7soZD7UJGG6j0fq0D9ZoVnBRRnSeEC7kShhRo3Olgaw=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"NJsonSchema": {
"type": "Transitive",
"resolved": "11.5.1",
"contentHash": "3a7ntoBncSKkLgpIhT3uQ8BiyDzYKOHIzpzNF4o1vtKc+Re4vWxBcDXFDarOWcr/UkxZ8nxRXbbWk05j6bXFzQ==",
"dependencies": {
"NJsonSchema.Annotations": "11.5.1",
"Namotion.Reflection": "3.4.3",
"Newtonsoft.Json": "13.0.3"
}
},
"NJsonSchema.Annotations": {
"type": "Transitive",
"resolved": "11.5.1",
"contentHash": "xiqZ2DBJM1HuV+EhXgueb5ZUBlWFN3kVfLTKdtpTSxvtyQCO/vit8lqZiUiejnReUMRMIUhtS9m0GbieHZlSow=="
},
"SixLabors.Fonts": {
"type": "Transitive",
"resolved": "2.1.3",
"contentHash": "ORWbZ5BHrC/LZvo+Y09MnoJq5VUKD85LsYALk+YI7CHFra+m5arCkz00IntDM6SrAiB22bvSdKtKmuCyHOKlqg=="
},
"SixLabors.ImageSharp.Drawing": {
"type": "Transitive",
"resolved": "2.1.7",
"contentHash": "9KwCo9Fa350cx6ckpsy8NqXQZKwir4RQ8Kj0sdCmJA7wsK9FMyfgC527Sn4l/D6bj2ditSHlhS7dGzcgGszvSQ==",
"dependencies": {
"SixLabors.Fonts": "2.1.3",
"SixLabors.ImageSharp": "3.1.11"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "oTE5IfuMoET8yaZP/vdvy9xO47guAv/rOhe4DODuFBN3ySprcQOlXqO3j+e/H/YpKKR5sglrxRaZ2HYOhNJrqA=="
},
"ZLibDotNet": {
"type": "Transitive",
"resolved": "0.1.1",
"contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg=="
},
"acdream.core": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )",
"BCnEncoder.Net": "[2.2.1, )",
"Chorizite.Core": "[0.0.18, )",
"Chorizite.DatReaderWriter": "[2.1.7, )",
"Serilog": "[4.0.2, )",
"StbImageSharp": "[2.30.16, )"
}
},
"acdream.plugin.abstractions": {
"type": "Project"
},
"BCnEncoder.Net": {
"type": "CentralTransitive",
"requested": "[2.2.1, )",
"resolved": "2.2.1",
"contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==",
"dependencies": {
"CommunityToolkit.HighPerformance": "8.4.0"
}
},
"Chorizite.Core": {
"type": "CentralTransitive",
"requested": "[0.0.18, )",
"resolved": "0.0.18",
"contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==",
"dependencies": {
"Autofac": "8.4.0",
"Chorizite.ACProtocol": "1.0.1",
"Chorizite.Common": "1.0.3",
"Chorizite.DatReaderWriter": "1.0.0",
"FontStashSharp": "1.3.10",
"Microsoft.Diagnostics.Runtime": "3.1.512801",
"Microsoft.Extensions.Logging.Abstractions": "9.0.9",
"NJsonSchema": "11.5.1",
"SixLabors.ImageSharp": "3.1.11",
"SixLabors.ImageSharp.Drawing": "2.1.7"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[9.0.9, )",
"resolved": "9.0.9",
"contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9"
}
},
"Serilog": {
"type": "CentralTransitive",
"requested": "[4.0.2, )",
"resolved": "4.0.2",
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
},
"SixLabors.ImageSharp": {
"type": "CentralTransitive",
"requested": "[3.1.12, )",
"resolved": "3.1.11",
"contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw=="
},
"StbImageSharp": {
"type": "CentralTransitive",
"requested": "[2.30.16, )",
"resolved": "2.30.16",
"contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw=="
},
"StbTrueTypeSharp": {
"type": "CentralTransitive",
"requested": "[1.26.12, )",
"resolved": "1.26.12",
"contentHash": "hCc6/OsfcPa5VsLECcEU2m78WOshBrKwK42nAodSm9Z5wH68f7n66SoiRLCdGCkDaqbWz2TlX4zYHIjogj1HJA=="
}
}
}
}