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>
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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`.
|
||||
BIN
assets/icons/acdream-client-1024.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
assets/icons/acdream-client-128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/icons/acdream-client-16.png
Normal file
|
After Width: | Height: | Size: 699 B |
BIN
assets/icons/acdream-client-24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/icons/acdream-client-256.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/icons/acdream-client-32.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
assets/icons/acdream-client-48.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/icons/acdream-client-512.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
assets/icons/acdream-client-64.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/icons/acdream-client.ico
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/icons/acdream-launcher-1024.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
assets/icons/acdream-launcher-128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/icons/acdream-launcher-16.png
Normal file
|
After Width: | Height: | Size: 819 B |
BIN
assets/icons/acdream-launcher-24.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/icons/acdream-launcher-256.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/icons/acdream-launcher-32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
assets/icons/acdream-launcher-48.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/icons/acdream-launcher-512.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
assets/icons/acdream-launcher-64.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/icons/acdream-launcher.ico
Normal file
|
After Width: | Height: | Size: 86 KiB |
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
107
src/AcDream.App/Rendering/WindowIconLoader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
tests/AcDream.App.Tests/Rendering/WindowIconLoaderTests.cs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
76
tools/IconForge/launcher.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
13
tools/MosswartArt/MosswartArt.csproj
Normal 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>
|
||||
59
tools/MosswartArt/Program.cs
Normal 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;
|
||||
164
tools/MosswartArt/Textures.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
285
tools/MosswartArt/packages.neutral.lock.json
Normal 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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||