feat(desktop): suggest managed members for Unity script assemblies (#5511)

* feat(desktop): suggest managed members for Unity predefined script assemblies

Unity Mono triage listed only inspect_managed_artifact probes, mostly for
engine and framework assemblies. Assembly-CSharp.dll and
Assembly-CSharp-firstpass.dll directly in a managed directory with a
UnityEngine reference witness now get an inspect_managed_members probe,
ordered before every managed artifact probe.

* docs(desktop): describe Unity Mono managed member probes

* refactor(desktop): read Unity managed directory from the witness fact

* refactor(desktop): reuse the managed directory index for predefined assemblies

* feat(desktop): point Unity member probes at summary delivery first

* test(desktop): no member probes without a Unity reference witness

* docs(desktop): trim Unity member probe note

* feat(desktop): suggest managed members in Unity IL2CPP backup folders

Unity IL2CPP builds write <Product>_BackUpThisFolder_ButDontShipItWithYourGame
next to the player. When shipped, its Managed/ holds pre-IL2CPP assemblies.
A path convention now records the folder on Assembly-CSharp*.dll directly in
its Managed/, including when the admitted root is the folder itself, and
triage offers inspect_managed_members for those files. Correspondence to
the shipped native code stays unknown.

* docs(desktop): note IL2CPP backup folder member probes

* docs(desktop): list IL2CPP metadata versions 24 and 27 for the metadata probe

* style(desktop): merge stacked doc comments on desktopPathConventions

* test(desktop): cover member probes in a macOS bundle managed directory

* docs(desktop): note that member probes need the game folder as root

* test(desktop): no member probe when the root is the Unity data folder

* test(desktop): check the data-folder root still gets managed probes

* test(desktop): check the IL2CPP backup convention against the probe bound

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
JANG BYUNGKUNandgensokyo-dev authored and GitHub committed 2026-11-04 19:53:00 +08:00
1 parent 398eb0d1f1
commit abc647bf6c
7 files changed
+307 -10

No files matched your search

+9
View File
@@ -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
`<Product>_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 `<Product>_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.
+19 -6
View File
@@ -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<string>();
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)
+6
View File
@@ -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",
@@ -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" },
];
+31 -1
View File
@@ -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,
+43 -3
View File
@@ -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 `<Product>_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" &&
@@ -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");