Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ResourceHelper: Add MIP importing #306

Merged
merged 10 commits into from
Dec 31, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom<ApiGameLe
Title = level.Title,
Publisher = ApiGameUserResponse.FromOld(level.Publisher),
LevelId = level.LevelId,
IconHash = level.IconHash,
IconHash = level.GameVersion == TokenGame.LittleBigPlanetPSP ? "psp/" + level.IconHash : level.IconHash,
Description = level.Description,
Location = ApiGameLocationResponse.FromGameLocation(level.Location)!,
PublishDate = DateTimeOffset.FromUnixTimeMilliseconds(level.PublishDate),
Expand Down
9 changes: 4 additions & 5 deletions Refresh.GameServer/Endpoints/ApiV3/ResourceApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response;
using Refresh.GameServer.Importing;
using Refresh.GameServer.Time;
using Refresh.GameServer.Types.Assets;
using Refresh.GameServer.Types.UserData;
using Refresh.GameServer.Verification;
Expand Down Expand Up @@ -59,7 +58,7 @@ public Response DownloadPspGameAsset(RequestContext context, IDataStore dataStor
[DocError(typeof(ApiInternalError), ApiInternalError.CouldNotGetAssetErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.HashMissingErrorWhen)]
public Response DownloadGameAssetAsImage(RequestContext context, IDataStore dataStore, GameDatabaseContext database,
[DocSummary("The SHA1 hash of the asset")] string hash)
[DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter importer)
{
bool isPspAsset = hash.StartsWith("psp/");

Expand All @@ -73,8 +72,8 @@ public Response DownloadGameAssetAsImage(RequestContext context, IDataStore data
{
GameAsset? asset = database.GetAssetFromHash(realHash);
if (asset == null) return ApiInternalError.CouldNotGetAssetDatabaseError;
ImageImporter.ImportAsset(asset, dataStore);

importer.ImportAsset(asset, dataStore);
}

bool gotData = dataStore.TryGetDataFromStore("png/" + realHash, out byte[]? data);
Expand All @@ -90,7 +89,7 @@ public Response DownloadGameAssetAsImage(RequestContext context, IDataStore data
[DocError(typeof(ApiInternalError), ApiInternalError.CouldNotGetAssetErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.HashMissingErrorWhen)]
public Response DownloadPspGameAssetAsImage(RequestContext context, IDataStore dataStore, GameDatabaseContext database,
[DocSummary("The SHA1 hash of the asset")] string hash) => this.DownloadGameAssetAsImage(context, dataStore, database, $"psp/{hash}");
[DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter importer) => this.DownloadGameAssetAsImage(context, dataStore, database, $"psp/{hash}", importer);

[ApiV3Endpoint("assets/{hash}"), Authentication(false)]
[DocSummary("Gets information from the database about a particular hash. Includes user who uploaded, dependencies, timestamps, etc.")]
Expand Down
18 changes: 12 additions & 6 deletions Refresh.GameServer/Importing/AssetImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,20 @@ public void ImportFromDataStore(GameDatabaseContext database, IDataStore dataSto
int newAssets = 0;
this.Stopwatch.Start();

IEnumerable<string> assetHashes = dataStore.GetKeysFromStore()
.Where(key => !key.Contains('/'));
IEnumerable<string> assetHashes = dataStore.GetKeysFromStore();

List<GameAsset> assets = new();
foreach (string hash in assetHashes)
List<GameAsset> assets = new(assetHashes.Count());
foreach (string path in assetHashes)
{
byte[] data = dataStore.GetDataFromStore(hash);
bool isPsp = path.StartsWith("psp/");
//If the hash has a `/` and it doesnt start with `psp/`, then its an invalid asset
if (path.Contains('/') && !isPsp) continue;

string hash = isPsp ? path[4..] : path;

byte[] data = dataStore.GetDataFromStore(path);

GameAsset? newAsset = this.ReadAndVerifyAsset(hash, data, null);
GameAsset? newAsset = this.ReadAndVerifyAsset(hash, data, isPsp ? TokenPlatform.PSP : null);
if (newAsset == null) continue;

GameAsset? oldAsset = database.GetAssetFromHash(hash);
Expand Down Expand Up @@ -132,6 +137,7 @@ or GameAssetType.Png
or GameAssetType.Tga
or GameAssetType.Texture
or GameAssetType.GameDataTexture
or GameAssetType.Mip
or GameAssetType.Unknown)
{
return false;
Expand Down
7 changes: 7 additions & 0 deletions Refresh.GameServer/Importing/ImageImporter.Conversions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ICSharpCode.SharpZipLib.Zip.Compression;
using Pfim;
using Refresh.GameServer.Importing.Gtf;
using Refresh.GameServer.Importing.Mip;
using SixLabors.ImageSharp.Formats;

namespace Refresh.GameServer.Importing;
Expand Down Expand Up @@ -33,6 +34,12 @@ private static void GtfToPng(Stream stream, Stream writeStream)
using Image image = new GtfDecoder().Decode(new DecoderOptions(), stream);
image.SaveAsPng(writeStream);
}

private static void MipToPng(Stream stream, Stream writeStream)
{
using Image image = new MipDecoder().Decode(new DecoderOptions(), stream);
image.SaveAsPng(writeStream);
}

private static readonly PfimConfig Config = new();

Expand Down
13 changes: 12 additions & 1 deletion Refresh.GameServer/Importing/ImageImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using NotEnoughLogs;
using Refresh.GameServer.Database;
using Refresh.GameServer.Extensions;
using Refresh.GameServer.Resources;
using Refresh.GameServer.Types.Assets;

namespace Refresh.GameServer.Importing;
Expand All @@ -22,6 +23,7 @@ public void ImportFromDataStore(GameDatabaseContext context, IDataStore dataStor
assets.AddRange(context.GetAssetsByType(GameAssetType.GameDataTexture));
assets.AddRange(context.GetAssetsByType(GameAssetType.Jpeg));
assets.AddRange(context.GetAssetsByType(GameAssetType.Png));
assets.AddRange(context.GetAssetsByType(GameAssetType.Mip));

this.Info("Acquired all other assets");

Expand Down Expand Up @@ -66,7 +68,7 @@ private void ThreadTask(ConcurrentQueue<GameAsset> assetQueue, IDataStore dataSt
this._runningCount--;
}

public static void ImportAsset(GameAsset asset, IDataStore dataStore)
public void ImportAsset(GameAsset asset, IDataStore dataStore)
{
using Stream stream = dataStore.GetStreamFromStore(asset.IsPSP ? "psp/" + asset.AssetHash : asset.AssetHash);
using Stream writeStream = dataStore.OpenWriteStream("png/" + asset.AssetHash);
Expand All @@ -76,6 +78,15 @@ public static void ImportAsset(GameAsset asset, IDataStore dataStore)
case GameAssetType.GameDataTexture:
GtfToPng(stream, writeStream);
break;
case GameAssetType.Mip: {
byte[] rawData = dataStore.GetDataFromStore(asset.IsPSP ? "psp/" + asset.AssetHash : asset.AssetHash);
byte[] data = ResourceHelper.PspDecrypt(rawData, this._pspKey.Value);

using MemoryStream dataStream = new(data);

MipToPng(dataStream, writeStream);
break;
}
case GameAssetType.Texture:
TextureToPng(stream, writeStream);
break;
Expand Down
78 changes: 75 additions & 3 deletions Refresh.GameServer/Importing/Importer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Bunkum.Core;
using NotEnoughLogs;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Resources;
using Refresh.GameServer.Types.Assets;

namespace Refresh.GameServer.Importing;
Expand All @@ -13,6 +14,7 @@ public abstract class Importer
{
private readonly Logger _logger;
protected readonly Stopwatch Stopwatch;
protected readonly Lazy<byte[]?> _pspKey;

protected Importer(Logger? logger = null)
{
Expand All @@ -23,6 +25,27 @@ protected Importer(Logger? logger = null)

this._logger = logger;
this.Stopwatch = new Stopwatch();

this._pspKey = new(() =>
{
try
{
return File.ReadAllBytes("keys/psp");
}
catch(Exception e)
{
if (e.GetType() == typeof(FileNotFoundException) || e.GetType() == typeof(DirectoryNotFoundException))
{
this._logger.LogWarning(BunkumCategory.Digest, "PSP key file not found, encrypting/decrypting of PSP images will not work.");
}
else
{
this._logger.LogError(BunkumCategory.Digest, "Unknown error while loading PSP key! err: {0}", e.ToString());
}

return null;
}
}, LazyThreadSafetyMode.ExecutionAndPublication);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Expand Down Expand Up @@ -99,8 +122,55 @@ private static bool IsPspTga(ReadOnlySpan<byte> data)

return true;
}

protected GameAssetType DetermineAssetType(ReadOnlySpan<byte> data, TokenPlatform? tokenPlatform)

private bool IsMip(Span<byte> rawData)
{
//If we dont have a key, then we cant determine the data type
if (this._pspKey.Value == null) return false;

//Data less than this size isn't encrypted(?) and all Mip files uploaded to the server will be encrypted
//See https://github.com/ennuo/lbparc/blob/16ad36aa7f4eae2f7b406829e604082750f16fe1/tools/toggle.js#L33
if (rawData.Length < 0x19) return false;

try
{
//Decrypt the data
ReadOnlySpan<byte> data = ResourceHelper.PspDecrypt(rawData, this._pspKey.Value);

uint clutOffset = BinaryPrimitives.ReadUInt32LittleEndian(data[..4]);
uint width = BinaryPrimitives.ReadUInt32LittleEndian(data[4..8]);
uint height = BinaryPrimitives.ReadUInt32LittleEndian(data[8..12]);
byte bpp = data[12];
byte numBlocks = data[13];
byte texMode = data[14];
byte alpha = data[15];
uint dataOffset = BinaryPrimitives.ReadUInt32LittleEndian(data[16..20]);

//Its unlikely that any mip textures are ever gonna be this big
if (width > 512 || height > 512) return false;

//We only support MIP files which have a bpp of 4 and 8
if (bpp is not 8 and 4) return false;

//Alpha can only be 0 or 1
if (alpha > 1) return false;

//If the data offset is past the end of the file, its not a MIP
if (dataOffset > data.Length || clutOffset > data.Length) return false;

//If the size of the image is too big for the data passed in, its not a MIP
if (width * height * bpp / 8 > data.Length - dataOffset) return false;
}
catch
{
//If the data is invalid an invalid encrypted file, its not a MIP
return false;
}

return true;
}

protected GameAssetType DetermineAssetType(Span<byte> data, TokenPlatform? tokenPlatform)
{
// LBP assets
if (MatchesMagic(data, "TEX "u8)) return GameAssetType.Texture;
Expand All @@ -121,8 +191,10 @@ protected GameAssetType DetermineAssetType(ReadOnlySpan<byte> data, TokenPlatfor
if (MatchesMagic(data, 0xFFD8FFE0)) return GameAssetType.Jpeg;
if (MatchesMagic(data, 0x89504E470D0A1A0A)) return GameAssetType.Png;

// ReSharper disable once ConvertIfStatementToSwitchStatement
if (tokenPlatform is null or TokenPlatform.PSP && IsPspTga(data)) return GameAssetType.Tga;

if (tokenPlatform is null or TokenPlatform.PSP && this.IsMip(data)) return GameAssetType.Mip;

this.Warn($"Unknown asset header [0x{Convert.ToHexString(data[..4])}] [str: {Encoding.ASCII.GetString(data[..4])}]");

return GameAssetType.Unknown;
Expand Down
126 changes: 126 additions & 0 deletions Refresh.GameServer/Importing/Mip/MipDecoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Metadata;

namespace Refresh.GameServer.Importing.Mip;

public class MipDecoder : ImageDecoder
{
protected override Image<TPixel> Decode<TPixel>(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
MipHeader header = MipHeader.Read(stream);

Image<TPixel> image = new(options.Configuration, (int)header.Width, (int)header.Height);
Buffer2D<TPixel> pixels = image.Frames.RootFrame.PixelBuffer;

this.ProcessPixels(header, stream, pixels);

return image;
}

private void ProcessPixels<TPixel>(MipHeader header, Stream stream, Buffer2D<TPixel> pixels) where TPixel : unmanaged, IPixel<TPixel>
{
stream.Seek(header.DataOffset, SeekOrigin.Begin);

BinaryReader reader = new(stream);

const int blockWidth = 16;
const int blockHeight = 8;

int x = 0;
int y = 0;
int xStart = 0;
int xTarget = blockWidth;
int yStart = 0;
int yTarget = blockHeight;
for (int i = 0; i < header.Width * header.Height; i++)
{
#region hack to get swizzled coordinates

if (x == xTarget && y == yTarget - 1)
{
xStart += blockWidth;
xTarget += blockWidth;

if (xStart == header.Width)
{
xStart = 0;
xTarget = blockWidth;
yStart += blockHeight;
yTarget += blockHeight;
}

x = xStart;
y = yStart;
}
else
{
if (x == xTarget)
{
y += 1;
x = xStart;
}

if (y == yTarget)
{
xStart += blockWidth;
xTarget += blockWidth;
x = xStart;
y = yStart;
}
}

#endregion

switch (header.Bpp)
{
case 8: {
TPixel pixel = new();
pixel.FromRgba32(header.Clut[reader.ReadByte()]);
pixels[x, (int)(header.Height - y - 1)] = pixel;

x++;
break;
}
case 4: {
TPixel pixel = new();

byte data = reader.ReadByte();

pixel.FromRgba32(header.Clut[data & 0x0f]);
pixels[x, (int)(header.Height - y - 1)] = pixel;

pixel.FromRgba32(header.Clut[data >> 4]);
pixels[x + 1, (int)(header.Height - y - 1)] = pixel;

x += 2;
break;
}
default:
throw new Exception("Unknown BPP");
}

}
}

protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
return this.Decode<Rgba32>(options, stream, cancellationToken);
}

protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
MipHeader header = MipHeader.Read(stream);

return new ImageInfo(
new PixelTypeInfo(32),
new Size((int)header.Width, (int)header.Height),
new ImageMetadata
{
HorizontalResolution = header.Width,
VerticalResolution = header.Height,
ResolutionUnits = PixelResolutionUnit.AspectRatio,
}
);
}
}
Loading
Loading