[+] Chuni Userbox with Assets

Co-authored-by: split / May  <split@split.pet>
This commit is contained in:
Raymond
2025-01-01 06:16:16 -05:00
parent 8fb443d41d
commit 8aa829ab02
8 changed files with 1070 additions and 33 deletions

View File

@@ -149,4 +149,7 @@ export interface UserBox {
avatarItem: number,
avatarFront: number,
avatarBack: number,
level: number
playerRating: number
}

View File

@@ -176,9 +176,19 @@ export const EN_REF_USERBOX = {
'userbox.avatarItem': 'Avatar Item',
'userbox.avatarFront': 'Avatar Front',
'userbox.avatarBack': 'Avatar Back',
'userbox.preview.notice': 'To honor the copyright, we cannot host the images of the userbox items. However, if someone else is willing to provide the images, you can enter their URL here and it will be displayed.',
'userbox.preview.url': 'Image URL',
'userbox.error.nodata': 'Chuni data not found',
'userbox.new.name': 'AquaBox',
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
'userbox.new.setup.processing_file': 'Processing',
'userbox.new.setup.finalizing': 'Saving to internal storage',
'userbox.new.drop': 'Drop game folder here',
'userbox.new.activate_first': 'Enable AquaBox (game files required)',
'userbox.new.activate_update': 'Update AquaBox (game files required)',
'userbox.new.activate': 'Use AquaBox',
'userbox.new.activate_desc': 'Enable displaying UserBoxes with their nameplate & avatar',
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A001" option pack is present.'
}
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,

View File

@@ -186,7 +186,6 @@ export const zhUserbox: typeof EN_REF_USERBOX = {
'userbox.avatarItem': '企鹅物品',
'userbox.avatarFront': '企鹅前景',
'userbox.avatarBack': '企鹅背景',
'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。',
'userbox.preview.url': '图床 URL',
'userbox.error.nodata': '未找到中二数据',
};

View File

@@ -0,0 +1,314 @@
/*
A simplified DDS parser with Chusan userbox in mind.
There are some issues on Safari. I don't really care, to be honest.
Authored by Raymond and May.
DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e38933757c2
*/
function makeFourCC(string: string) {
return string.charCodeAt(0) +
(string.charCodeAt(1) << 8) +
(string.charCodeAt(2) << 16) +
(string.charCodeAt(3) << 24);
};
/**
* @description Magic bytes for the DDS file format (see https://en.wikipedia.org/wiki/Magic_number_(programming))
*/
const DDS_MAGIC_BYTES = 0x20534444;
/*
to get around the fact that TS's builtin Object.fromEntries() typing
doesn't persist strict types and instead only uses broad types
without creating a new function to get around it...
sorry, this is a really ugly solution, but it's not my problem
*/
/**
* @description List of compression type markers used in DDS
*/
const DDS_COMPRESSION_TYPE_MARKERS = ["DXT1", "DXT3", "DXT5"] as const;
/**
* @description Object mapping string versions of DDS compression type markers to their value in uint32s
*/
const DDS_COMPRESSION_TYPE_MARKERS_MAP = Object.fromEntries(
DDS_COMPRESSION_TYPE_MARKERS
.map(e => [e, makeFourCC(e)] as [typeof e, number])
) as Record<typeof DDS_COMPRESSION_TYPE_MARKERS[number], number>
const DDS_DECOMPRESS_VERTEX_SHADER = `
attribute vec2 aPosition;
varying highp vec2 vTextureCoord;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
vTextureCoord = ((aPosition * vec2(1.0, -1.0)) / 2.0 + 0.5);
}`;
const DDS_DECOMPRESS_FRAGMENT_SHADER = `
varying highp vec2 vTextureCoord;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture, vTextureCoord);
}`
export class DDS {
constructor(db: IDBDatabase | undefined) {
this.db = db
let gl = this.canvasGL.getContext("webgl");
if (!gl) throw new Error("Failed to get WebGL rendering context") // TODO: make it switch to Classic userbox
this.gl = gl;
let ctx = this.canvas2D.getContext("2d");
if (!ctx) throw new Error("Failed to reach minimum system requirements") // TODO: make it switch to Classic userbox
this.ctx = ctx;
let ext =
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
gl.getExtension("MOZ_WEBGL_compressed_texture_s3tc") ||
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");
if (!ext) throw new Error("Browser is not supported."); // TODO: make it switch to Classic userbox
this.ext = ext;
/* Initialize shaders */
this.compileShaders();
this.gl.useProgram(this.shader);
/* Setup position buffer */
let attributeLocation = this.gl.getAttribLocation(this.shader ?? 0, "aPosition");
let positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]), this.gl.STATIC_DRAW);
this.gl.vertexAttribPointer(
attributeLocation,
2, this.gl.FLOAT,
false, 0, 0
);
this.gl.enableVertexAttribArray(attributeLocation)
}
/**
* @description Loads a DDS file into the internal canvas object.
* @param buffer Uint8Array to load DDS from.
* @returns String if failed to load, void if success
*/
load(buffer: Uint8Array) {
let header = this.loadHeader(buffer);
if (!header) return;
let compressionMode: GLenum = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
if (header.pixelFormat.flags & 0x4) {
switch (header.pixelFormat.type) {
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT1:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
break;
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT3:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT3_EXT;
break;
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT5:
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT5_EXT;
break;
};
} else return;
/* Initialize and configure the texture */
let texture = this.gl.createTexture();
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
this.gl.compressedTexImage2D(
this.gl.TEXTURE_2D,
0,
compressionMode,
header.width,
header.height,
0,
buffer.slice(128)
);
this.gl.uniform1i(this.gl.getUniformLocation(this.shader || 0, "uTexture"), 0);
/* Prepare the canvas for drawing */
this.canvasGL.width = header.width;
this.canvasGL.height = header.height
this.gl.viewport(0, 0, this.canvasGL.width, this.canvasGL.height);
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
this.gl.deleteTexture(texture);
};
/**
* @description Export a Blob from the parsed DDS texture
* @returns DDS texture in specified format
* @param inFormat Mime type to export in
*/
getBlob(inFormat?: string): Promise<Blob | null> {
return new Promise(res => this.canvasGL.toBlob(res, inFormat))
}
get2DBlob(inFormat?: string): Promise<Blob | null> {
return new Promise(res => this.canvas2D.toBlob(res, inFormat))
}
/**
* @description Helper function to load in a Blob
* @input Blob to use
*/
async fromBlob(input: Blob) {
this.load(new Uint8Array(await input.arrayBuffer()));
}
/**
* @description Read a DDS file header
* @param buffer Uint8Array of the DDS file's contents
*/
loadHeader(buffer: Uint8Array) {
if (this.getUint32(buffer, 0) !== DDS_MAGIC_BYTES) return;
return {
size: this.getUint32(buffer, 4),
flags: this.getUint32(buffer, 8),
height: this.getUint32(buffer, 12),
width: this.getUint32(buffer, 16),
mipmaps: this.getUint32(buffer, 24),
/* TODO: figure out if we can cut any of this out (we totally can btw) */
pixelFormat: {
size: this.getUint32(buffer, 76),
flags: this.getUint32(buffer, 80),
type: this.getUint32(buffer, 84),
}
}
};
loadFile(path: string) : Promise<boolean> {
return new Promise(async r => {
if (!this.db)
return r(false);
let transaction = this.db.transaction(["dds"], "readonly");
let objectStore = transaction.objectStore("dds");
let request = objectStore.get(path);
request.onsuccess = async (e) => {
if (request.result)
if (request.result.blob) {
await this.fromBlob(request.result.blob)
return r(true);
}
r(false);
}
request.onerror = () => r(false);
})
};
async getFile(path: string, fallback?: string) : Promise<string> {
if (this.urlCache[path])
return this.urlCache[path]
if (!await this.loadFile(path))
if (fallback) {
if (!await this.loadFile(fallback))
return "";
} else
return "";
let url = URL.createObjectURL(await this.getBlob("image/png") ?? new Blob([]));
this.urlCache[path] = url;
return url
};
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> {
if (!await this.loadFile(path))
return "";
this.canvas2D.width = w * (s ?? 1);
this.canvas2D.height = h * (s ?? 1);
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
};
async getFileScaled(path: string, s: number, fallback?: string): Promise<string> {
if (this.urlCache[path])
return this.urlCache[path]
if (!await this.loadFile(path))
if (fallback) {
if (!await this.loadFile(fallback))
return "";
} else
return "";
this.canvas2D.width = this.canvasGL.width * (s ?? 1);
this.canvas2D.height = this.canvasGL.height * (s ?? 1);
this.ctx.drawImage(this.canvasGL, 0, 0, this.canvasGL.width, this.canvasGL.height, 0, 0, this.canvasGL.width * (s ?? 1), this.canvasGL.height * (s ?? 1));
let url = URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
this.urlCache[path] = url;
return url;
};
/**
* @description Retrieve a Uint32 from a Uint8Array at the specified offset
* @param buffer Uint8Array to retrieve the Uint32 from
* @param offset Offset at which to retrieve bytes
*/
getUint32(buffer: Uint8Array, offset: number) {
return (buffer[offset + 0] << 0) +
(buffer[offset + 1] << 8) +
(buffer[offset + 2] << 16) +
(buffer[offset + 3] << 24);
};
private compileShaders() {
let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader) return;
this.gl.shaderSource(vertexShader, DDS_DECOMPRESS_VERTEX_SHADER);
this.gl.compileShader(vertexShader);
if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS))
throw new Error(
`An error occurred compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)}`,
);
this.gl.shaderSource(fragmentShader, DDS_DECOMPRESS_FRAGMENT_SHADER);
this.gl.compileShader(fragmentShader);
if (!this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS))
throw new Error(
`An error occurred compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)}`,
);
let program = this.gl.createProgram();
if (!program) return;
this.shader = program;
this.gl.attachShader(program, vertexShader);
this.gl.attachShader(program, fragmentShader);
this.gl.linkProgram(program);
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS))
throw new Error(
`An error occurred linking the program: ${this.gl.getProgramInfoLog(program)}`,
);
};
canvas2D: HTMLCanvasElement = document.createElement("canvas");
canvasGL: HTMLCanvasElement = document.createElement("canvas");
urlCache: Record<string, string> = {};
ctx: CanvasRenderingContext2D;
gl: WebGLRenderingContext;
ext: ReturnType<typeof this.gl.getExtension>;
shader: WebGLShader | null = null;
db: IDBDatabase | undefined;
};

View File

@@ -0,0 +1,180 @@
import { t, ts } from "../../libs/i18n";
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isDirectory
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
let reader = directory.createReader();
let files: Array<FileSystemEntry> = [];
let currentFiles: number = 1e9;
while (currentFiles != 0) {
let entries = await new Promise<Array<FileSystemEntry>>(r => reader.readEntries(r));
files = files.concat(entries);
currentFiles = entries.length;
}
return files;
};
const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise<boolean> => {
const pathTrail = path.split("/");
let directory: FileSystemDirectoryEntry = base;
for (let part of pathTrail) {
let newDirectory = await getDirectory(directory, part).catch(_ => null);
if (newDirectory && isDirectory(newDirectory)) {
directory = newDirectory;
} else
return false;
};
return true
}
const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string): Promise<FileSystemDirectoryEntry | null> => {
const pathTrail = path.split("/");
let directory: FileSystemDirectoryEntry = base;
for (let part of pathTrail) {
let newDirectory = await getDirectory(directory, part).catch(_ => null);
if (newDirectory && isDirectory(newDirectory)) {
directory = newDirectory;
} else
return null;
};
return directory;
}
export let ddsDB: IDBDatabase | undefined ;
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
const DIRECTORY_PATHS = ([
{
folder: "ddsImage",
processName: "Characters",
path: "characterThumbnail",
filter: (name: string) => name.substring(name.length - 6, name.length) == "02.dds",
id: (name: string) => `0${name.substring(17, 21)}${name.substring(23, 24)}`
},
{
folder: "namePlate",
processName: "Nameplates",
path: "nameplate",
filter: () => true,
id: (name: string) => name.substring(17, 25)
},
{
folder: "avatarAccessory",
processName: "Avatar Accessory Thumbnails",
path: "avatarAccessoryThumbnail",
filter: (name: string) => name.substring(14, 18) == "Icon",
id: (name: string) => name.substring(19, 27)
},
{
folder: "avatarAccessory",
processName: "Avatar Accessories",
path: "avatarAccessory",
filter: (name: string) => name.substring(14, 17) == "Tex",
id: (name: string) => name.substring(18, 26)
},
{
folder: "texture",
processName: "Surfboard Textures",
useFileName: true,
path: "surfboard",
filter: (name: string) =>
([
"CHU_UI_Common_Avatar_body_00.dds",
"CHU_UI_Common_Avatar_face_00.dds",
"CHU_UI_title_rank_00_v10.dds"
]).includes(name),
id: (name: string) => name
}
] satisfies {folder: string, processName: string, path: string, useFileName?: boolean, filter: (name: string) => boolean, id: (name: string) => string}[] )
export const scanOptionFolder = async (optionFolder: FileSystemDirectoryEntry, progressUpdate: (progress: number, text: string) => void) => {
let filesToProcess: Record<string, FileSystemFileEntry[]> = {};
let directories = (await getFiles(optionFolder))
.filter(directory => isDirectory(directory) && ((directory.name.substring(0, 1) == "A" && directory.name.length == 4) || directory.name == "surfboard"))
for (let directory of directories)
if (isDirectory(directory)) {
for (const directoryData of DIRECTORY_PATHS) {
let folder = await getDirectoryFromPath(directory, directoryData.folder).catch(_ => null) ?? [];
if (folder) {
if (!filesToProcess[directoryData.path])
filesToProcess[directoryData.path] = [];
for (let dataFolderEntry of await getFiles(folder as FileSystemDirectoryEntry).catch(_ => null) ?? [])
if (isDirectory(dataFolderEntry)) {
for (let dataEntry of await getFiles(dataFolderEntry as FileSystemDirectoryEntry).catch(_ => null) ?? [])
if (isFile(dataEntry) && directoryData.filter(dataEntry.name))
filesToProcess[directoryData.path].push(dataEntry);
} else if (isFile(dataFolderEntry) && directoryData.filter(dataFolderEntry.name))
filesToProcess[directoryData.path].push(dataFolderEntry);
}
}
}
let data = [];
for (const [folder, files] of Object.entries(filesToProcess)) {
let reference = DIRECTORY_PATHS.find(r => r.path == folder);
for (const [idx, file] of files.entries()) {
progressUpdate((idx / files.length) * 100, `${t("userbox.new.setup.processing_file")} ${reference?.processName ?? "?"}...`)
data.push({
path: `${folder}:${reference?.id(file.name)}`, name: file.name, blob: await new Promise<File>(res => file.file(res))
});
}
}
progressUpdate(100, `${t("userbox.new.setup.finalizing")}...`)
let transaction = ddsDB?.transaction(['dds'], 'readwrite', { durability: "strict" })
if (!transaction) return; // TODO: bubble error up to user
transaction.onerror = e => e.preventDefault()
let objectStore = transaction.objectStore('dds');
for (let object of data)
objectStore.put(object)
// await transaction completion
await new Promise(r => transaction.addEventListener("complete", r, {once: true}))
};
export function initializeDb() : Promise<void> {
return new Promise(r => {
const dbRequest = indexedDB.open("userboxChusanDDS", 1)
dbRequest.addEventListener("upgradeneeded", (event) => {
if (!(event.target instanceof IDBOpenDBRequest)) return
ddsDB = event.target.result;
if (!ddsDB) return;
const store = ddsDB.createObjectStore('dds', { keyPath: 'path' });
store.createIndex('path', 'path', { unique: true })
store.createIndex('name', 'name', { unique: false })
store.createIndex('blob', 'blob', { unique: false })
r();
});
dbRequest.addEventListener("success", () => {
ddsDB = dbRequest.result;
r();
})
})
}
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
if (!isDirectory(folder))
return t("userbox.new.error.invalidFolder")
if (!(await validateDirectories(folder, "bin/option")) || !(await validateDirectories(folder, "data/A000")))
return t("userbox.new.error.invalidFolder");
initializeDb();
const optionFolder = await getDirectoryFromPath(folder, "bin/option");
if (optionFolder)
await scanOptionFolder(optionFolder, progressUpdate);
const dataFolder = await getDirectoryFromPath(folder, "data");
if (dataFolder)
await scanOptionFolder(dataFolder, progressUpdate);
useLocalStorage("userboxNew", false).value = true;
location.reload();
return null
}