From 2c8793a796cd66727c6b3da9eb97ebe64b57704e Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 3 Jul 2024 06:00:16 -0400 Subject: [PATCH] msagent.js: Parse character info msagent.js can now parse the character section of a ACS file fully. This also adds a testbed thing. Later on it will display individual frames and animations for debugging before the full character API is ready. For now, it just has a file input for dumping ACS files into so we can debug reading of the data. --- msagent.js/res/wordballoon.xcf | Bin 0 -> 1508 bytes msagent.js/src/buffer.ts | 117 +++++++++++ msagent.js/src/character.ts | 342 +++++++++++++++++++++++++++++++++ msagent.js/src/index.ts | 1 + webapp/package.json | 4 +- webapp/src/html/testbed.html | 21 ++ webapp/src/ts/testbed.ts | 21 ++ 7 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 msagent.js/res/wordballoon.xcf create mode 100644 msagent.js/src/buffer.ts create mode 100644 msagent.js/src/character.ts create mode 100644 webapp/src/html/testbed.html create mode 100644 webapp/src/ts/testbed.ts diff --git a/msagent.js/res/wordballoon.xcf b/msagent.js/res/wordballoon.xcf new file mode 100644 index 0000000000000000000000000000000000000000..0f241106b2cb7ccc46ff840608843dc1c5f600ee GIT binary patch literal 1508 zcmd5+%Wl&^6dl{K`+(9A+NKqyn(|V*h!lSTi!Klnm5}%X*O@pLNnF_ml2uu-WQkbu z1*rcuAHb%Is>8YCnRF_3gT#Uxo!oQJy`D^uXPV{HGd@p8{L>(g8R99>A%ge;JOn%s zUyp#*V@L$V7SIA706T`$+&1tV*vPPSKP{$HF{@ECYIcrGkJES zvJiUMHxm(!0}41K{^|?9VLm`QJ+)>yl73^PQ|tqluREMO{GP+_JAA|8o8a_&Hda7r zU0VNz;YjQ`afLvB=5TvB0%%V7bEmK_nEm?S@907DFRb2WYbs)G{Md>LSx4s<+d__1 zz_yV?)iU{t?AuWucBg3<-2mmaV!N6`CF(Js1{B)^mh_U8M%9n_Y9G}(`^Ns0JxKT< z;e&(^I>QI#NWur?P{IeDub6}n5@-*c_u5?1&^Rn4uPBO;{{QDlLK{t7tz2|9KI9uL9!=i z^Szjew9bneKS`<&bUy5%x0mM}T61}t)AP5NhdXQ1e>hs7E?yfjhx#Qb-5)pXxu;Pl Z!Z8ZFd(#WQIs>)`Dg0@5I&1#|e*jJFPt5=T literal 0 HcmV?d00001 diff --git a/msagent.js/src/buffer.ts b/msagent.js/src/buffer.ts new file mode 100644 index 0000000..bf62e9a --- /dev/null +++ b/msagent.js/src/buffer.ts @@ -0,0 +1,117 @@ +// This is more a utility thing but it will more than likely only ever +// be used in the msagent.js code so /shrug + +export enum SeekDir { + BEG = 0, + CUR = 1, + END = 2 +}; + +// A helper over DataView to make it more ergonomic for parsing file data. +export class BufferStream { + private bufferImpl: Uint8Array; + private dataView: DataView; + private readPointer: number = 0; + + constructor(buffer: Uint8Array, byteOffset?: number) { + this.bufferImpl = buffer; + this.dataView = new DataView(this.bufferImpl.buffer, byteOffset); + } + + seek(where: number, whence: SeekDir) { + switch(whence) { + case SeekDir.BEG: + this.readPointer = where; + break; + + case SeekDir.CUR: + this.readPointer += where; + break; + + case SeekDir.END: + if(where > 0) + throw new Error("Cannot use SeekDir.END with where greater than 0"); + + this.readPointer = this.bufferImpl.length + whence; + break; + } + + return this.readPointer; + } + + tell() { return this.seek(0, SeekDir.CUR); } + + // common impl function for read*() + private readImpl(func: (this: DataView, offset: number, le?: boolean|undefined) => T, size: number, le?: boolean|undefined) { + let res = func.call(this.dataView, this.readPointer, le); + this.readPointer += size; + return res; + } + + // Creates a view of a part of the buffer. + // THIS DOES NOT DEEP COPY! + subBuffer(len: number) { + let oldReadPointer = this.readPointer; + let buffer = this.bufferImpl.subarray(oldReadPointer, oldReadPointer + len); + this.readPointer += len; + return new BufferStream(buffer, oldReadPointer); + } + + readS8() { return this.readImpl(DataView.prototype.getInt8, 1); } + readU8() { return this.readImpl(DataView.prototype.getUint8, 1); } + readS16LE() { return this.readImpl(DataView.prototype.getInt16, 2, true); } + readS16BE() { return this.readImpl(DataView.prototype.getInt16, 2, false); } + readU16LE() { return this.readImpl(DataView.prototype.getUint16, 2, true); } + readU16BE() { return this.readImpl(DataView.prototype.getUint16, 2, false); } + readS32LE() { return this.readImpl(DataView.prototype.getInt32, 4, true); } + readS32BE() { return this.readImpl(DataView.prototype.getInt32, 4, false); } + readU32LE() { return this.readImpl(DataView.prototype.getUint32, 4, true); } + readU32BE() { return this.readImpl(DataView.prototype.getUint32, 4, false); } + + // converts easy! + readBool() : boolean { + let res = this.readU8(); + return res != 0; + } + + readString(len: number, charReader: (this: BufferStream) => TChar): string { + let str = ""; + + for(let i = 0; i < len; ++i) + str += String.fromCharCode(charReader.call(this)); + + // dispose of a nul terminator + charReader.call(this); + return str; + } + + readPascalString(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE, charReader: (this: BufferStream) => number = BufferStream.prototype.readU16LE) { + let len = lengthReader.call(this); + if(len == 0) + return ""; + + return this.readString(len, charReader); + } + + readDataChunk(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE) { + let len = lengthReader.call(this); + return this.subBuffer(len).raw(); + } + + // reads a counted list. The length reader is on the other end so you don't need to specify it + // (if it's u32) + readCountedList(objReader: (stream: BufferStream) => TObject, lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE): TObject[] { + let len = lengthReader.call(this); + let arr: TObject[] = []; + + for(let i = 0; i < len; ++i) + arr.push(objReader(this)); + + return arr; + } + + + raw() { + return this.bufferImpl; + } +} diff --git a/msagent.js/src/character.ts b/msagent.js/src/character.ts new file mode 100644 index 0000000..5892555 --- /dev/null +++ b/msagent.js/src/character.ts @@ -0,0 +1,342 @@ +import { BufferStream, SeekDir } from "./buffer.js"; + +class GUID { + bytes: number[] = []; + + static read(buffer: BufferStream) { + let guid = new GUID(); + + for(var i = 0; i < 16; ++i) + guid.bytes.push(buffer.readU8()); + + return guid; + } +}; + +class LOCATION { + offset: number = 0; + size: number = 0; + + static read(buffer: BufferStream) { + let loc = new LOCATION(); + loc.offset = buffer.readU32LE(); + loc.size = buffer.readU32LE(); + return loc; + } +}; + +class RGBAColor { + r = 0; + g = 0; + b = 0; + a = 0; + + // does what it says on the tin + to_rgba(): number { + return (this.r << 24) | (this.g << 16) | (this.b << 8) | this.a; + } + + static from_gdi_rgbquad(val: number, transparent: boolean = false) { + let quad = new RGBAColor(); + + // Extract individual RGB values from the RGBQUAD + // We ignore the last 8 bits because it is always left + // as 0x00 or if uncleared, just random garbage. + quad.r = (val & 0xff000000) >> 24; + quad.g = (val & 0x00ff0000) >> 16; + quad.b = (val & 0x0000ff00) >> 8; + + if(transparent) + quad.a = 0; + else + quad.a = 255; + + return quad; + } + + static read(buffer: BufferStream, transparent: boolean = false) { + return RGBAColor.from_gdi_rgbquad(buffer.readU32LE(), transparent); + } +} + +// DA doesn't test individual bits, but for brevity I do +enum AcsCharacterInfoFlags { + // This agent is configured for a given TTS. + VoiceOutput = (1 << 5), + + // Could be a 2-bit value (where 01 = disable and 10 = enable) + // I wonder why. + WordBalloonDisabled = (1 << 8), + WordBalloonEnabled = (1 << 9), + + // 16-18 are a 3-bit unsigned + // value which stores a inner set of + // bits to control the style of the wordballoon. + + StandardAnimationSet = (1 << 20) +}; + +class AcsVoiceInfoExtraData { + langId = 0; + langDialect = ""; + + gender = 0; + age = 0; + + style = ""; + + static read(buffer: BufferStream) { + let info = new AcsVoiceInfoExtraData(); + + info.langId = buffer.readU16LE(); + + info.langDialect = buffer.readPascalString(); + + info.gender = buffer.readU16LE(); + info.age = buffer.readU16LE(); + + info.style = buffer.readPascalString(); + + return info; + } +}; + +class AcsVoiceInfo { + ttsEngineId = new GUID(); + ttsModeId = new GUID(); + + speed = 0; + pitch = 0; + + extraData: AcsVoiceInfoExtraData | null = null; + + static read(buffer: BufferStream) { + let info = new AcsVoiceInfo(); + + info.ttsEngineId = GUID.read(buffer); + info.ttsModeId = GUID.read(buffer); + + info.speed = buffer.readU32LE(); + info.pitch = buffer.readU16LE(); + + // extraData member + if(buffer.readBool()) { + info.extraData = AcsVoiceInfoExtraData.read(buffer); + } + + return info; + } +}; + +class AcsBalloonInfo { + nrTextLines = 0; + charsPerLine = 0; + + foreColor = new RGBAColor(); + backColor = new RGBAColor(); + borderColor = new RGBAColor(); + + fontName = ""; + + fontHeight = 0; + fontWeight = 0; + + italic = false; + unkFlag = false; + + static read(buffer: BufferStream) { + let info = new AcsBalloonInfo(); + + info.nrTextLines = buffer.readU8(); + info.charsPerLine = buffer.readU8(); + + info.foreColor = RGBAColor.read(buffer); + info.backColor = RGBAColor.read(buffer); + info.borderColor = RGBAColor.read(buffer); + + info.fontName = buffer.readPascalString(); + info.fontHeight = buffer.readS32LE(); + info.fontWeight = buffer.readS32LE(); + + info.italic = buffer.readBool(); + info.unkFlag = buffer.readBool(); + + return info; + } +}; + +class AcsTrayIcon { + monoBitmap: Uint8Array | null = null; + colorBitmap: Uint8Array | null = null; + + static read(buffer: BufferStream) { + let icon = new AcsTrayIcon(); + icon.monoBitmap = buffer.readDataChunk(BufferStream.prototype.readU32LE); + icon.colorBitmap = buffer.readDataChunk(BufferStream.prototype.readU32LE); + return icon; + } +} + +class AcsStateInfo { + stateName = ""; + animations : string[] = []; + + static read(buffer: BufferStream) { + let info = new AcsStateInfo(); + + info.stateName = buffer.readPascalString(); + info.animations = buffer.readCountedList(() => { + return buffer.readPascalString(); + }, BufferStream.prototype.readU16LE); + + return info; + } +} + +class AcsLocalizedInfo { + langId = 0; + charName = ""; + charDescription = ""; + charExtraData = ""; + + static read(buffer: BufferStream) { + let info = new AcsLocalizedInfo(); + + info.langId = buffer.readU16LE(); + info.charName = buffer.readPascalString(); + info.charDescription = buffer.readPascalString(); + info.charExtraData = buffer.readPascalString(); + + return info; + } +} + +class AcsCharacterInfo { + minorVersion = 0; + majorVersion = 0; + + localizationInfoListLocation = new LOCATION(); + + guid = new GUID(); + + charWidth = 0; + charHeight = 0; + + // Color index in the palette for the transparent color + transparencyColorIndex = 0; + + flags = 0; + + animSetMajorVer = 0; + animSetMinorVer = 0; + + voiceInfo : AcsVoiceInfo | null = null; + balloonInfo : AcsBalloonInfo | null = null; + + // The color palette. + palette: RGBAColor[] = []; + + trayIcon: AcsTrayIcon | null = null; + + stateInfo: AcsStateInfo[] = []; + + localizedInfo: AcsLocalizedInfo[] = []; + + static read(buffer: BufferStream) { + let info = new AcsCharacterInfo(); + + info.minorVersion = buffer.readU16LE(); + info.majorVersion = buffer.readU16LE(); + + info.localizationInfoListLocation = LOCATION.read(buffer); + info.guid = GUID.read(buffer); + + info.charWidth = buffer.readU16LE(); + info.charHeight = buffer.readU16LE(); + + info.transparencyColorIndex = buffer.readU8(); + + info.flags = buffer.readU32LE(); + + info.animSetMajorVer = buffer.readU16LE(); + info.animSetMinorVer = buffer.readU16LE(); + + if((info.flags & AcsCharacterInfoFlags.VoiceOutput)) { + info.voiceInfo = AcsVoiceInfo.read(buffer); + } + + if( + (info.flags & AcsCharacterInfoFlags.WordBalloonEnabled) && + !(info.flags & AcsCharacterInfoFlags.WordBalloonDisabled) + ) { + info.balloonInfo = AcsBalloonInfo.read(buffer); + } + + info.palette = buffer.readCountedList(() => { + return RGBAColor.read(buffer); + }); + + // Tray icon + if(buffer.readBool() == true) { + info.trayIcon = AcsTrayIcon.read(buffer); + } + + // this makes me wish type had sensible generics + // so this could be encoded in a type, like c++ or rust lol + info.stateInfo = buffer.readCountedList(() => { + return AcsStateInfo.read(buffer); + }, BufferStream.prototype.readU16LE); + + if(info.localizationInfoListLocation.offset != 0) { + let lastOffset = buffer.tell(); + + buffer.seek(info.localizationInfoListLocation.offset, SeekDir.BEG); + + info.localizedInfo = buffer.readCountedList(() => { + return AcsLocalizedInfo.read(buffer); + }, BufferStream.prototype.readU16LE) + + buffer.seek(lastOffset, SeekDir.BEG); + } + + return info; + } + + +} + + +function agentCharacterParseACS(buffer: BufferStream) { + let magic = buffer.readU32LE(); + + if(magic != 0xabcdabc3) { + throw new Error("This is not an ACS file."); + } + + // Read the rest of the header. + let characterInfoLocation = LOCATION.read(buffer); + let animationInfoLocation = LOCATION.read(buffer); + let imageInfoLocation = LOCATION.read(buffer); + let audioInfoLocation = LOCATION.read(buffer); + + console.log(characterInfoLocation.offset.toString(16)); + + // Read the character info in. + buffer.seek(characterInfoLocation.offset, SeekDir.BEG); + let characterInfo = AcsCharacterInfo.read(buffer); + + console.log(characterInfo) +} + +// For the testbed code only, remove when that gets axed +// (or don't, I'm not your dad) +export function agentParseCharacterTestbed(buffer: Uint8Array) { + return agentCharacterParseACS(new BufferStream(buffer)); +} + +// TODO this will be the public API +// Dunno about maintaining canvases. We can pass a div into agentInit and add a characterInit() which recieves it +// (which we then mount characters and their wordballoons into?) +export function agentCreateCharacter(data: Uint8Array) : Promise { + throw new Error("Not implemented yet"); +} diff --git a/msagent.js/src/index.ts b/msagent.js/src/index.ts index 2d0e502..c9209e0 100644 --- a/msagent.js/src/index.ts +++ b/msagent.js/src/index.ts @@ -1,6 +1,7 @@ import { wordballoonInit } from "./wordballoon.js"; export * from "./types.js"; +export * from "./character.js"; export * from "./sprite.js"; export * from "./wordballoon.js"; diff --git a/webapp/package.json b/webapp/package.json index 8d3850d..73b3bba 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,8 +1,8 @@ { "name": "@msagent-chat/webapp", "scripts": { - "build": "parcel build --no-source-maps --dist-dir dist --public-url '.' src/html/index.html", - "serve": "parcel src/html/index.html", + "build": "parcel build --no-source-maps --dist-dir dist --public-url '.' src/html/index.html src/html/testbed.html", + "serve": "parcel src/html/index.html src/html/testbed.html", "clean": "run-script-os", "clean:darwin:linux": "rm -rf dist .parcel-cache", "clean:win32": "rd /s /q dist .parcel-cache" diff --git a/webapp/src/html/testbed.html b/webapp/src/html/testbed.html new file mode 100644 index 0000000..3f6c126 --- /dev/null +++ b/webapp/src/html/testbed.html @@ -0,0 +1,21 @@ + + + + MSAgent Chat - testbed + + + + + + + + + + + +
+ + +
+ + diff --git a/webapp/src/ts/testbed.ts b/webapp/src/ts/testbed.ts new file mode 100644 index 0000000..3cf5984 --- /dev/null +++ b/webapp/src/ts/testbed.ts @@ -0,0 +1,21 @@ +// Testbed code +// This will go away when it isn't needed + +import * as msagent from "@msagent-chat/msagent.js"; + +let input = document.getElementById("testbed-input") as HTMLInputElement; + +input.addEventListener("change", async () => { + + + let buffer = await input.files![0].arrayBuffer(); + + console.log("About to parse character"); + msagent.parseCharacterTestbed(new Uint8Array(buffer)); + console.log("parsed character"); +}) + +document.addEventListener("DOMContentLoaded", async () => { + await msagent.agentInit(); + console.log("msagent initalized!"); +})