mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 02:45:58 +00:00
feat(artifacts): read Godot 3 PCK version 1 archives (#7781)
This commit is contained in:
1 parent
c77735fa83
commit
09225b08e1
6 files changed
+199
-33
No files matched your search
+12
-3
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+46
@@ -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
|
||||
|
||||
Reference in new issue
Block a user