Files
blockout/BlockOutReplica/Assets/Editor/OriginalTypographyRecovery.cs
2026-10-20 13:21:04 +08:00

188 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using BlockOutReplica.Presentation;
using UnityEditor;
using UnityEngine;
namespace BlockOutReplica.Editor
{
public static class OriginalTypographyRecovery
{
private const string Destination = "Assets/Resources/OriginalTypography";
[MenuItem("Block Out/Recover Original Typography")]
public static void Recover()
{
string project = Path.GetFullPath(Path.Combine(Application.dataPath, "../.."));
string source = project + "/reverse/assetripper_export/unity_project/ExportedProject/Assets";
string binary = project + "/reverse/unpacked/base/assets/bin/Data/sharedassets0.assets.split180";
byte[] bytes = File.ReadAllBytes(binary);
byte[] needle = Encoding.UTF8.GetBytes("MuseoSansRounded-1000 SDF");
int nameOffset = -1;
for (int i = 4; i < bytes.Length - needle.Length; i++)
if (BitConverter.ToInt32(bytes, i - 4) == needle.Length && needle.Select((b, j) => bytes[i + j] == b).All(b => b))
{if (nameOffset != -1) throw new InvalidDataException("Ambiguous original Museo font blob"); nameOffset = i;}
if (nameOffset < 0) throw new InvalidDataException("Original Museo font blob not found");
var data = ScriptableObject.CreateInstance<OriginalSdfFontData>();
data.name = "MuseoSansRounded-1000";
data.SourceFile = "reverse/unpacked/base/assets/bin/Data/sharedassets0.assets.split180";
data.SourceNameOffset = nameOffset;
using (var sha = SHA256.Create()) data.SourceSha256 = string.Concat(sha.ComputeHash(bytes).Select(b => b.ToString("x2")));
var reader = new BinaryReader(new MemoryStream(bytes));
reader.BaseStream.Position = Align(nameOffset + needle.Length) + 20; // TMP_Asset hash, material PPtr, material hash.
if (ReadString(reader) != "1.1.0") throw new InvalidDataException("Unsupported source TMP font layout");
ReadString(reader); // Source font GUID. Static atlas build does not include the source TTF.
reader.BaseStream.Position += 12; // source font PPtr
if (reader.ReadInt32() != 0) throw new InvalidDataException("Expected original static font atlas");
reader.ReadInt32(); // FaceInfo face index
data.Family = ReadString(reader); data.Style = ReadString(reader);
data.PointSize = reader.ReadInt32(); data.FaceScale = reader.ReadSingle(); reader.ReadInt32(); // Units per EM
data.LineHeight = reader.ReadSingle(); data.Ascent = reader.ReadSingle(); data.CapHeight = reader.ReadSingle();
reader.ReadSingle(); reader.ReadSingle(); data.Descent = reader.ReadSingle();
reader.BaseStream.Position += 9 * 4; // superscript/subscript, underline/strikethrough and tab metrics
int glyphCount = reader.ReadInt32();
if (data.Family != "Museo Sans Rounded" || data.Style != "1000" || data.PointSize != 50 || glyphCount != 317)
throw new InvalidDataException("Source font identity/layout differs; do not guess glyphs");
var glyphs = new Dictionary<int, OriginalSdfFontData.Glyph>();
for (int i = 0; i < glyphCount; i++)
{
var glyph = new OriginalSdfFontData.Glyph {Index = reader.ReadInt32(), Width = reader.ReadSingle(), Height = reader.ReadSingle(),
BearingX = reader.ReadSingle(), BearingY = reader.ReadSingle(), Advance = reader.ReadSingle()};
glyph.Rect = new Rect(reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32());
glyph.Scale = reader.ReadSingle();
if (reader.ReadInt32() != 0) throw new InvalidDataException("Unexpected source glyph atlas index");
reader.ReadInt32(); // Glyph class definition
glyphs.Add(glyph.Index, glyph);
}
int characterCount = reader.ReadInt32();
if (characterCount != 317) throw new InvalidDataException("Unexpected original character table length");
var characters = new List<OriginalSdfFontData.Glyph>();
for (int i = 0; i < characterCount; i++)
{
if (reader.ReadInt32() != 1) throw new InvalidDataException("Expected original TMP character element");
int unicode = reader.ReadInt32(), index = reader.ReadInt32(); float scale = reader.ReadSingle();
var original = glyphs[index];
characters.Add(new OriginalSdfFontData.Glyph {Unicode = unicode, Index = index, Rect = original.Rect, Width = original.Width,
Height = original.Height, BearingX = original.BearingX, BearingY = original.BearingY, Advance = original.Advance, Scale = original.Scale * scale});
}
if (reader.ReadInt32() != 1 || reader.ReadInt32() != 0) throw new InvalidDataException("Expected one local source atlas");
data.SourceAtlasPathId = checked((int)reader.ReadInt64());
if (data.SourceAtlasPathId != 248) throw new InvalidDataException("Source font atlas identity differs");
data.Glyphs = characters.ToArray();
data.Padding = Scalar(File.ReadAllText(source + "/Material/MuseoSansRounded-1000 SDF-Flat.mat"), "_GradientScale") - 1;
Directory.CreateDirectory(Destination);
string atlasPath = Destination + "/MuseoSansRounded-1000 SDF Atlas.png";
File.Copy(source + "/Texture2D/MuseoSansRounded-1000 SDF Atlas.png", atlasPath, true);
AssetDatabase.ImportAsset(atlasPath, ImportAssetOptions.ForceSynchronousImport);
var importer = (TextureImporter)AssetImporter.GetAtPath(atlasPath);
importer.textureType = TextureImporterType.Default; importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.mipmapEnabled = false; importer.sRGBTexture = false; importer.alphaIsTransparency = false;
importer.maxTextureSize = 2048; importer.npotScale = TextureImporterNPOTScale.None;
importer.wrapMode = TextureWrapMode.Clamp; importer.filterMode = FilterMode.Bilinear; importer.SaveAndReimport();
data.Atlas = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasPath);
var material = new Material(Shader.Find("BlockOut/OriginalTypographySdf")) {name = "Original Museo SDF Compatibility", mainTexture = data.Atlas};
data.RenderingMaterial = Save(material, Destination + "/MuseoSdf.mat");
data.Profiles = new[] {Profile(source, "Body", "Flat"), Profile(source, "Counter", "DarkGray"),
Profile(source, "Title", "PurpleTitle1"), Profile(source, "Button", "Atlas"),
Profile(source, "ButtonGreen", "Atlas"), Profile(source, "ButtonRed", "Atlas"),
Profile(source, "ButtonGreenOutline", "Multilayer-GreenDarkGreen"),
Profile(source, "ButtonRedOuter", "Multilayer-59-6-3"), Profile(source, "ButtonRedOutline", "Multilayer-85-17-7"),
Profile(source, "PopupLabel", "PurpleTitle1"),
Profile(source, "Timer", "LevelTimer"), Profile(source, "Navigation", "Flat"),
Profile(source, "TitleOuter", "PurpleTitle3"), Profile(source, "TitleExpanded", "DilateFull"),
// Pause's expanded Text (1) uses DilateFull, but its TMP vertex tint is
// absent from the export. These genuine source palette colors are retained
// separately; applying them to that layer is an explicit reconstruction.
Profile(source, "TitleBrightPalette", "MultilayerMidLightPurple"),
Profile(source, "TitleHighlightPalette", "MultilayerOuterLightPurple"),
Profile(source, "JourneyTitle", "JourneyTitle1"), Profile(source, "JourneyTitleWhiteStroke", "JourneyTitle2"),
Profile(source, "JourneyTitleOuter", "JourneyTitle3")};
data = Save(data, Destination + "/MuseoSansRounded-1000.asset");
foreach (string fontName in new[] {"Roboto-Bold.ttf", "LiberationSans.ttf"})
{
string path = Destination + "/" + fontName;
if (!File.Exists(path)) File.Copy(source + "/Font/" + fontName, path);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
}
AssetDatabase.SaveAssets();
Debug.Log($"BLOCKOUT_ORIGINAL_TYPOGRAPHY_RECOVERED: family={data.Family}, style={data.Style}, pointSize={data.PointSize}, glyphs={glyphCount}, characters={characterCount}, atlasPathId={data.SourceAtlasPathId}, blobOffset={nameOffset}, sourceSha256={data.SourceSha256}, trueSdfGlyphs=True, sourceTtfAbsent=True, kerningNotRecovered=True, shaderCompatibilityAdapter=True");
}
[MenuItem("Block Out/Validate Original Typography")]
public static void Validate()
{
var data = Resources.Load<OriginalSdfFontData>("OriginalTypography/MuseoSansRounded-1000");
if (data == null || data.Glyphs.Length != 317 || !data.Supports("PLAY Journey Level 10 PAUSE 1000 02:59"))
throw new InvalidDataException("Actual original Latin glyph table is missing");
if (data.Atlas.width != 1024 || data.Atlas.height != 1024 || data.Glyphs.Any(g => g.Rect.xMin < 0 || g.Rect.yMin < 0 || g.Rect.xMax > 1024 || g.Rect.yMax > 1024))
throw new InvalidDataException("Original glyph rectangles do not match atlas");
if (data.Supports("中") || data.Supports("∞")) throw new InvalidDataException("Do not pretend source font supports absent glyphs");
if (data.RenderingMaterial == null || !data.RenderingMaterial.shader.isSupported) throw new InvalidDataException("Typography compatibility shader unavailable");
foreach (string role in new[] {"Title", "TitleOuter", "TitleExpanded", "JourneyTitle", "JourneyTitleWhiteStroke", "JourneyTitleOuter"})
{
var profile = data.GetProfile(role);
string source = Path.GetFullPath(Path.Combine(Application.dataPath,
"../../reverse/assetripper_export/unity_project/ExportedProject/Assets/Material/" + profile.SourceMaterial + ".mat"));
if (Mathf.Abs(profile.ScaleRatioA - Scalar(File.ReadAllText(source), "_ScaleRatioA")) > .00001f)
throw new InvalidDataException("Original title ScaleRatioA missing; rerun typography recovery: " + role);
}
foreach (var profile in data.Profiles)
{
string source = Path.GetFullPath(Path.Combine(Application.dataPath,
"../../reverse/assetripper_export/unity_project/ExportedProject/Assets/Material/" + profile.SourceMaterial + ".mat"));
string yaml = File.ReadAllText(source);
if (profile.OutlineEnabled != Regex.IsMatch(yaml, @"(?m)^ - OUTLINE_ON$") ||
profile.UnderlayEnabled != Regex.IsMatch(yaml, @"(?m)^ - UNDERLAY_ON$") ||
Mathf.Abs(profile.ScaleRatioC - Scalar(yaml, "_ScaleRatioC")) > .00001f)
throw new InvalidDataException("Original typography keyword/underlay ratio missing: " + profile.Name);
}
foreach (string role in new[] {"ButtonGreen", "ButtonRed", "ButtonGreenOutline", "ButtonRedOuter", "ButtonRedOutline", "PopupLabel", "TitleBrightPalette", "TitleHighlightPalette"})
if (!data.Profiles.Any(p => p.Name == role)) throw new InvalidDataException("Original popup profile missing: " + role);
if (data.GetProfile("ButtonGreenOutline").Face.g <= data.GetProfile("ButtonGreenOutline").Face.r ||
data.GetProfile("ButtonRedOutline").Face.r <= data.GetProfile("ButtonRedOutline").Face.g)
throw new InvalidDataException("Original button colored layers are not green/red");
Color bright = data.GetProfile("TitleBrightPalette").Face;
if (bright.r <= data.GetProfile("Title").Outline.r || bright.b <= data.GetProfile("Title").Outline.b)
throw new InvalidDataException("Title expanded layer must use distinct original bright-purple palette, not the inner outline color");
Debug.Log("BLOCKOUT_ORIGINAL_TYPOGRAPHY_VALIDATION: passed=True, characters=317, latinLabels=True, missingUnicodeExplicit=True, sourceFontNotFreight=True, titleSourceScaleRatios=True, sourceOutlineUnderlayKeywords=True, pauseOriginalButtonLayers=True, expandedTitleTint=OriginalMultilayerMidLightPurplePaletteReconstruction, pauseVertexTintNotRecovered=True, gpuContourVisualNotValidated=True");
}
private static OriginalSdfFontData.Profile Profile(string source, string role, string suffix)
{
string name = suffix == "Atlas" ? "MuseoSansRounded-1000 Atlas Material" : "MuseoSansRounded-1000 SDF-" + suffix;
string yaml = File.ReadAllText(source + "/Material/" + name + ".mat");
return new OriginalSdfFontData.Profile {Name = role, SourceMaterial = name,
Face = ColorField(yaml, "_FaceColor"), Outline = ColorField(yaml, "_OutlineColor"), Underlay = ColorField(yaml, "_UnderlayColor"),
FaceDilate = Scalar(yaml, "_FaceDilate"), OutlineWidth = Scalar(yaml, "_OutlineWidth"), UnderlayDilate = Scalar(yaml, "_UnderlayDilate"),
UnderlayOffsetX = Scalar(yaml, "_UnderlayOffsetX"), UnderlayOffsetY = Scalar(yaml, "_UnderlayOffsetY"),
ScaleRatioA = Scalar(yaml, "_ScaleRatioA"), ScaleRatioC = Scalar(yaml, "_ScaleRatioC"),
OutlineEnabled = Regex.IsMatch(yaml, @"(?m)^ - OUTLINE_ON$"),
UnderlayEnabled = Regex.IsMatch(yaml, @"(?m)^ - UNDERLAY_ON$")};
}
private static float Scalar(string yaml, string name) => float.Parse(Regex.Match(yaml, @"(?m)^\s*" + name + @": (\S+)").Groups[1].Value, CultureInfo.InvariantCulture);
private static Color ColorField(string yaml, string name)
{
Match m = Regex.Match(yaml, name + @": \{r: ([^,]+), g: ([^,]+), b: ([^,]+), a: ([^}]+)\}");
return new Color(float.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), float.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture),
float.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture), float.Parse(m.Groups[4].Value, CultureInfo.InvariantCulture));
}
private static int Align(int position) => (position + 3) & ~3;
private static string ReadString(BinaryReader reader)
{
int count = reader.ReadInt32(); if (count < 0 || count > 512) throw new InvalidDataException("Invalid aligned source string");
string value = Encoding.UTF8.GetString(reader.ReadBytes(count)); reader.BaseStream.Position = Align((int)reader.BaseStream.Position); return value;
}
private static T Save<T>(T asset, string path) where T : UnityEngine.Object
{
T old = AssetDatabase.LoadAssetAtPath<T>(path);
if (old == null) {AssetDatabase.CreateAsset(asset, path); return asset;}
EditorUtility.CopySerialized(asset, old); UnityEngine.Object.DestroyImmediate(asset); return old;
}
}
}