feat(artifacts): read Godot 3 PCK version 1 archives (#7781)

This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-10-28 19:43:43 +08:00
1 parent c77735fa83
commit 09225b08e1
6 files changed
+199 -33

No files matched your search

+12 -3
View File
@@ -1,9 +1,18 @@
# Godot PCK archives
`inspect-artifact` and `extract-artifact` read ordinary Godot PCK formats 2, 3
`inspect-artifact` and `extract-artifact` read ordinary Godot PCK formats 1, 2, 3
and 4 without running the game or requiring Godot. Official Godot 4.4.1, 4.6.1
and 4.7.1 produced these respective formats in verification; other engine
releases have not been verified. Format 1 is unsupported.
and 4.7.1 produced formats 2, 3 and 4 respectively in verification; other engine
releases have not been verified. Format 1 compatibility follows the
[Godot 3.5 loader](https://github.com/godotengine/godot/blob/3.5/core/io/file_access_pack.cpp#L201-L242)
and [exporter](https://github.com/godotengine/godot/blob/3.5/editor/editor_export.cpp#L1094-L1164)
layout and is covered by synthetic standalone and embedded fixtures; an actual
Godot 3 export has not been exercised.
Format 1 uses an 84-byte header, no pack or member flags, and absolute member
offsets in the complete source file, including an embedded executable carrier.
Its reported flags and file base are zero because those fields are absent.
Zero-offset removal records remain unsupported; REA does not apply patches.
Inspection retains the original header and directory facts, including engine
version, flags, declared file count, source paths, offsets and declared MD5.
+92 -2
View File
@@ -1,5 +1,6 @@
import {
buildGodotPck,
buildGodotPck1,
buildAliasedGodotPck,
embedGodotPck,
pckU32,
@@ -52,6 +53,95 @@ const members: readonly GodotPckMember[] = [
{ path: "data/é.json", data: '{"session":"Session1"}' },
];
describe("GodotPckArtifactReader format 1", () => {
it.each([false, true])(
"reads Godot 3 format 1 directory and absolute member offsets (embedded=%s)",
async (embedded) => {
const carrier = embedded
? Buffer.from("old Godot engine")
: Buffer.alloc(0);
const pack = buildGodotPck1(members, { carrierBytes: carrier.length });
const bytes = embedded
? Buffer.concat([
carrier,
pack,
pckU64(pack.length),
Buffer.from("GDPC"),
])
: pack;
await withReader(bytes, async (reader) => {
const entries = await list(reader);
expect(entries.map(({ sourcePath }) => sourcePath)).toEqual(
members.map(({ path }) => path),
);
expect(entries.map(({ path }) => path)).toEqual([
"scenes/main.gd",
"project.binary",
"data/é.json",
]);
expect(reader.formatMetadata()).toMatchObject({
format_version: 1,
engine_major: 3,
engine_minor: 5,
embedded,
pack_start: carrier.length,
directory_start: carrier.length + 84,
file_base: 0,
pack_flags: 0,
declared_file_count: members.length,
});
for (const [index, entry] of entries.entries()) {
expect(entry.encrypted).toBe(false);
expect(entry.formatMetadata).toMatchObject({ flags: 0 });
expect(await text(await reader.open(entry))).toBe(
members[index]?.data,
);
}
});
},
);
it("keeps a valid empty Godot 3 format 1 inventory complete", async () => {
await withReader(buildGodotPck1([]), async (reader) => {
expect(await list(reader)).toEqual([]);
expect(reader.formatMetadata()).toMatchObject({
format_version: 1,
directory_status: "available",
declared_file_count: 0,
});
});
});
it.each([
[
"zero-offset removal",
{ path: "gone.gd", data: "", offset: 0n },
"unavailable",
],
[
"out-of-pack data",
{ path: "bad.bin", data: "x", offset: 1n << 40n },
"format",
],
[
"oversized data",
{ path: "bad.bin", data: "x", size: 1n << 40n },
"format",
],
] as const)("refuses Godot 3 format 1 %s", async (_, member, reason) => {
await withReader(buildGodotPck1([member]), async (reader) => {
await expect(list(reader)).rejects.toMatchObject({ reason });
});
});
it("rejects a truncated Godot 3 format 1 directory", async () => {
const pack = buildGodotPck1([{ path: "a.gd", data: "x" }]);
await withReader(pack.subarray(0, 110), async (reader) => {
await expect(list(reader)).rejects.toMatchObject({ reason: "format" });
});
});
});
describe("GodotPckArtifactReader", () => {
it.each([
["format 4 standalone", buildGodotPck(4, members), false],
@@ -186,8 +276,8 @@ describe("GodotPckArtifactReader", () => {
"format",
],
[
"a Godot 3 format 1 pack",
Buffer.concat([Buffer.from("GDPC"), pckU32(1), Buffer.alloc(120)]),
"an unknown format 5 pack",
Buffer.concat([Buffer.from("GDPC"), pckU32(5), Buffer.alloc(120)]),
"unavailable",
],
["bytes without a pack", Buffer.from("not a godot pack at all"), "format"],
+21 -13
View File
@@ -29,7 +29,6 @@ const PACK_DIR_ENCRYPTED = 1;
const PACK_REL_FILEBASE = 2;
const PACK_SPARSE_BUNDLE = 4;
const PACK_FILE_ENCRYPTED = 1;
const MINIMUM_ENTRY_BYTES = 40;
const DIRECTORY_CHUNK_BYTES = 64 * 1024;
/** Encoded retained entry metadata, including escaped paths and source facts. */
const MAX_DIRECTORY_METADATA_BYTES = 64 * 1024 * 1024;
@@ -222,13 +221,19 @@ export class GodotPckArtifactReader implements ArtifactReader {
const offset = await cursor.uint64();
const size = await cursor.uint64();
const md5 = (await cursor.bytes(16)).toString("hex");
const flags = await cursor.uint32();
// Godot 3's format 1 has neither per-file flags nor a file-base field.
const flags = header.format_version === 1 ? 0 : await cursor.uint32();
if ((flags & ~PACK_FILE_ENCRYPTED) !== 0)
throw new ArtifactReaderFailure(
"unavailable",
`Godot patch or unknown member flags are unsupported: 0x${flags.toString(16)}`,
);
const sourcePath = pckMemberPath(rawPath);
if (header.format_version === 1 && offset === 0n)
throw new ArtifactReaderFailure(
"unavailable",
`Godot PCK patch removal entry is unsupported: ${sourcePath}`,
);
const start = toSafeOffset(
offset,
header.file_base,
@@ -329,12 +334,12 @@ const readGodotPckHeader = async (
const fixed = await readArtifactFileBytes(handle, start, 40, end, signal);
const header = readGodotPckHeaderFacts(fixed);
const version = header.formatVersion;
if (![2, 3, 4].includes(version))
if (![1, 2, 3, 4].includes(version))
throw new ArtifactReaderFailure(
"unavailable",
`Godot PCK format ${String(version)} is unsupported; plain formats 2, 3 and 4 are supported`,
`Godot PCK format ${String(version)} is unsupported; plain formats 1, 2, 3 and 4 are supported`,
);
const flags = fixed.readUInt32LE(20);
const flags = version === 1 ? 0 : fixed.readUInt32LE(20);
if (
(flags & ~(PACK_DIR_ENCRYPTED | PACK_REL_FILEBASE | PACK_SPARSE_BUNDLE)) !==
0
@@ -343,16 +348,19 @@ const readGodotPckHeader = async (
"unavailable",
`Godot PCK declares unknown pack flags 0x${flags.toString(16)}`,
);
const fileBase = toSafeOffset(
fixed.readBigUInt64LE(24),
version >= 3 || (flags & PACK_REL_FILEBASE) !== 0 ? start : 0,
end,
"file base",
);
const fileBase =
version === 1
? 0
: toSafeOffset(
fixed.readBigUInt64LE(24),
version >= 3 || (flags & PACK_REL_FILEBASE) !== 0 ? start : 0,
end,
"file base",
);
const directory =
version >= 3
? toSafeOffset(fixed.readBigUInt64LE(32), start, end, "directory")
: start + 96;
: start + (version === 1 ? 84 : 96);
const count = (
await readArtifactFileBytes(handle, directory, 4, end, signal)
).readUInt32LE(0);
@@ -364,7 +372,7 @@ const readGodotPckHeader = async (
: "available";
if (
status === "available" &&
count * MINIMUM_ENTRY_BYTES > end - directory - 4
count * (version === 1 ? 36 : 40) > end - directory - 4
)
throw formatFailure("declared member count cannot fit in the directory");
return {
+15 -8
View File
@@ -7,6 +7,7 @@ import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import {
buildAliasedGodotPck,
buildGodotPck,
buildGodotPck1,
pckU64,
} from "../fixtures/godotPck.js";
import { elf } from "../../src/domain/binaryTarget.fixture.js";
@@ -33,12 +34,15 @@ const graphFrom = (reply: unknown) => {
);
};
cliTest.for([2, 3, 4] as const)(
cliTest.for([1, 2, 3, 4] as const)(
"inspects and safely extracts format %s PCK through the compiled CLI",
async (version, { cli }) => {
const root = await createTestTempDirectory("rea-pck-cli-");
const source = join(root, "game.pck");
await writeFile(source, buildGodotPck(version, members));
await writeFile(
source,
version === 1 ? buildGodotPck1(members) : buildGodotPck(version, members),
);
const inspected = await cli.run({
arguments: ["inspect-artifact", source, "--json"],
});
@@ -86,13 +90,16 @@ cliTest.for([2, 3, 4] as const)(
},
);
cliTest(
"reads an embedded pack while preserving the native carrier's content classification",
async ({ cli }) => {
cliTest.for([1, 4] as const)(
"reads an embedded format %s pack while preserving the native carrier's content classification",
async (version, { cli }) => {
const root = await createTestTempDirectory("rea-pck-embedded-cli-");
const source = join(root, "game.x86_64");
const pack = buildGodotPck(4, members);
const carrier = elf(2, 1, 62);
const pack =
version === 1
? buildGodotPck1(members, { carrierBytes: carrier.length })
: buildGodotPck(4, members);
const bytes = Buffer.concat([
carrier,
pack,
@@ -165,7 +172,7 @@ cliTest(
const root = await createTestTempDirectory("rea-pck-confirmed-malformed-");
const source = join(root, "unsupported.x86_64");
const pack = buildGodotPck(4, members);
pack.writeUInt32LE(1, 4);
pack.writeUInt32LE(5, 4);
await writeFile(
source,
Buffer.concat([
@@ -184,7 +191,7 @@ cliTest(
code: "artifact_operation_failed",
details: {
reason: "unavailable",
detail: expect.stringContaining("Godot PCK format 1 is unsupported"),
detail: expect.stringContaining("Godot PCK format 5 is unsupported"),
},
});
},
+13 -7
View File
@@ -7,7 +7,7 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { buildGodotPck } from "../../fixtures/godotPck.js";
import { buildGodotPck, buildGodotPck1 } from "../../fixtures/godotPck.js";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
@@ -90,15 +90,21 @@ const assertInvalidNodeField = async (
};
const profiles = ["full", "compact"] as const;
it.each(profiles)("reuses retained PCK inventory (%s)", async (profile) => {
const inventoryCases = profiles.flatMap((profile) => [
{ profile, version: 1 as const },
{ profile, version: 4 as const },
]);
it.each(inventoryCases)("retains PCK ($profile/v$version)", async (input) => {
const { profile, version } = input;
const root = await createTestTempDirectory("rea-pck-mcp-");
const path = join(root, "game.pck");
const members = [
{ path: "res://a.txt", data: "a" },
{ path: "res://b.txt", data: "b" },
];
await writeFile(
path,
buildGodotPck(4, [
{ path: "res://a.txt", data: "a" },
{ path: "res://b.txt", data: "b" },
]),
version === 1 ? buildGodotPck1(members) : buildGodotPck(4, members),
);
const session = createTestBinarySession(new ArtifactProvider({}));
const server = createServer(
@@ -129,7 +135,7 @@ it.each(profiles)("reuses retained PCK inventory (%s)", async (profile) => {
);
expect(summary.summary.root_format_metadata).toMatchObject({
kind: "godot-pck",
format_version: 4,
format_version: version,
declared_file_count: 2,
});
const parent = session.evidenceForAnalysis(summary.parent_evidence_id);
+46
View File
@@ -22,6 +22,52 @@ export const pckU64 = (value: bigint | number): Buffer => {
const pad4 = (bytes: Buffer): Buffer =>
Buffer.concat([bytes, Buffer.alloc((4 - (bytes.length % 4)) % 4)]);
/** Godot 3.5 exporter layout: 84-byte header, no flags, absolute member offsets. */
export const buildGodotPck1 = (
members: readonly GodotPckMember[],
options: { carrierBytes?: number; fileCount?: number } = {},
): Buffer => {
const header = Buffer.alloc(84);
header.write("GDPC");
header.writeUInt32LE(1, 4);
header.writeUInt32LE(3, 8);
header.writeUInt32LE(5, 12);
const paths = members.map((member) => pad4(Buffer.from(member.path)));
const directoryEnd =
header.length +
4 +
paths.reduce((bytes, path) => bytes + 36 + path.length, 0);
const directoryFileEnd = (options.carrierBytes ?? 0) + directoryEnd;
const padding = (16 - (directoryFileEnd % 16)) % 16;
let dataOffset = directoryFileEnd + padding;
const data: Buffer[] = [];
const directory = members.flatMap((member, index) => {
const bytes = Buffer.from(member.data);
const path = paths[index]!;
const row = [
pckU32(path.length),
path,
pckU64(member.offset ?? BigInt(dataOffset)),
pckU64(member.size ?? BigInt(bytes.length)),
member.md5 ?? createHash("md5").update(bytes).digest(),
];
const stored = Buffer.concat([
bytes,
Buffer.alloc((16 - (bytes.length % 16)) % 16),
]);
data.push(stored);
dataOffset += stored.length;
return row;
});
return Buffer.concat([
header,
pckU32(options.fileCount ?? members.length),
...directory,
Buffer.alloc(padding),
...data,
]);
};
/**
* Build a pack with the layout Godot's exporter writes (and GDRE Tools
* reproduces): format 2 places the directory after a 96-byte header and