From 09225b08e1acdf0da9759956cffbfce341f6e082 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Wed, 28 Oct 2026 19:43:43 +0800 Subject: [PATCH] feat(artifacts): read Godot 3 PCK version 1 archives (#7781) --- docs/godot-pck.md | 15 +++- src/artifacts/GodotPckArtifactReader.test.ts | 94 +++++++++++++++++++- src/artifacts/GodotPckArtifactReader.ts | 34 ++++--- tests/acceptance/godotPck.test.ts | 23 +++-- tests/boundary/mcp/godotPckMcp.test.ts | 20 +++-- tests/fixtures/godotPck.ts | 46 ++++++++++ 6 files changed, 199 insertions(+), 33 deletions(-) diff --git a/docs/godot-pck.md b/docs/godot-pck.md index efd464cf09..19b6f1d938 100644 --- a/docs/godot-pck.md +++ b/docs/godot-pck.md @@ -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. diff --git a/src/artifacts/GodotPckArtifactReader.test.ts b/src/artifacts/GodotPckArtifactReader.test.ts index 92c56943e4..3e57c8acb3 100644 --- a/src/artifacts/GodotPckArtifactReader.test.ts +++ b/src/artifacts/GodotPckArtifactReader.test.ts @@ -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"], diff --git a/src/artifacts/GodotPckArtifactReader.ts b/src/artifacts/GodotPckArtifactReader.ts index e2bb1ca9d6..50d796c7b8 100644 --- a/src/artifacts/GodotPckArtifactReader.ts +++ b/src/artifacts/GodotPckArtifactReader.ts @@ -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 { diff --git a/tests/acceptance/godotPck.test.ts b/tests/acceptance/godotPck.test.ts index 1a1a53d4c0..24a58416fd 100644 --- a/tests/acceptance/godotPck.test.ts +++ b/tests/acceptance/godotPck.test.ts @@ -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"), }, }); }, diff --git a/tests/boundary/mcp/godotPckMcp.test.ts b/tests/boundary/mcp/godotPckMcp.test.ts index ec67806d81..2ba1ec5c67 100644 --- a/tests/boundary/mcp/godotPckMcp.test.ts +++ b/tests/boundary/mcp/godotPckMcp.test.ts @@ -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); diff --git a/tests/fixtures/godotPck.ts b/tests/fixtures/godotPck.ts index de6b60f14c..5f61ae2449 100644 --- a/tests/fixtures/godotPck.ts +++ b/tests/fixtures/godotPck.ts @@ -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