From 9a02683d33af03ee700d4511bba87815538c839e Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Mon, 9 Dec 2024 16:36:02 -0500 Subject: [PATCH] GvasTextAsNumber (#240) --- ts/Gvas.ts | 50 +++++++++-- ts/exporter.ts | 91 +++++++++++++------ ts/parser.ts | 235 ++++++++++++++++++++++++++++--------------------- ts/util.ts | 58 ++++++++---- 4 files changed, 282 insertions(+), 152 deletions(-) diff --git a/ts/Gvas.ts b/ts/Gvas.ts index fce9414..a9bf349 100644 --- a/ts/Gvas.ts +++ b/ts/Gvas.ts @@ -79,7 +79,12 @@ export interface CustomData { value: number; } -export type GvasText = GvasTextNone | GvasTextArgumentFormat | GvasTextBase; +export type GvasText = + | GvasTextNone + | GvasTextBase + | GvasTextArgumentFormat + | GvasTextAsNumber + ; // Component type 255 export interface GvasTextNone { @@ -98,13 +103,44 @@ export interface GvasTextBase { // Component type 3 export interface GvasTextArgumentFormat { flags: number; - guid: GvasString; - pattern: GvasString; - args: FormatArgumentValue[]; + sourceFormat: GvasText; + args: FormatArgumentValueMap[]; +} + +// Component type 4 +export interface GvasTextAsNumber { + flags: number; + sourceValue: FormatArgumentValue; + formatOptions?: NumberFormattingOptions | undefined; + targetCulture: GvasString; } -export interface FormatArgumentValue { +export interface FormatArgumentValueMap { name: GvasString; - contentType: number; - values: GvasString[]; + value: FormatArgumentValue; +} + +export type FormatArgumentValue = + | ['Int', number] + | ['Text', GvasText] + ; + +export interface NumberFormattingOptions { + alwaysIncludeSign: boolean; + useGrouping: boolean; + roundingMode: RoundingMode; + minimumIntegralDigits: number; + maximumIntegralDigits: number; + minimumFractionalDigits: number; + maximumFractionalDigits: number; +} + +export enum RoundingMode { + HalfToEven = 0, + HalfFromZero = 1, + HalfToZero = 2, + FromZero = 3, + ToZero = 4, + ToNegativeInfinity = 5, + ToPositiveInfinity = 6, } diff --git a/ts/exporter.ts b/ts/exporter.ts index 7587eb3..d83ee83 100644 --- a/ts/exporter.ts +++ b/ts/exporter.ts @@ -8,6 +8,7 @@ import { GvasString, GvasText, GvasTypes, + NumberFormattingOptions, } from './Gvas'; import {Permission} from './Permission'; import {Quaternion} from './Quaternion'; @@ -1159,10 +1160,11 @@ function stringToBlob(str: GvasString): BlobPart { function textToBlob(text: GvasText, largeWorldCoords: boolean): BlobPart { if ('values' in text) { + if (text.values.length > 1) throw new Error('Only zero or one values are allowed'); // (u32) Flags // (u8) Component Type (None = 255) - // (u32) Component Count - // (str*) Component Array + // (b32) Has Culture Invariant String + // (str?) Culture Invariant String return new Blob([ new Uint32Array([text.flags]), new Uint8Array([255]), @@ -1180,46 +1182,79 @@ function textToBlob(text: GvasText, largeWorldCoords: boolean): BlobPart { new Uint8Array([0]), new Blob([text.namespace, text.key, text.value].map(stringToBlob)), ]); - } else if ('pattern' in text) { + } else if ('args' in text) { // (u32) Flags - // (u8) Component Type (Argument Format = 3) - // (u8) Unknown (8) - // (u32) Unknown (0) - // (str) Unknown - // (str) GUID - // (str) Pattern + // (u8) Component Type (ArgumentFormat = 3) + // (txt) Source Format // (u32) TextFormat Count // (...) TextFormat Array return new Blob([ new Uint32Array([text.flags]), new Uint8Array([3]), - new Uint8Array([8, 0, 0, 0, 0]), - stringToBlob(largeWorldCoords ? '' : null), - stringToBlob(text.guid), - stringToBlob(text.pattern), + textToBlob(text.sourceFormat, largeWorldCoords), new Uint32Array([text.args.length]), - new Blob(text.args.map(rtfToBlob)), + new Blob(text.args.map((favm) => new Blob([ + stringToBlob(favm.name), + formatArgumentValueToBlob(favm.value), + ]))), + ]); + } else if ('targetCulture' in text) { + // (u32) Flags + // (u8) Component Type (AsNumber = 4) + // (FAV) Source Value + // (b32) Has Number Formatting Options + // (NFO?) Number Formatting Options + // (str) Target Culture + const formatOptions = [text.formatOptions] + .filter(Boolean) + .map(numberFormattingOptionsToBlob); + return new Blob([ + new Uint32Array([text.flags]), + new Uint8Array([4]), + formatArgumentValueToBlob(text.sourceValue), + new Uint32Array([formatOptions.length]), + ...formatOptions, + stringToBlob(text.targetCulture), ]); } else { throw new Error('Unexpected text type'); } } -function rtfToBlob(rtf: FormatArgumentValue): BlobPart { - // TextFormat: - // (str) Format Key - // (u8) Unknown (4) - // (u32) Content Type - // (u8) Unknown (255) - // (u32) Values Count - // (str*) Values Array +function formatArgumentValueToBlob(value: FormatArgumentValue): BlobPart { + const largeWorldCoords = true; // FIXME + if (value[0] === 'Int') { + // Int (0) + return new Blob([ + new Uint8Array([0]), + new Int32Array([value[1], 0]), + ]); + } else if (value[0] === 'Text') { + // Text (4) + return new Blob([ + new Uint8Array([4]), + textToBlob(value[1], largeWorldCoords), + ]); + } else { + throw new Error(`Unexpected FormatArgumentValue ${value}`); + } +} + +function numberFormattingOptionsToBlob(value: NumberFormattingOptions): BlobPart { return new Blob([ - stringToBlob(rtf.name), - new Uint8Array([4]), - new Uint32Array([rtf.contentType]), - new Uint8Array([255]), - new Uint32Array([rtf.values.length]), - new Blob(rtf.values.map(stringToBlob)), + new Uint32Array([ + value.alwaysIncludeSign ? 1 : 0, + value.useGrouping ? 1 : 0, + ]), + new Uint8Array([ + value.roundingMode, + ]), + new Int32Array([ + value.minimumIntegralDigits, + value.maximumIntegralDigits, + value.minimumFractionalDigits, + value.maximumFractionalDigits, + ]), ]); } diff --git a/ts/parser.ts b/ts/parser.ts index 9204bdd..9b099e4 100644 --- a/ts/parser.ts +++ b/ts/parser.ts @@ -10,6 +10,10 @@ import { GvasTextNone, FormatArgumentValue, gvasToString, + GvasTextAsNumber, + FormatArgumentValueMap, + NumberFormattingOptions, + RoundingMode, } from './Gvas'; import {Permission} from './Permission'; import {Quaternion} from './Quaternion'; @@ -623,115 +627,148 @@ function parseStructArray( /** * Parse a struct array from a buffer. * @param {ArrayBuffer} buffer + * @param {number} pos * @return {GvasText[]} */ -function parseTextArray(buffer: ArrayBuffer): [number, GvasText[]] { +function parseTextArray(buffer: ArrayBuffer, pos = 0): [number, GvasText[]] { // (u32) Entry Count const entryCount = new Uint32Array(buffer, 0, 1)[0]; - let pos = 4; + pos += 4; const array: GvasText[] = []; for (let i = 0; i < entryCount; i++) { - // (u32) Flags - const flags = new Uint32Array(buffer.slice(pos, pos + 4))[0]; + let text; + [pos, text] = parseText(buffer, pos); + array.push(text); + } + return [pos, array]; +} + +function parseText(buffer: ArrayBuffer, pos = 0): [number, GvasText] { + // (u32) Flags + const flags = new Uint32Array(buffer.slice(pos, pos + 4))[0]; + pos += 4; + // (u8) FTextHistory type + const componentType = new Uint8Array(buffer, pos, 1)[0]; + pos++; + // FTextHistory + if (componentType === 255) { // None + const count = new Uint32Array(buffer.slice(pos, pos + 4))[0]; pos += 4; - // (u8) Component Type - const componentType = new Uint8Array(buffer, pos, 1)[0]; - pos++; - // Read the component - if (componentType === 255) { - const count = new Uint32Array(buffer.slice(pos, pos + 4))[0]; - pos += 4; - const values: GvasString[] = []; - for (let k = 0; k < count; k++) { - let value; - [pos, value] = parseString(buffer, pos); - values.push(value); - } - const value: GvasTextNone = {flags, values}; - array.push(value); - } else if (componentType === 0) { - let namespace; - let key; + const values: GvasString[] = []; + for (let k = 0; k < count; k++) { let value; - [pos, namespace] = parseString(buffer, pos); - [pos, key] = parseString(buffer, pos); [pos, value] = parseString(buffer, pos); - const values: GvasTextBase = {flags, key, namespace, value}; - array.push(values); - } else if (componentType === 3) { - // text_rich: - // seq: - // - id: flags - // contents: [8, 0, 0, 0, 0, 0, 0, 0, 0] - const argumentType = new Uint8Array(buffer, pos, 1)[0]; - if (argumentType !== 8) throw new Error(`Expected argumentType == 8, ${argumentType}`); - const unknown = new Uint32Array(buffer.slice(pos + 1, pos + 5))[0]; - if (unknown !== 0) throw new Error(`Expected unknown == 0, ${unknown}`); - pos += 5; - let unknownStr; - [pos, unknownStr] = parseString(buffer, pos); - if (unknownStr && unknownStr.length) throw new Error(`Expected empty str, ${unknownStr}`); - // - id: component_guid - // type: string - let guid; - [pos, guid] = parseString(buffer, pos); - // - id: text_format_pattern - // type: string - let pattern; - [pos, pattern] = parseString(buffer, pos); - // - id: text_format_arg_count - // type: u4 - const textFormatArgCount = new Uint32Array(buffer.slice(pos, pos + 4))[0]; - pos += 4; - // - id: text_format - // type: text_format - // repeat: expr - // repeat-expr: text_format_arg_count - const args: FormatArgumentValue[] = []; - for (let j = 0; j < textFormatArgCount; j++) { - // textFormat: - // seq: - // - id: format_key - // type: string - let name; - [pos, name] = parseString(buffer, pos); - // - id: separator - // contents: [4] - const separator = new Uint8Array(buffer, pos++, 1)[0]; - if (separator !== 4) { - throw new Error(`Expected separator == 4, ${separator}`); - } - // - id: flags - // type: u4 - const contentType = new Uint32Array(buffer.slice(pos, pos + 4))[0]; - pos += 4; - // - id: component_type - // contents: [255] - const type = new Uint8Array(buffer, pos++, 1)[0]; - if (type !== 255) { - throw new Error(`Expected type == 255, ${type}`); - } - // - id: count - // type: u4 - const count = new Uint32Array(buffer.slice(pos, pos + 4))[0]; - pos += 4; - const values = []; - for (let k = 0; k < count; k++) { - // - id: value - // type: string - // repeat: expr - // repeat-expr: count - let value; - [pos, value] = parseString(buffer, pos); - values.push(value); - } - args.push({contentType, name, values}); - } - const values: GvasTextArgumentFormat = {args, flags, guid, pattern}; - array.push(values); + values.push(value); + } + const value: GvasTextNone = {flags, values}; + return [pos, value]; + } else if (componentType === 0) { // Base + let namespace; + let key; + let value; + [pos, namespace] = parseString(buffer, pos); + [pos, key] = parseString(buffer, pos); + [pos, value] = parseString(buffer, pos); + const base: GvasTextBase = {flags, key, namespace, value}; + return [pos, base]; + } else if (componentType === 3) { // ArgumentFormat + let sourceFormat; + [pos, sourceFormat] = parseText(buffer, pos); + + // (u32) Argument Count + const textFormatArgCount = new Uint32Array(buffer.slice(pos, pos + 4))[0]; + pos += 4; + const args: FormatArgumentValueMap[] = []; + for (let j = 0; j < textFormatArgCount; j++) { + // (str) Name + let name; + [pos, name] = parseString(buffer, pos); + // (text) Value + let value; + [pos, value] = parseFormatArgumentValue(buffer, pos); + args.push({name, value}); + } + + const value: GvasTextArgumentFormat = {args, flags, sourceFormat}; + return [pos, value]; + } else if (componentType === 4) { // AsNumber + // (FAV) Source Value + let sourceValue: FormatArgumentValue; + [pos, sourceValue] = parseFormatArgumentValue(buffer, pos); + + // (b32) Has Number Formatting Options + let formatOptions: NumberFormattingOptions | undefined; + const hasFormatOptions = new Uint32Array(buffer.slice(pos, pos + 4))[0]; + pos += 4; + if (hasFormatOptions === 0) { + // Nothing to do + } else if (hasFormatOptions === 1) { + // (NFO) Number Formatting Options + [pos, formatOptions] = parseNumberFormattingOptions(buffer, pos); } else { - throw new Error(`Unexpected component type ${componentType} with flags ${flags}`); + throw new Error(`Unexpected hasFormatOptions ${hasFormatOptions}`); } + + // (str) Target Culture + let targetCulture: GvasString; + [pos, targetCulture] = parseString(buffer, pos); + + const value: GvasTextAsNumber = {flags, formatOptions, sourceValue, targetCulture}; + // array.push(value); + return [pos, value]; + } else { + throw new Error(`Unexpected component type ${componentType} with flags ${flags}`); } - return [pos, array]; +} + +function parseFormatArgumentValue(buffer: ArrayBuffer, pos: number): [number, FormatArgumentValue] { + const type = new Uint8Array(buffer, pos++, 1)[0]; + if (type === 0) { + const [value, extra] = new Int32Array(buffer.slice(pos, pos + 8)); + pos += 8; + if (extra !== 0) throw new Error('Overflow'); + return [pos, ['Int', value]]; + } else if (type === 4) { + let text; + [pos, text] = parseText(buffer, pos); + return [pos, ['Text', text]]; + } else { + throw new Error(`Unknown FormatArgumentValue type ${type}`); + } +} + +function parseNumberFormattingOptions(buffer: ArrayBuffer, pos: number): [number, NumberFormattingOptions] { + // (b32) Always Include Sign + // (b32) Use Grouping + // (e8) Rounding Mode + // (i32) Minimum Integral Digits + // (i32) Maximum Integral Digits + // (i32) Minimum Fractional Digits + // (i32) Maximum Fractional Digits + + const uint32s = new Uint32Array(buffer.slice(pos, pos + 8)); + const u8s = new Uint8Array(buffer, pos + 8, 1); + const int32s = new Int32Array(buffer.slice(pos + 9, pos + 25)); + + if (u8s[0] > RoundingMode.ToPositiveInfinity) { + throw new Error(`Invalid RoundingMode ${u8s[0]}`); + } + + const alwaysIncludeSign = Boolean(uint32s[0]); + const useGrouping = Boolean(uint32s[1]); + const roundingMode: RoundingMode = u8s[0]; + const minimumIntegralDigits = int32s[0]; + const maximumIntegralDigits = int32s[1]; + const minimumFractionalDigits = int32s[2]; + const maximumFractionalDigits = int32s[3]; + + return [pos + 25, { + alwaysIncludeSign, + maximumFractionalDigits, + maximumIntegralDigits, + minimumFractionalDigits, + minimumIntegralDigits, + roundingMode, + useGrouping, + }]; } diff --git a/ts/util.ts b/ts/util.ts index 9356208..82fa2dc 100644 --- a/ts/util.ts +++ b/ts/util.ts @@ -1,4 +1,4 @@ -import {GvasString, GvasText} from './Gvas'; +import {FormatArgumentValueMap, GvasString, GvasText, GvasTextArgumentFormat} from './Gvas'; import {Quaternion} from './Quaternion'; import {Rotator} from './Rotator'; import {Vector} from './Vector'; @@ -38,35 +38,57 @@ export function stringToText(str: GvasString): GvasText { if (lines.length === 1) return {flags: 0, values: [str]}; return { args: lines.map((line, i) => ({ - contentType: 2, name: String(i), - values: line ? [line] : [], - })), + value: ['Text', { + flags: 2, + values: line ? [line] : [], + }], + } satisfies FormatArgumentValueMap)), flags: 1, - guid: RRO_TEXT_GUID, - pattern: lines.map((_, i) => '{' + i + '}').join('
'), - }; + sourceFormat: { + flags: 8, + key: RRO_TEXT_GUID, + namespace: '', + value: lines.map((_, i) => '{' + i + '}').join('
'), + }, + } satisfies GvasTextArgumentFormat; } export function textToString(value: GvasText): GvasString { if (value === null) return null; - if ('pattern' in value) { - // ArgumentFormat - switch (value.guid) { + if ('key' in value) { + // Base (0) + if (value.namespace !== '') throw new Error(`Unexpected unknown value: ${value.namespace}`); + return value.value; + } else if ('args' in value) { + // ArgumentFormat (3) + const sourceFormat = value.sourceFormat; + if (!('key' in sourceFormat)) throw new Error('Unexpected sourceFormat'); + switch (sourceFormat.key) { case RRO_TEXT_GUID: case '1428110346E6AD292230C4AA503E3FE9': case '69981E2B47B2AABC01CE39842FB03A96': break; default: - throw new Error(`Unexpected GUID: ${value.guid}`); + throw new Error(`Unexpected GUID: ${sourceFormat.key}`); + } + const pattern = sourceFormat.value; + if (pattern === null) throw new Error('Null pattern'); + return pattern.replace(/{(\d+)}/g, + (_, i) => { + const v = value.args[Number(i)].value; + if (v[0] !== 'Text') throw new Error('Unexpected formatArgumentValue'); + return textToString(v[1]) ?? ''; + }); + } else if ('sourceValue' in value) { + // AsNumber (4) + if (value.sourceValue[0] === 'Int') { + return String(value.sourceValue[1]); + } else if (value.sourceValue[0] === 'Text') { + return textToString(value.sourceValue[1]); + } else { + throw new Error(`Unknown Source Value ${value}`); } - if (value.pattern === null) throw new Error('Null pattern'); - return value.pattern.replace(/{(\d+)}/g, - (_, i) => value.args[Number(i)].values[0] ?? ''); - } else if ('key' in value) { - // Base - if (value.namespace !== '') throw new Error(`Unexpected unknown value: ${value.namespace}`); - return value.value; } else { // None if (0 === value.values.length) return null;