cosmonarchy-bw-prerelease/tools/tbl/test/dialect.test.ts
Kenny Udovic 36cc26b464 25 August 2026 -- tbl encoder decoder and image.tbl.txt loader
Ported from cosmonarchy-bw-release fe42f19. The .tbl.txt sources were
regenerated from this repo's own .tbl binaries rather than copied, so
images.tbl.txt and unitnames.tbl.txt carry the prerelease strings
(Askosi Sons of Ash, Faction Selection Askosi, Terran Fleshthief,
Forerunner Lumen) instead of the release ones.

tools/tbl is committed as real files here; in the release repo it is a
bare gitlink with no .gitmodules, so it comes out empty on clone.
2026-08-25 20:08:20 -04:00

134 lines
5.3 KiB
TypeScript

/**
* 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 <N> 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("<foo>")).toEqual(["<foo>"])
expect(one("<>")).toEqual(["<>"])
expect(one("a<b")).toEqual(["a<b"])
expect(one("<<")).toEqual(["<<"])
})
it("wraps values above 255, matching the C++ char() narrowing", () => {
// 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))
})
}
})