diff --git a/docs/desktop-applications.md b/docs/desktop-applications.md index 58c8926ee8..64f4bf9e2c 100644 --- a/docs/desktop-applications.md +++ b/docs/desktop-applications.md @@ -110,6 +110,15 @@ not establish full metadata validity or correspondence to a native binary; version 24 inspection still applies only to supported header layouts. See [IL2CPP metadata](il2cpp-metadata.md). +In a managed directory with a Unity Mono witness, `Assembly-CSharp.dll` and +`Assembly-CSharp-firstpass.dll` get an `inspect_managed_members` probe listed +first; project-named asmdef assemblies keep only the ordinary managed probes. +The same probe is offered for those names in `Managed/` of a +`_BackUpThisFolder_ButDontShipItWithYourGame` folder (or when that +folder is the root); whether its IL matches the shipped IL2CPP code is unknown. +Point the scan at the game folder: a root that is the `_Data` or +`Managed` folder itself has no Mono witness path and gets no member probe. + Candidates are derived or inferred from these facts and do not prove loading or execution. BepInEx, MelonLoader, Easy Anti-Cheat and BattlEye labels identify path conventions only; runtime presence stays unknown. No bypass is provided. diff --git a/src/artifacts/DesktopApplicationReader.ts b/src/artifacts/DesktopApplicationReader.ts index 5f05bc03e8..7bf2efc7f9 100644 --- a/src/artifacts/DesktopApplicationReader.ts +++ b/src/artifacts/DesktopApplicationReader.ts @@ -1,5 +1,5 @@ import { desktopPathConventions } from "../domain/nativeConvention.js"; -import { isAbsolute } from "node:path"; +import { basename, isAbsolute } from "node:path"; import { DirectoryArtifactReader, DirectoryArtifactRootFailure, @@ -236,6 +236,7 @@ const scanDirectory = async ( const files: DesktopFile[] = []; const omissions: DesktopOmission[] = []; const unityWitnessRoots = new Set(); + const rootName = basename(root.canonical_path); const budget: DesktopReadBudget = { bytes: 0, maximum: input.max_probe_bytes, @@ -299,9 +300,12 @@ const scanDirectory = async ( entries++; abortIfNeeded(signal); const conventions = - entry.kind === "file" ? pathConventions(entry.path) : undefined; + entry.kind === "file" + ? pathConventions(entry.path, rootName) + : undefined; // Reserve the row and every repeated path before retaining them: each - // convention fact copies the path and its probes copy the local path. + // convention fact copies at most the path and its probes copy the + // local path. reserve( RETAINED_ROW_BYTES + retainedTextBytes(entry.path) + @@ -487,10 +491,19 @@ const fileRow = ( }; }; -/** Path-convention facts of one file; each repeats the path as its value. */ -const pathConventions = (path: string) => { - const conventions = desktopPathConventions(path); +/** Path-convention facts of one file; each value repeats at most the path. */ +const pathConventions = (path: string, rootName: string) => { + const conventions = desktopPathConventions(path, rootName); const facts: DesktopFact[] = []; + if ( + conventions.unityIl2cppBackupAssembly && + conventions.unityIl2cppBackupDirectory !== null + ) + facts.push({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: conventions.unityIl2cppBackupDirectory }, + }); if (conventions.electronArchive) facts.push({ kind: "electron-asar-convention", offset: null, value: path }); if (conventions.unrealContainer) diff --git a/src/domain/desktopApplication.ts b/src/domain/desktopApplication.ts index 77cf6c9a95..2d7effd10b 100644 --- a/src/domain/desktopApplication.ts +++ b/src/domain/desktopApplication.ts @@ -93,6 +93,11 @@ const desktopFactSchema = z.discriminatedUnion("kind", [ offset: z.null(), value: z.string(), }), + z.strictObject({ + kind: z.literal("unity-il2cpp-backup-convention"), + offset: z.null(), + value: z.strictObject({ backup_directory: z.string() }), + }), z.strictObject({ kind: z.literal("electron-asar-convention"), offset: z.null(), @@ -162,6 +167,7 @@ export const desktopProbeSchema = z.strictObject({ probe_id: prefixedDigestSchema("dsp"), operation: z.enum([ "inspect_managed_artifact", + "inspect_managed_members", "inspect_il2cpp_metadata", "open_binary", "analyze_javascript_application", diff --git a/src/domain/desktopApplicationProjection.test.ts b/src/domain/desktopApplicationProjection.test.ts index 0ca7ddd1cb..d3adc89173 100644 --- a/src/domain/desktopApplicationProjection.test.ts +++ b/src/domain/desktopApplicationProjection.test.ts @@ -62,6 +62,11 @@ const facts: DesktopFact[] = [ value: { container: "FORM", first_chunk: "GEN8" }, }, { kind: "il2cpp-library-convention", offset: null, value: "p" }, + { + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: "B" }, + }, { kind: "electron-asar-convention", offset: null, value: "p" }, { kind: "unreal-container-convention", offset: null, value: "p" }, ]; diff --git a/src/domain/desktopApplicationProjection.ts b/src/domain/desktopApplicationProjection.ts index b7e47c3a0c..2cd31d218c 100644 --- a/src/domain/desktopApplicationProjection.ts +++ b/src/domain/desktopApplicationProjection.ts @@ -1,5 +1,6 @@ import { IL2CPP_METADATA_VERSIONS } from "./il2cpp/il2cppMetadata.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; +import { desktopPathConventions } from "./nativeConvention.js"; import type { DesktopApplication, DesktopFile } from "./desktopApplication.js"; /** The most next probes this projection derives from any one fact. */ @@ -53,13 +54,42 @@ export const projectDesktopApplication = ( managed, "PE optional headers declare nonempty CLI directories; CLR execution and metadata validity remain unverified.", ); + const mono = managed.filter((file) => has(file, "unity-assembly-reference")); + const monoDirectories = new Set( + mono.flatMap((file) => + file.facts.flatMap((fact) => + fact.kind === "unity-assembly-reference" + ? [fact.value.managed_directory] + : [], + ), + ), + ); + for (const file of managed) { + const conventions = desktopPathConventions(file.path); + if ( + conventions.unityPredefinedAssembly && + conventions.unityManagedDirectory !== null && + monoDirectories.has(conventions.unityManagedDirectory) + ) + probe( + "inspect_managed_members", + file, + 'Inventory types, members and user strings of this Unity predefined script assembly; its references and contents are unverified. Start with detail "summary" and page rows through inspect_analysis_view.', + ); + } + for (const file of managed) + if (has(file, "unity-il2cpp-backup-convention")) + probe( + "inspect_managed_members", + file, + 'Inventory types, members and user strings of this predefined script assembly in a Unity IL2CPP backup folder; its correspondence to the shipped native code is unknown. Start with detail "summary" and page rows through inspect_analysis_view.', + ); for (const file of managed) probe( "inspect_managed_artifact", file, "Inspect the declared managed metadata and references.", ); - const mono = managed.filter((file) => has(file, "unity-assembly-reference")); candidate( "unity-mono", mono, diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts index 081e38d0a0..c36f9d4099 100644 --- a/src/domain/nativeConvention.ts +++ b/src/domain/nativeConvention.ts @@ -22,6 +22,11 @@ const DESKTOP_IL2CPP_LIBRARIES = new Set([ "gameassembly.dylib", "libil2cpp.so", ]); +/** Unity's predefined script assembly names; asmdef assembly names are project-chosen. */ +const UNITY_PREDEFINED_ASSEMBLIES = new Set([ + "assembly-csharp.dll", + "assembly-csharp-firstpass.dll", +]); /** * A runtime family named by the library filename or a containing framework. @@ -48,11 +53,19 @@ export const isSwiftRuntimeLibrary = (path: string): boolean => .split("/") .some((segment) => /^libswift.+\.dylib$/u.test(foldSegment(segment))); -/** Parse desktop layout conventions once while preserving original path segments. */ -export const desktopPathConventions = (path: string) => { +/** Unity names the pre-IL2CPP managed backup `_BackUpThisFolder_ButDontShipItWithYourGame`. */ +const UNITY_IL2CPP_BACKUP_SUFFIX = + "_backupthisfolder_butdontshipitwithyourgame"; + +/** + * Parse desktop layout conventions once while preserving original path segments. + * `rootName` is the admitted root directory name; `.` denotes that root in returned directories. + */ +export const desktopPathConventions = (path: string, rootName?: string) => { const segments = path.split("/"); const folded = segments.map(foldSegment); let unityManagedDirectory: string | null = null; + let unityManagedIndex = -1; for (let index = 0; index + 2 < folded.length; index++) { const segment = folded[index]; const namedDataDirectory = @@ -64,12 +77,39 @@ export const desktopPathConventions = (path: string) => { if ( (namedDataDirectory || bundleDataDirectory) && folded[index + 1] === "managed" - ) + ) { unityManagedDirectory = segments.slice(0, index + 2).join("/"); + unityManagedIndex = index + 1; + } + } + // The admitted root may itself be the backup folder, so its name precedes the relative segments. + const rooted = + rootName === undefined ? folded : [foldSegment(rootName), ...folded]; + const shift = rooted.length - folded.length; + let unityIl2cppBackupDirectory: string | null = null; + let unityIl2cppBackupIndex = -1; + for (let index = 0; index + 1 < rooted.length; index++) { + const segment = rooted[index] ?? ""; + if ( + segment.length > UNITY_IL2CPP_BACKUP_SUFFIX.length && + segment.endsWith(UNITY_IL2CPP_BACKUP_SUFFIX) + ) { + unityIl2cppBackupDirectory = + index < shift ? "." : segments.slice(0, index - shift + 1).join("/"); + unityIl2cppBackupIndex = index - shift; + } } const name = folded.at(-1) ?? ""; return { + unityIl2cppBackupDirectory, + unityIl2cppBackupAssembly: + unityIl2cppBackupIndex === segments.length - 3 && + folded[unityIl2cppBackupIndex + 1] === "managed" && + UNITY_PREDEFINED_ASSEMBLIES.has(name), unityManagedDirectory, + unityPredefinedAssembly: + unityManagedIndex === segments.length - 2 && + UNITY_PREDEFINED_ASSEMBLIES.has(name), il2cppLibrary: DESKTOP_IL2CPP_LIBRARIES.has(name), electronArchive: folded.at(-2) === "resources" && diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index c144d41503..054157520c 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -500,6 +500,200 @@ it.each(["Game_Data/Managed", "Contents/Resources/Data/Managed"])( }, ); +it("orders member probes for Unity predefined script assemblies before every managed artifact probe", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-members-"); + const unity = buildManagedPeFixture({ + references: ["UnityEngine.CoreModule"], + }); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, "Game_Data/Managed/Assembly-CSharp.dll", unity); + await put(root, "Game_Data/Managed/ASSEMBLY-CSHARP-FIRSTPASS.DLL", plain); + await put(root, "Game_Data/Managed/Game.Logic.dll", unity); + await put(root, "Game_Data/Managed/UnityEngine.CoreModule.dll", plain); + await put(root, "Game_Data/Managed/Nested/Assembly-CSharp.dll", plain); + await put(root, "Other_Data/Managed/Assembly-CSharp.dll", plain); + await put(root, "Plugins/Assembly-CSharp.dll", unity); + const { report } = await inspect(root); + const relative = new Map( + report.files.map((file) => [file.file_id, file.path]), + ); + const rows = report.next_probes.map((probe) => [ + probe.operation, + relative.get(probe.file_id), + ]); + expect( + rows.filter(([operation]) => operation === "inspect_managed_members"), + ).toEqual([ + [ + "inspect_managed_members", + "Game_Data/Managed/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + ], + ["inspect_managed_members", "Game_Data/Managed/Assembly-CSharp.dll"], + ]); + expect(rows.slice(0, 2).map(([operation]) => operation)).toEqual([ + "inspect_managed_members", + "inspect_managed_members", + ]); + expect( + rows.filter(([operation]) => operation === "inspect_managed_artifact"), + ).toHaveLength(7); + for (const probe of report.next_probes) + if (probe.operation === "inspect_managed_members") + expect(probe.rationale).toContain( + 'Start with detail "summary" and page rows through inspect_analysis_view.', + ); +}); + +it("offers member probes for predefined script assemblies in a macOS bundle's managed directory", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-bundle-"); + const managed = "Contents/Resources/Data/Managed"; + await put( + root, + `${managed}/Assembly-CSharp.dll`, + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + await put( + root, + `${managed}/Game.Logic.dll`, + buildManagedPeFixture({ references: ["System.Runtime"] }), + ); + const { report } = await inspect(root); + const relative = new Map( + report.files.map((file) => [file.file_id, file.path]), + ); + expect( + report.next_probes + .filter((probe) => probe.operation === "inspect_managed_members") + .map((probe) => relative.get(probe.file_id)), + ).toEqual([`${managed}/Assembly-CSharp.dll`]); + expect(report.next_probes[0]?.operation).toBe("inspect_managed_members"); +}); + +it.each(["Game_Data", "Game_Data/Managed"])( + "offers no member probe when the root is %s itself", + async (rootDirectory) => { + const root = await createTestTempDirectory("rea-desktop-mono-data-root-"); + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + const { report } = await inspect(join(root, rootDirectory)); + const operations = report.next_probes.map((probe) => probe.operation); + expect(operations).toContain("inspect_managed_artifact"); + expect(operations).not.toContain("inspect_managed_members"); + }, +); + +it("offers no member probes when no managed directory has a Unity reference witness", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-no-witness-"); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, "Game_Data/Managed/Assembly-CSharp.dll", plain); + await put(root, "Game_Data/Managed/Assembly-CSharp-firstpass.dll", plain); + await put(root, "Contents/Resources/Data/Managed/Assembly-CSharp.dll", plain); + const { report } = await inspect(root); + expect(report.candidates.map((row) => row.family)).toEqual(["dotnet"]); + expect(report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_artifact", + "inspect_managed_artifact", + "inspect_managed_artifact", + ]); +}); + +const BACKUP = "Game_BackUpThisFolder_ButDontShipItWithYourGame"; + +it("offers member probes for predefined script assemblies in a game directory's IL2CPP backup folder", async () => { + const root = await createTestTempDirectory("rea-desktop-il2cpp-backup-"); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, `${BACKUP}/Managed/Assembly-CSharp.dll`, plain); + await put( + root, + "OTHER_BACKUPTHISFOLDER_BUTDONTSHIPITWITHYOURGAME/MANAGED/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + plain, + ); + await put(root, `${BACKUP}/Managed/Game.Logic.dll`, plain); + await put(root, `${BACKUP}/Managed/Nested/Assembly-CSharp.dll`, plain); + await put(root, `${BACKUP}/Assembly-CSharp.dll`, plain); + await put( + root, + "_BackUpThisFolder_ButDontShipItWithYourGame/Managed/Assembly-CSharp.dll", + plain, + ); + await put(root, "Game_BackUpThisFolder/Managed/Assembly-CSharp.dll", plain); + const metadata = Buffer.alloc(8); + metadata.writeUInt32LE(0xfab11baf); + metadata.writeUInt32LE(31, 4); + await put( + root, + "Game_Data/il2cpp_data/Metadata/global-metadata.dat", + metadata, + ); + const { report } = await inspect(root); + const relative = new Map(report.files.map((file) => [file.file_id, file])); + const members = report.next_probes.filter( + (probe) => probe.operation === "inspect_managed_members", + ); + expect( + members.map((probe) => relative.get(probe.file_id)?.path).sort(), + ).toEqual( + [ + `${BACKUP}/Managed/Assembly-CSharp.dll`, + "OTHER_BACKUPTHISFOLDER_BUTDONTSHIPITWITHYOURGAME/MANAGED/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + ].sort(), + ); + for (const probe of members) { + expect(probe.rationale).toContain( + "correspondence to the shipped native code is unknown", + ); + expect(relative.get(probe.file_id)?.facts).toContainEqual({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { + backup_directory: relative.get(probe.file_id)!.path.split("/")[0], + }, + }); + } + const order = report.next_probes.map((probe) => probe.operation); + expect(order.lastIndexOf("inspect_managed_members")).toBeLessThan( + order.indexOf("inspect_il2cpp_metadata"), + ); + expect(report.candidates.map((row) => row.family)).not.toContain( + "unity-mono", + ); + expect( + report.files.filter((file) => + file.facts.some((fact) => fact.kind === "unity-il2cpp-backup-convention"), + ), + ).toHaveLength(2); +}); + +it("recognizes an admitted root that is itself the IL2CPP backup folder", async () => { + const parent = await createTestTempDirectory( + "rea-desktop-il2cpp-backup-root-", + ); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(parent, `${BACKUP}/Managed/Assembly-CSharp.dll`, plain); + await put(parent, "Ordinary/Managed/Assembly-CSharp.dll", plain); + const backup = await inspect(join(parent, BACKUP)); + expect(backup.report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_members", + "inspect_managed_artifact", + ]); + expect( + backup.report.files.find( + (file) => file.path === "Managed/Assembly-CSharp.dll", + )?.facts, + ).toContainEqual({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: "." }, + }); + const ordinary = await inspect(join(parent, "Ordinary")); + expect(ordinary.report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_artifact", + ]); +}); + it("retains complete observations through cancellation during directory finalization", async () => { const root = await createTestTempDirectory("rea-desktop-finalize-cancel-"); await put(root, "notes.txt", "target content");