/** * The GPTP dialect: the text format CM-GPTP's `collapse_control_codes` * (`hooks/limits/tbl_extender.cpp`) accepts, which the engine already reads for * `rez/stat.txt` and `arr/sounds.txt`. * * These tests are the reference for that parser. If they and the C++ ever * disagree, one of the two is a bug. */ import { describe, expect, it } from "bun:test" import * as Tbl from "../src/Tbl.ts" import * as TblText from "../src/TblText.ts" import { failWith, show, succeed } from "./helpers.ts" const repo = `${import.meta.dir}/../../..` const bytes = (text: string) => Buffer.from(text, "latin1") const one = (line: string) => show([TblText.decodeEntryGptp(bytes(line))])[0]! describe("GPTP dialect - control codes", () => { it("collapses into the raw byte N", () => { expect(one("a<65>b")).toEqual(["aAb"]) expect(one("<4>Tank Mode")).toEqual(["\x04Tank Mode"]) }) it("treats <0> as a part separator", () => { expect(one("e<0>Tank Mode")).toEqual(["e", "Tank Mode"]) }) it("leaves a trailing <0> as an empty final part", () => { expect(one("Spider Mines<0>")).toEqual(["Spider Mines", ""]) }) it("leaves non-numeric angle brackets completely literal", () => { // There is no `<<` escape in this dialect, and `<>` is not an empty marker. expect(one("")).toEqual([""]) expect(one("<>")).toEqual(["<>"]) expect(one("a { // from_chars parses into u32, then `line[i] = char(control_char)` truncates, // so <256> collapses to a NUL and therefore splits the entry. expect(one("<256>")).toEqual(["", ""]) expect(one("<321>")).toEqual([String.fromCharCode(321 & 0xff)]) // 65 -> "A" }) it("handles the two backslash escapes and leaves other pairs alone", () => { expect(one("a\\nb")).toEqual(["a\nb"]) expect(one("a\\\\b")).toEqual(["a\\b"]) expect(one("zerg\\avenger.grp")).toEqual(["zerg\\avenger.grp"]) }) it("keeps raw bytes above 127", () => { expect(one("\xa4passive")).toEqual(["\xa4passive"]) }) }) describe("GPTP dialect - whole files", () => { it("has no comment or blank-line syntax: every line is an entry", () => { const table = TblText.decodeGptp(bytes("a\n\n# not a comment\nb\n")) expect(show(table)).toEqual([["a"], [""], ["# not a comment"], ["b"]]) }) it("does not turn a trailing newline into an extra entry", () => { expect(TblText.decodeGptp(bytes("a\nb\n")).length).toBe(2) expect(TblText.decodeGptp(bytes("a\nb")).length).toBe(2) }) it("tolerates CRLF", () => { expect(show(TblText.decodeGptp(bytes("a\r\nb\r\n")))).toEqual([["a"], ["b"]]) }) }) describe("GPTP dialect - encoding is unambiguous", () => { const roundTrips = (entry: Tbl.TblEntry) => show([TblText.decodeEntryGptp(bytes(TblText.encodeEntryGptp(entry)))])[0] it("escapes a literal < so it cannot be read back as a control code", () => { const entry = [Buffer.from("<10>literal")] expect(TblText.encodeEntryGptp(entry)).toBe("<60>10>literal") expect(roundTrips(entry)).toEqual(["<10>literal"]) }) it("escapes a backslash only when it would otherwise become an escape", () => { expect(TblText.encodeEntryGptp([Buffer.from("zerg\\avenger.grp")])).toBe("zerg\\avenger.grp") expect(TblText.encodeEntryGptp([Buffer.from("zerg\\night.grp")])).toBe("zerg\\\\night.grp") expect(roundTrips([Buffer.from("zerg\\night.grp")])).toEqual(["zerg\\night.grp"]) }) it("writes pure ASCII even for high bytes", () => { const text = TblText.encodeEntryGptp([Buffer.from("\xa4passive", "latin1")]) expect(text).toBe("<164>passive") expect([...text].every((c) => c.charCodeAt(0) <= 0x7e)).toBe(true) }) }) describe("GPTP dialect - the shipped text tables", () => { const load = async (path: string) => TblText.decodeGptp(new Uint8Array(await Bun.file(`${repo}/${path}`).arrayBuffer())) // stat.txt has grown past the table it was cloned from: the strings added // beyond index 1813 (weapon names, mostly) live only in the text file, which // is what the engine reads. stat_txt.tbl is the frozen legacy artefact. it("rez/stat.txt has outgrown the 1813 entries stat_txt.tbl indexes", async () => { const text = await load("mpq/rez/stat.txt") const binary = await succeed( Tbl.decode(new Uint8Array(await Bun.file(`${repo}/mpq/rez/stat_txt.tbl`).arrayBuffer())) ) expect(text.length).toBe(2999) expect(binary.length).toBe(1813) }) it("rez/stat.txt is far too large to be a u16 .tbl - the text loader is load-bearing", async () => { const table = await load("mpq/rez/stat.txt") const error = await failWith(Tbl.encode(table)) expect(error._tag).toBe("TblEncodeError") expect(error.message).toContain("past the 65535") }) for (const path of ["mpq/rez/stat.txt", "mpq/arr/sounds.txt"]) { it(`${path} survives gptp -> table -> gptp with an identical table`, async () => { const table = await load(path) const reparsed = TblText.decodeGptp(bytes(TblText.encodeGptp(table))) expect(show(reparsed)).toEqual(show(table)) }) it(`${path} converts losslessly to tbl-text v1 and back`, async () => { const table = await load(path) const back = await succeed(TblText.decode(TblText.encode(table))) expect(show(back)).toEqual(show(table)) }) } })