From 8aa829ab020c460289159fa1e8c4d381a81a8149 Mon Sep 17 00:00:00 2001 From: Raymond <101374892+raymonable@users.noreply.github.com> Date: Wed, 1 Jan 2025 06:16:16 -0500 Subject: [PATCH 1/7] [+] Chuni Userbox with Assets Co-authored-by: split / May --- .../components/settings/ChuniSettings.svelte | 291 ++++++++++++++-- .../settings/userbox/ChuniPenguin.svelte | 165 +++++++++ .../settings/userbox/ChuniUserplate.svelte | 137 ++++++++ AquaNet/src/libs/generalTypes.ts | 3 + AquaNet/src/libs/i18n/en_ref.ts | 12 +- AquaNet/src/libs/i18n/zh.ts | 1 - AquaNet/src/libs/userbox/dds.ts | 314 ++++++++++++++++++ AquaNet/src/libs/userbox/userbox.ts | 180 ++++++++++ 8 files changed, 1070 insertions(+), 33 deletions(-) create mode 100644 AquaNet/src/components/settings/userbox/ChuniPenguin.svelte create mode 100644 AquaNet/src/components/settings/userbox/ChuniUserplate.svelte create mode 100644 AquaNet/src/libs/userbox/dds.ts create mode 100644 AquaNet/src/libs/userbox/userbox.ts diff --git a/AquaNet/src/components/settings/ChuniSettings.svelte b/AquaNet/src/components/settings/ChuniSettings.svelte index 63a7739b..372a147e 100644 --- a/AquaNet/src/components/settings/ChuniSettings.svelte +++ b/AquaNet/src/components/settings/ChuniSettings.svelte @@ -16,6 +16,14 @@ import { filter } from "d3"; import { coverNotFound } from "../../libs/ui"; + import { userboxFileProcess, ddsDB, initializeDb } from "../../libs/userbox/userbox" + + import ChuniPenguinComponent from "./userbox/ChuniPenguin.svelte" + import ChuniUserplateComponent from "./userbox/ChuniUserplate.svelte"; + + import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte"; + import { DDS } from "../../libs/userbox/dds"; + let user: AquaNetUser let [loading, error, submitting, preview] = [true, "", "", ""] let changed: string[] = []; @@ -26,7 +34,7 @@ let iKinds = { namePlate: 1, frame: 2, trophy: 3, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 } // In userbox: 'nameplateId', 'frameId', 'trophyId', 'mapIconId', 'voiceId', 'avatar{Wear/Head/Face/Skin/Item/Front/Back}' let userbox: UserBox - let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] + let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] as const // iKey should match allItems keys, and ubKey should match userbox keys let userItems: { iKey: string, ubKey: keyof UserBox, items: UserItem[] }[] = [] @@ -83,6 +91,40 @@ user = u return fetchData() }).catch((e) => { loading = false; error = e.message }); + + let DDSreader: DDS | undefined; + + let USERBOX_PROGRESS = 0; + let USERBOX_SETUP_RUN = false; + let USERBOX_SETUP_TEXT = t("userbox.new.setup"); + + let USERBOX_ENABLED = useLocalStorage("userboxNew", false); + let USERBOX_INSTALLED = false; + let USERBOX_SUPPORT = "webkitGetAsEntry" in DataTransferItem.prototype; + + type OnlyNumberPropsOf> = {[Prop in keyof T as (T[Prop] extends number ? Prop : never)]: T[Prop]} + let userboxSelected: keyof OnlyNumberPropsOf = "avatarWear"; + const userboxNewOptions = ["systemVoice", "frame", "trophy", "mapIcon"] + + async function userboxSafeDrop(event: Event & { currentTarget: EventTarget & HTMLInputElement; }) { + if (!event.target) return null; + let input = event.target as HTMLInputElement; + let folder = input.webkitEntries[0]; + error = await userboxFileProcess(folder, (progress: number, progressString: string) => { + USERBOX_SETUP_TEXT = progressString; + USERBOX_PROGRESS = progress; + }) ?? ""; + } + + indexedDB.databases().then(async (dbi) => { + let databaseExists = dbi.some(db => db.name == "userboxChusanDDS"); + if (databaseExists) { + await initializeDb(); + DDSreader = new DDS(ddsDB); + USERBOX_INSTALLED = databaseExists; + } + }) + @@ -91,42 +133,123 @@

{t("userbox.header.general")}

{t("userbox.header.userbox")}

-
- {#each userItems as { iKey, ubKey, items }, i} -
- -
- - {#if changed.includes(ubKey)} - - {/if} -
-
- {/each} -
- {#if HAS_USERBOX_ASSETS} -

{t("userbox.header.preview")}

-

{t("userbox.preview.notice")}

- - {#if preview} -
- {#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i} + {#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED} +
+ {#each userItems as { iKey, ubKey, items }, i} +
+
- {ts(`userbox.${ubKey}`)} - + + {#if changed.includes(ubKey)} + + {/if}
+
+ {/each} +
+ {:else} +
+ userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level} chuniRating={userbox.playerRating / 100} + chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}> + +
+
+ {#each avatarKinds as avatarKind} + {#await DDSreader?.getFile(`avatarAccessoryThumbnail:${userbox[`avatar${avatarKind}`].toString().padStart(8, "0")}`) then imageURL} + + {/await} + {/each} +
+
+ {#if userboxSelected == "nameplateId"} + {#each userItems.find(f => f.ubKey == "nameplateId")?.items ?? [] as item} + {#await DDSreader?.getFile(`nameplate:${item.itemId.toString().padStart(8, "0")}`) then imageURL} + + {/await} {/each} -
- {/if} + {:else} + {#each userItems.find(f => f.ubKey == userboxSelected)?.items ?? [] as item} + {#await DDSreader?.getFile(`avatarAccessoryThumbnail:${item.itemId.toString().padStart(8, "0")}`) then imageURL} + + {/await} + {/each} + {/if} +
+
+ {#each userItems.filter(i => userboxNewOptions.includes(i.iKey)) as { iKey, ubKey, items }, i} +
+ +
+ + {#if changed.includes(ubKey)} + + {/if} +
+
+ {/each} +
+ {/if} + {#if USERBOX_INSTALLED} + +
+ + +
+ {/if} + {#if USERBOX_SUPPORT} +

+ +

{/if} {/if} + +{#if USERBOX_SETUP_RUN && !error} +
+
+

{t('userbox.new.name')}

+ {USERBOX_SETUP_TEXT} +
+ {#if USERBOX_PROGRESS != 0} +
+
+
+ {:else} + + + {/if} +
+
+
+{/if} diff --git a/AquaNet/src/components/settings/userbox/ChuniPenguin.svelte b/AquaNet/src/components/settings/userbox/ChuniPenguin.svelte new file mode 100644 index 00000000..ed9730c6 --- /dev/null +++ b/AquaNet/src/components/settings/userbox/ChuniPenguin.svelte @@ -0,0 +1,165 @@ + +
+
+ + {#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 0, 256, 400, 0.75) then imageURL} + Body + {/await} + + + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_face_00.dds", 0, 0, 225, 150, 0.75) then imageURL} + Eyes + {/await} + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 86, 103, 96, 43, 0.75) then imageURL} + Beak + {/await} + + + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL} + Left Arm + {/await} + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL} + Right Arm + {/await} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniWear.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01100001`) then imageURL} + Wear + {/await} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniHead.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01200001`) then imageURL} + Head + {/await} + {#if chuniHead == 1200001} + + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 104, 153, 57, 58, 0.75) then imageURL} + Head2 + {/await} + {#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 5, 160, 100, 150, 0.75) then imageURL} + Head3 + {/await} + {/if} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniFace.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01300001`) then imageURL} + Face (Accessory) + {/await} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01500001`) then imageURL} + Item + {/await} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniFront.toString().padStart(8, "0")}`, 0.75) then imageURL} + Front + {/await} + + + {#await DDSreader.getFileScaled(`avatarAccessory:${chuniBack.toString().padStart(8, "0")}`, 0.75) then imageURL} + Back + {/await} +
+
+ + {#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 167, 80, 0.75) then imageURL} + Feet + {/await} +
+
+ + \ No newline at end of file diff --git a/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte b/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte new file mode 100644 index 00000000..836ec866 --- /dev/null +++ b/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte @@ -0,0 +1,137 @@ + +{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`) then nameplateURL} + + +
+ {#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`) then characterThumbnailURL} + Character + {/await} + {#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_title_rank_00_v10.dds", 5, 5 + (75 * 2), 595, 64) then trophyURL} +
+ {chuniTrophyName} + Trophy +
+ {/await} + +
+{/await} + \ No newline at end of file diff --git a/AquaNet/src/libs/generalTypes.ts b/AquaNet/src/libs/generalTypes.ts index 4382c0ab..de460d71 100644 --- a/AquaNet/src/libs/generalTypes.ts +++ b/AquaNet/src/libs/generalTypes.ts @@ -149,4 +149,7 @@ export interface UserBox { avatarItem: number, avatarFront: number, avatarBack: number, + + level: number + playerRating: number } diff --git a/AquaNet/src/libs/i18n/en_ref.ts b/AquaNet/src/libs/i18n/en_ref.ts index 27e2729b..4b225e5b 100644 --- a/AquaNet/src/libs/i18n/en_ref.ts +++ b/AquaNet/src/libs/i18n/en_ref.ts @@ -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, diff --git a/AquaNet/src/libs/i18n/zh.ts b/AquaNet/src/libs/i18n/zh.ts index a98e6e93..a96060a0 100644 --- a/AquaNet/src/libs/i18n/zh.ts +++ b/AquaNet/src/libs/i18n/zh.ts @@ -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': '未找到中二数据', }; diff --git a/AquaNet/src/libs/userbox/dds.ts b/AquaNet/src/libs/userbox/dds.ts new file mode 100644 index 00000000..b95afd23 --- /dev/null +++ b/AquaNet/src/libs/userbox/dds.ts @@ -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 + +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 { + return new Promise(res => this.canvasGL.toBlob(res, inFormat)) + } + get2DBlob(inFormat?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + + ctx: CanvasRenderingContext2D; + + gl: WebGLRenderingContext; + ext: ReturnType; + shader: WebGLShader | null = null; + + db: IDBDatabase | undefined; +}; \ No newline at end of file diff --git a/AquaNet/src/libs/userbox/userbox.ts b/AquaNet/src/libs/userbox/userbox.ts new file mode 100644 index 00000000..2b3e7343 --- /dev/null +++ b/AquaNet/src/libs/userbox/userbox.ts @@ -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 => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej())); +const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej())); +const getFiles = async (directory: FileSystemDirectoryEntry): Promise> => { + let reader = directory.createReader(); + let files: Array = []; + let currentFiles: number = 1e9; + while (currentFiles != 0) { + let entries = await new Promise>(r => reader.readEntries(r)); + files = files.concat(entries); + currentFiles = entries.length; + } + return files; +}; + +const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise => { + 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 => { + 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 = {}; + 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(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 { + 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 { + 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 +} \ No newline at end of file From f1d1b81456ae1c28075da0836fea48f1e67d17f8 Mon Sep 17 00:00:00 2001 From: split / May Date: Wed, 1 Jan 2025 04:23:04 -0800 Subject: [PATCH 2/7] refactor: :recycle: replace "CHUNITHM" string with "AquaDX" recommendation from @raymonable --- AquaNet/src/components/settings/userbox/ChuniUserplate.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte b/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte index 836ec866..37d485c7 100644 --- a/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte +++ b/AquaNet/src/components/settings/userbox/ChuniUserplate.svelte @@ -5,7 +5,7 @@ const DDSreader = new DDS(ddsDB); export var chuniLevel: number = 1 - export var chuniName: string = "CHUNITHM" + export var chuniName: string = "AquaDX" export var chuniRating: number = 1.23 export var chuniNameplate: number = 1 export var chuniCharacter: number = 0 From 223de57b65d8508f8072ff959d1dbc12ef758fa7 Mon Sep 17 00:00:00 2001 From: split / May Date: Wed, 1 Jan 2025 05:13:13 -0800 Subject: [PATCH 3/7] style: linebreaks --- AquaNet/src/libs/userbox/dds.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AquaNet/src/libs/userbox/dds.ts b/AquaNet/src/libs/userbox/dds.ts index b95afd23..56180c8c 100644 --- a/AquaNet/src/libs/userbox/dds.ts +++ b/AquaNet/src/libs/userbox/dds.ts @@ -225,6 +225,7 @@ export class DDS { this.urlCache[path] = url; return url }; + async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise { if (!await this.loadFile(path)) return ""; @@ -234,6 +235,7 @@ export class DDS { return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])); }; + async getFileScaled(path: string, s: number, fallback?: string): Promise { if (this.urlCache[path]) return this.urlCache[path] From 81ef029bf6d52b3367f6e444f1b1a55144f3ad7f Mon Sep 17 00:00:00 2001 From: split / May Date: Wed, 1 Jan 2025 05:21:29 -0800 Subject: [PATCH 4/7] docs: :memo: add TSDoc comments to functions in the DDS class --- AquaNet/src/libs/userbox/dds.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/AquaNet/src/libs/userbox/dds.ts b/AquaNet/src/libs/userbox/dds.ts index 56180c8c..4da5fe28 100644 --- a/AquaNet/src/libs/userbox/dds.ts +++ b/AquaNet/src/libs/userbox/dds.ts @@ -193,6 +193,11 @@ export class DDS { } }; + /** + * @description Retrieve a file from the IndexedDB database and load it into the DDS loader + * @param path File path + * @returns Whether or not the attempt to retrieve the file was successful + */ loadFile(path: string) : Promise { return new Promise(async r => { if (!this.db) @@ -212,6 +217,12 @@ export class DDS { }) }; + /** + * @description Retrieve a file from a path + * @param path File path + * @param fallback Path to a file to fallback to if loading this file fails + * @returns An object URL which correlates to a Blob + */ async getFile(path: string, fallback?: string) : Promise { if (this.urlCache[path]) return this.urlCache[path] @@ -226,6 +237,16 @@ export class DDS { return url }; + /** + * @description Transform a spritesheet located at a path to match the dimensions specified in the parameters + * @param path Spritesheet path + * @param x Crop: X + * @param y Crop: Y + * @param w Crop: Width + * @param h Crop: Height + * @param s Scale factor + * @returns An object URL which correlates to a Blob + */ async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise { if (!await this.loadFile(path)) return ""; @@ -236,6 +257,13 @@ export class DDS { return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])); }; + /** + * @description Retrieve a file and scale it by a specified scale factor + * @param path File path + * @param s Scale factor + * @param fallback Path to a file to fallback to if loading this file fails + * @returns An object URL which correlates to a Blob + */ async getFileScaled(path: string, s: number, fallback?: string): Promise { if (this.urlCache[path]) return this.urlCache[path] From 931e611cf77eaf892aba78fb8cf761922871f9b8 Mon Sep 17 00:00:00 2001 From: split / May Date: Wed, 1 Jan 2025 06:21:12 -0800 Subject: [PATCH 5/7] revert: :rewind: Reverse decision to remove the classic UserBox preview Adds back the classic UserBox preview when AquaBox is disabled / unavailable --- .../components/settings/ChuniSettings.svelte | 45 +++++++++++++------ AquaNet/src/libs/i18n/en_ref.ts | 1 + AquaNet/src/libs/i18n/zh.ts | 1 + 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/AquaNet/src/components/settings/ChuniSettings.svelte b/AquaNet/src/components/settings/ChuniSettings.svelte index 372a147e..7ff8be0f 100644 --- a/AquaNet/src/components/settings/ChuniSettings.svelte +++ b/AquaNet/src/components/settings/ChuniSettings.svelte @@ -209,20 +209,37 @@ {/each} {/if} - {#if USERBOX_INSTALLED} - -
- - -
- {/if} - {#if USERBOX_SUPPORT} -

- -

+ {#if HAS_USERBOX_ASSETS} + {#if USERBOX_INSTALLED} + +
+ + +
+ {/if} + {#if USERBOX_SUPPORT} +

+ +

+ {/if} + {#if !USERBOX_SUPPORT || !USERBOX_INSTALLED || !USERBOX_ENABLED.value} +

{t("userbox.header.preview")}

+

{t("userbox.preview.notice")}

+ + {#if preview} +
+ {#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i} +
+ {ts(`userbox.${ubKey}`)} + +
+ {/each} +
+ {/if} + {/if} {/if} {/if} diff --git a/AquaNet/src/libs/i18n/en_ref.ts b/AquaNet/src/libs/i18n/en_ref.ts index 4b225e5b..40a07673 100644 --- a/AquaNet/src/libs/i18n/en_ref.ts +++ b/AquaNet/src/libs/i18n/en_ref.ts @@ -176,6 +176,7 @@ 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', diff --git a/AquaNet/src/libs/i18n/zh.ts b/AquaNet/src/libs/i18n/zh.ts index a96060a0..a98e6e93 100644 --- a/AquaNet/src/libs/i18n/zh.ts +++ b/AquaNet/src/libs/i18n/zh.ts @@ -186,6 +186,7 @@ export const zhUserbox: typeof EN_REF_USERBOX = { 'userbox.avatarItem': '企鹅物品', 'userbox.avatarFront': '企鹅前景', 'userbox.avatarBack': '企鹅背景', + 'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。', 'userbox.preview.url': '图床 URL', 'userbox.error.nodata': '未找到中二数据', }; From ce95f2165d67bab6ff89ec1ef14471a1090f3600 Mon Sep 17 00:00:00 2001 From: Raymond <101374892+raymonable@users.noreply.github.com> Date: Wed, 1 Jan 2025 15:06:52 -0500 Subject: [PATCH 6/7] style: make nameplates fit better --- AquaNet/src/components/settings/ChuniSettings.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AquaNet/src/components/settings/ChuniSettings.svelte b/AquaNet/src/components/settings/ChuniSettings.svelte index 7ff8be0f..2a5f2c33 100644 --- a/AquaNet/src/components/settings/ChuniSettings.svelte +++ b/AquaNet/src/components/settings/ChuniSettings.svelte @@ -174,7 +174,7 @@ {#if userboxSelected == "nameplateId"} {#each userItems.find(f => f.ubKey == "nameplateId")?.items ?? [] as item} {#await DDSreader?.getFile(`nameplate:${item.itemId.toString().padStart(8, "0")}`) then imageURL} - {/await} @@ -440,6 +440,11 @@ p.notice img width: 100% + &.nameplate + width: 50% + aspect-ratio: unset + border: none + .chuni-userbox-container display: flex align-items: center From 4d4335004fe4394a074cb5179cc850e0d2a418fb Mon Sep 17 00:00:00 2001 From: Raymond <101374892+raymonable@users.noreply.github.com> Date: Wed, 1 Jan 2025 16:30:48 -0500 Subject: [PATCH 7/7] refactor: move DDS cache moved the DDS cache from dds.ts to ddsCache.ts and added caching for scaled images Co-authored-by: split / May --- AquaNet/src/libs/userbox/dds.ts | 50 +++++++++------------- AquaNet/src/libs/userbox/ddsCache.ts | 64 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 AquaNet/src/libs/userbox/ddsCache.ts diff --git a/AquaNet/src/libs/userbox/dds.ts b/AquaNet/src/libs/userbox/dds.ts index 4da5fe28..c2917aa6 100644 --- a/AquaNet/src/libs/userbox/dds.ts +++ b/AquaNet/src/libs/userbox/dds.ts @@ -8,6 +8,8 @@ DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e389337 */ +import DDSCache from "./ddsCache"; + function makeFourCC(string: string) { return string.charCodeAt(0) + (string.charCodeAt(1) << 8) + @@ -56,7 +58,7 @@ void main() { export class DDS { constructor(db: IDBDatabase | undefined) { - this.db = db + this.cache = new DDSCache(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 @@ -200,20 +202,10 @@ export class DDS { */ loadFile(path: string) : Promise { 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); + let file = await this.cache?.getFromDatabase(path) + if (file != null) + await this.fromBlob(file) + r(file != null) }) }; @@ -224,17 +216,19 @@ export class DDS { * @returns An object URL which correlates to a Blob */ async getFile(path: string, fallback?: string) : Promise { - if (this.urlCache[path]) - return this.urlCache[path] + if (this.cache?.cached(path)) + return this.cache.find(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 + return "" + let blob = await this.getBlob("image/png"); + if (!blob) return "" + return this.cache?.save( + path, URL.createObjectURL(blob) + ) ?? ""; }; /** @@ -254,6 +248,7 @@ export class DDS { this.canvas2D.height = h * (s ?? 1); this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1)); + /* We don't want to cache this, it's a spritesheet piece. */ return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])); }; @@ -265,8 +260,8 @@ export class DDS { * @returns An object URL which correlates to a Blob */ async getFileScaled(path: string, s: number, fallback?: string): Promise { - if (this.urlCache[path]) - return this.urlCache[path] + if (this.cache?.cached(path, s)) + return this.cache.find(path, s) ?? "" if (!await this.loadFile(path)) if (fallback) { if (!await this.loadFile(fallback)) @@ -276,9 +271,8 @@ export class DDS { 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; + + return this.cache?.save(path, URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])), s) ?? ""; }; /** @@ -332,13 +326,11 @@ export class DDS { canvas2D: HTMLCanvasElement = document.createElement("canvas"); canvasGL: HTMLCanvasElement = document.createElement("canvas"); - urlCache: Record = {}; + cache: DDSCache | null; ctx: CanvasRenderingContext2D; gl: WebGLRenderingContext; ext: ReturnType; shader: WebGLShader | null = null; - - db: IDBDatabase | undefined; }; \ No newline at end of file diff --git a/AquaNet/src/libs/userbox/ddsCache.ts b/AquaNet/src/libs/userbox/ddsCache.ts new file mode 100644 index 00000000..ff973d13 --- /dev/null +++ b/AquaNet/src/libs/userbox/ddsCache.ts @@ -0,0 +1,64 @@ +export default class DDSCache { + constructor(db: IDBDatabase | undefined) { + this.db = db; + } + + /** + * @description Finds an object URL for the image with the specified path and scale + * @param path Image path + * @param scale Scale factor + */ + find(path: string, scale: number = 1): string | undefined { + return (this.urlCache.find( + p => p.path == path && p.scale == scale)?.url) + } + + /** + * @description Checks whether an object URL is cached for the image with the specified path and scale + * @param path Image path + * @param scale Scale factor + */ + cached(path: string, scale: number = 1): boolean { + return this.urlCache.some( + p => p.path == path && p.scale == scale) + } + + /** + * @description Save an object URL for the specified path and scale to the cache + * @param path Image path + * @param url Object URL + * @param scale Scale factor + */ + save(path: string, url: string, scale: number = 1) { + if (this.cached(path, scale)) { + URL.revokeObjectURL(url); + return this.find(path, scale) + } + this.urlCache.push({path, url, scale}) + return url + } + + /** + * @description Retrieve a Blob from a database based on the specified path + * @param path Image path + */ + getFromDatabase(path: string): Promise { + return new Promise((resolve, reject) => { + if (!this.db) + return resolve(null); + 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) + return resolve(request.result.blob); + return resolve(null); + } + request.onerror = () => resolve(null); + }) + }; + + private urlCache: {scale: number, path: string, url: string}[] = []; + private db: IDBDatabase | undefined; +} \ No newline at end of file