Knowledge base

Mod loading and resource precedence

Research in progress: some of this may be wrong. These notes record what XF Studio’s research has worked out so far about how Cyberpunk 2077 and its modding frameworks behave. Most of it comes from reading source code and game resources offline; not all of it has been confirmed in the running game, and claims graded hypothesis are unproven. If you spot a mistake or know more, please open an issue or suggest an edit.

Evidence grades

source
Read in engine/framework/tool source or decompiled data.
resource
Observed in extracted game or mod resources.
wiki
Documented in the Modding Docs, text or image.
runtime
Observed in the running game.
hypothesis
Not yet established.

Maturity: Draft. Consolidates MO2 2.5.2, ArchiveXL 1.27.3 (commit 5474e34d) and WolvenKit (commit 11720772) source reading with the XF Studio resolver's run against one reference installation (game 2.31 with Phantom Liberty, MO2 profile of about 1,000 mods, ArchiveXL 1.26.3 installed at the first run, updated in place to 1.27.3 on 25 September (toolchain)). Evidence grades follow the knowledge rules. REDengine's own archive lookup has not been read or measured: every precedence rule below is a tool-source rule or a hypothesis for the native engine, and the resolver says so on each decision.

This page answers: given an installation, which bytes does the game use for a depot path, and what do ArchiveXL declarations change about them? The character-customisation file chain builds on it.

1. The stages, in order

StageQuestionRuleGradeCode
1. Virtual filesWhich physical file is visible at a virtual path such as archive/pc/mod/x.archive?MO2: overwrite, then the first modlist.txt row, wins; disabled rows are not mounted. An MO2 file over a physical game-folder file is expected to win (MO2's VFS overlays the game folder). Direct launch: the game folder only. Vortex has no virtual layer: it deploys hard links into the game folder, so the direct route sees its winners (Vortex). A copy in the game folder and one in a separately configured manual mod root: the game folder's is taken. Every consumer (the mount plan, .xl, tweak and modlist.txt files, the source inventory, the framework check, the script-bundle search) uses this one order (PIPE-06).source MO2 profile.cpp, Vortex LinkingDeployment.ts; game-folder overlay and game over manual root hypothesisarchive-precedence.ts compareCopies/visibleCopy; mo2-instance.ts parseMo2Modlist, mo2ProviderFolders
2. Mount groupsWhich archives are mounted, in what order?archive/pc/mod/*.archive (mod group), then ArchiveXL's bundle directory (an extra Mod-scope group inserted before the first non-mod group), then archive/pc/ep1, then archive/pc/content.source WolvenKit ArchiveManager.Lookup, ArchiveXL ArchiveService::ResolveArchiveGroup; native order unreadbuildMountPlan
3. Mod group orderWhich of two mod archives wins a hash?A visible archive/pc/mod/modlist.txt lists archives first-to-last; otherwise the first alphabetical archive wins (PRC_f_… beats PRC_z_999_…).source MO2 basic_games Cyberpunk plugin and its maintainers' load-order guide; collation and unlisted-archive placement hypothesisDepotIndex.lookup
4. Hash lookupWhich archive supplies a depot hash?First mounted archive whose RDAR index contains the FNV-1a 64 of the sanitized path.source RED4ext SDK ResourcePath::HashSanitized, WolvenKit ArchiveReaderdepot-path.ts, rdar-index.ts
5. Depot additionsWhat if no archive has the path?ArchiveXL resource.copy then resource.link; both are rejected when the path already exists.source ArchiveXL ResourceLinkarchivexl-config.ts settleDepotAdditions, ResourceGraph.locate
6. Load-time changesWhat do .xl files change inside a resource?resource.fix (paths, mesh material names, context), resource.patch (per-type properties, section 4), customization merge (file chain §5).source ArchiveXL extensionsresource-graph.ts, cco-model.ts
7. What a launch renderedDid the game really use those bytes?Only a runtime capture (log naming the resource, resource-request probe, matched frame) can say.runtime needed—

2. Archive groups and naming

  • Location decides the group, not the name. basegame_*.archive inside archive/pc/mod (e.g. basegame_Kala Standalone Eyes V2.archive) is an ordinary mod archive.
  • Not mounted by this model (listed as unmounted, never silently ignored): archives in subfolders of archive/pc/mod, archive/pc/hot, REDmod mods/*/archives, and archives registered by other RED4ext plugins through ArchiveXL's API. Each is a later adapter.
  • Collation. "Alphabetical" is implemented as case-insensitive ordinal. When case-sensitive and case-insensitive order disagree for two colliding archives, the resolver records collation-sensitive-order.
  • Base-internal collisions. Many resources exist in both basegame_1_engine.archive and basegame_4_appearance.archive. Their relative order is unread; the resolver picks alphabetical-first and records base-internal-collision. For the reference character, all 70 such collisions were byte-identical when both copies were extracted, so the choice did not matter there resource.
  • Mod over base. For the same character, 57 resources were supplied by a mod archive over a base copy; 55 of those payloads differ from the base bytes resource (skin textures, head morph rig fix, teeth app, nails, arm meshes, the lash brown_liquorice.hp and brow hh_cap_grad__brown_ombre.xbm). This is the single most consequential unproven rule; each case carries mod-over-base-native-unread. One anecdotal observation supports it: in a playthrough with Alliekat's hair profiles installed, many NPC hairdos showed the yellow-to-brown look that only the mod's .hp files produce [runtime, uncontrolled] (yellow-hair investigation).

3. How ArchiveXL finds .xl files

source ExtensionLoader::Configure: first the bundle directory (red4ext/plugins/ArchiveXL/Bundle, recursively), then every Mod-scope archive group's base directory recursively (the game's archive/pc/mod, including X.archive.xl beside archives), in reverse depot-group order. Every .xl is YAML; anchors and aliases are used by the bundle (&AppearanceFixF … *AppearanceFixF). The resolver reads the visible files in that order (bundle first, then archive/pc/mod by case-insensitive virtual path; the directory-iteration order under MO2's VFS is hypothesis). A YAML !exclude tag is dropped by the reader and reported as an issue.

KeyEffectMerge across files
customizations: {female, male}Registers .inkcharcustomization resources, merged in declaration orderappended
resource.scopeNamed alias → members; aliases expand transitively to leaf paths (e.g. player_customization.app → player_wa_hair.app → each hairstyle .app)union
resource.fixpaths (CCO option .app remaps plus app overrides), names (mesh material renames), context (dynamic material attributes)later keys overwrite
resource.patchCopies properties from a patch resource into each (scope-expanded) target; see section 4list per target, sorted by order
resource.copysource: [targets]: new paths holding a copy of sourcerejected if the target exists
resource.linktarget: [aliases]: aliases resolve to target. The scalar form key: value is filed the other way round by ArchiveXL's config reader (the key becomes the alias)rejected if the alias exists or is a copy

ResourcePatch::Configure checks patch sources through the depot, which ArchiveXL's link hooks extend to copies and links; that is why Arkhe's brows can patch from an arkhe_copy\… path that no archive contains. A patch source may not itself be patched. Patches for one target are sorted by order with std::sort; the resolver keeps declaration order among equal values, which holds if ArchiveXL is built with MSVC, whose std::sort insertion-sorts ranges of up to 32 elements hypothesis.

4. What a patch changes

source ArchiveXL ResourcePatch/Extension.cpp. "Overwrite" means the target already has the property: an empty props list then does not replace it; only an explicit prop does.

Target typePropsBehaviour
.meshappearancesEach patch appearance is added, or replaces a same-named one when the existing appearance has no chunk materials or the patch appearance has some. A patch mesh with material entries becomes the material source for its appearances.
.meshrenderResourceBlobReplaces geometry (with bones, LOD info) — overwrite rule applies
.morphtargetbaseMesh, baseMeshAppearance, blob, boundingBox, targets, baseTexture(ParamName)targets merge by name; blob obeys the overwrite rule
.appappearances, partsValues, partsOverrides, components, visualTags, censorshipMappingNew appearances appended; existing ones gain parts (only if they exist) and overrides; an unnamed patch appearance applies to all
.ententity, componentsMerged into the compiled package (player entity patches, rule R6b) — not yet modelled

Mesh resource.fix names rename chunk materials and material entries before patches apply; context entries join the mesh's @context attributes.

5. ArchiveXL dynamic materials (mesh side)

source Mesh/Extension.cpp and Garment/Dynamic.cpp, replicated in character-resolver.ts:

  1. Expansion. An appearance with no chunk materials copies the chunk materials of an expansion source (a single appearance tag, else the @context attribute appearance_expansion_source, else the first appearance), replacing the part before @ with its own name: black_carbon@long → ash_brown@long.

  2. Lookup. A chunk material name is first looked up among the target mesh's ordinary entries, then the patch mesh's, then as a template: name@tpl uses the source mesh's @tpl entry (@material when there is no @).

  3. Instantiation. The template instance is cloned. Same-named @context parameters replace its values; resource paths and baseMaterial starting with * are expanded: {attr} reads the material attributes (material = the part before @, material.1… = +-separated parts) and then the context attributes (the @context CName parameters with names converted to snake_case, e.g. LongBaseMaterial → long_base_material, plus resource.fix context). A trailing ? makes a path optional. The inheritance chain is expanded the same way at each level.

  4. Patch source tag. source ResourcePatch/Extension.cpp tags every appearance a mesh appearances patch adds or replaces with the patch mesh's path hash; ProcessAppearance then appends that tag to the (expanded) chunk material list, and OnLoadMaterials takes the list's last name to find the source mesh. The tag's material entry is an empty CMaterialInstance (s_dummyMaterial).

  5. Short chunk material lists. A mesh may list fewer chunk materials than it has render chunks. CCXL hair does this on purpose: the High Ponytail's ponytail mesh has 10 render chunks (two real 44,893-vertex strand chunks on LOD 0, then three-vertex stubs on LOD 0 and every lower level) and its appearance lists two materials [resource: fhair_highpony_pony.mesh]. After a patch, the first unlisted chunk lines up with the patch source tag (so it gets the empty placeholder material); the chunks after it have no material name at all. How the engine draws a chunk with no material, or an empty one, is unread [hypothesis: nothing visible, as the stubs are degenerate]. The resolver reports such chunks as route: "none" (unlistedChunk, rule note R10-short-chunk-list); the planner neither draws them nor counts them as unsupported.

This is how one hair-colour pack colours every hairstyle without shipping per-style materials: the saved 38_ash_brown becomes mesh appearance ash_brown, ash_brown@long instantiates the pack's template__long.mi, *{long_base_material} resolves to the hairstyle's own strand material and *…\{material}.hp to ash_brown.hp. The High Ponytail works the same way with the template inside its own mesh: the saved 15_pink_magenta is built from the app's 01_blonde_platinum (FixCustomizationAppearance), ArchiveXL's bundled h1_base_color_patch.mesh adds an empty pink_magenta appearance, which expands from black_carbon to pink_magenta@long ×2; the mesh's local @long template has base material highpony_long.mi and HairProfile = *…\hair_profiles\{material}.hp, so the chunks draw hair.mt with pink_magenta.hp [resource: resolver run on the reference installation, 26 September 2026]. Its cap mesh instead lists one appearance per colour, and maps some colours to another colour's cap gradient (pink_magenta → black_carbon@cap); that is the mod's own data, drawn as authored.

Export: meshes WolvenKit won't write

Resolution can be right while the preview still can't draw a part, because the part's geometry comes from WolvenKit's GLB export. Short bonePositions. WolvenKit's mesh exporter builds the GLB skin from renderResourceBlob.header.bonePositions (one joint per entry, MeshTools.GetOrphanRig), taking each joint's transform from boneRigMatrices when it can invert it, while render chunks' skin indices address boneNames [source: WolvenKit commit 11720772]. The High Ponytail's ponytail mesh has 141 bone names and rig matrices but 45 header positions, which also don't match the rig matrices beyond the first bone resource. Its vertices reach bone 140, so SharpGLTF refuses to write the file (Accessor[7] JOINTS_0[31]: Is out of bounds) and uncook writes the raw mesh and textures but no GLB [observed: WolvenKit 9.0.1]. That, not material resolution, is why the preview showed only the fringe. The mod draws in game, so the engine evidently skins by boneNames and boneRigMatrices hypothesis.

XF Studio's exporter now retries such a mesh from a repaired copy (mesh-export-repair.ts, createWolvenKitMeshRepair): serialize the raw mesh, fill the missing positions with each bone's inverted rig matrix translation (WolvenKit's own importer rule), deserialize, pack the copy alone into a private archive at its depot path and uncook it as before. Only the header positions change, and WolvenKit derives the exported joints from the rig matrices anyway, so the geometry and skin are the mod's own. The result is cached like any complete export with a repair.txt line, and the character record carries a note. On the reference installation the repair takes about 22 s once (five WolvenKit launches) and yields a GLB with 141 joints. Any part the preview still leaves out is named in the record's notes with its reason (character-detail-service.ts build).

Resolution: mod resources WolvenKit won't read

A resource can be installed and win at its path, yet be one WolvenKit can't convert to JSON. [observed, WolvenKit 9.0.1] On the reference installation a hair replacer pack (22 archives under one MO2 mod, each replacing a vanilla hairstyle's base\characters\head\player_base_heads\appearances\hairs\hh_*.app) wins over basegame_4_appearance.archive, and both uncook -s and unbundle + convert s fail on its .app files with Invalid in wolven rtti: "entSkinnedMeshComponent.castShadows" [Expected: "shadowsShadowCastingMode" | Got: "Bool"] and a Red4ParserException; the converter leaves an empty .json. The mod stores castShadows (and castLocalShadows) with the property's older Bool type, where the current RTTI declares the enum shadowsShadowCastingMode. A body-UV framework's l0_000_base__full.app fails the same way. Through WolvenKit alone, the eight hairstyles the pack replaces on the default V (02, 03, 04, 07, 08, 09, 10, 12) were unreadable and the hair slot unavailable ("WolvenKit couldn't read your V's hair (…) from ….archive"), while the seven it doesn't replace drew.

The Studio now reads resources with its own reader first (section 6), which decodes a value by the type the file declares and notes where the RTTI disagrees (archive formats §6.5). All eight hairstyles resolve to the pack's meshes and draw, and the framework's body .app resolves [resource: the default V prepared with each of those hairstyles through the character-detail host, and the cold-resolve bench, 27 September 2026]. Each part read from such a file carries a record note (rule R11-stored-type, graded hypothesis) saying the value was read as stored. How the engine treats a stored property whose type differs from the current RTTI is still unread [hypothesis: it skips the mismatched property and keeps the default, since the pack is in active use]; runtime proof would be the hairstyle drawing in game with this pack enabled. The same pack's meshes store renderLODs with a count of 1 in front of 4 floats; WolvenKit shows all four, and the reader now reads such an array to its record's end too, with a note (R13-array-past-count).

A resource neither reader can read is still reported plainly: an appearance whose .app an archive provides but no reader can read is unreadable (character-resolver.ts), not missing, and the slot names the archive.

6. Implementation and reproduction

projects/xf-studio/authoring/src: depot-path.ts (hashing), rdar-index.ts, archive-precedence.ts (mount plan, DepotIndex), archivexl-config.ts (.xl semantics), resource-graph.ts (effective resources, provenance), cco-model.ts, character-resolver.ts — all pure — and resolver-host.ts (source discovery, index reading, the native-first fetch port, and WolvenKit CLI extraction to an ignored JSON cache keyed by depot hash, archive fingerprint and WolvenKit identity). tools/resolve-character.ts writes a local report. The archive container, depot hashes and CR2W encodings behind the index reads and extraction are in archive and resource formats. The eye plate uses the same rules through eye-plate-head-source.ts (pure) and eye-plate-head-resolver.ts (adapter) to cut from the head the route loads; it applies the mesh and morph patch rules of section 4 to the extracted JSON, limited to properties that reach the plate. Tests: tests/archive-precedence.test.ts, cco-merge.test.ts, character-resolver.test.ts, dynamic-hair.test.ts (synthetic: dynamic materials, a short chunk list, a patched colour, the export repair and dropped-part notes) and the opt-in character-resolver-integration.test.ts.

On the reference installation the MO2 route mounts 1,079 archives (77 listed as not mounted), reads 1,000 visible .xl files and merges 242 female custom CCO resources. Validation: resolver validation.

One opened route per process (installation-registry.ts). The hosts (character details, grading LUT, the eye plate's head source for Build) share one opened installation per launch route, WolvenKit and settings instead of scanning the mod setup for every request. Opening records every path its answer depends on, with a stamp taken when it was read: every folder source discovery walked (stamped before its entries are read), every candidate archive and .xl, the MO2 settings file and profile modlist.txt, the game's ArchiveXL bundle folder and bin/x64/Cyberpunk2077.exe. Before reuse every stamp is read again in parallel; any difference opens the route again. This relies on a folder's modification time changing when an entry is added, removed or renamed in it and on a file edited in place changing its size or time; a tool that rewrote a candidate archive with the same size and restored its timestamp would not be noticed [source: the resolver's JSON and index caches already key archives by path, size and time]. Folder times are trusted only on NTFS and ReFS volumes, read with GetVolumeInformationW (volume-info.ts); on FAT32, exFAT, a network share or a volume that can't be identified, each scanned folder is stamped by a hash of its listing (names and entry types) instead, which a restored or coarse folder time can't hide [source: the test puts a folder's time back after adding an archive; the time stamp misses it, the listing stamp doesn't]. A change also moves the route's generation on, which the hosts' request keys include, so answers prepared from the old setup are prepared again. That holds for a route dropped to make room for another (two are kept): it keeps its watch list, and the next check or open compares it. An installation opened with read errors (an unread archive index, an unreadable folder or .xl) is opened again once it is a minute old, since those stamps may not change when the problem clears; its generation moves on only if the errors changed. A route whose WolvenKit changed in place can't be asked for again (the key includes WolvenKit's identity) and is dropped. A host checks the route before answering a request, and that clean check stands for the preparation's own acquire a moment later, so a request checks once. Parsed archive indexes and .xl documents are kept in memory by path, size and time, so reopening after a mod change reads only what changed. On the reference route (9,334 watched paths) the check takes about 50 ms, a first open about 1.4 s and a reopen about 1 s. Source discovery dominates the open; it now takes entry types from the folder listing and inspects only folders and candidate files (0.8–0.9 s instead of 1.6–2.2 s, with identical output).

Native first, WolvenKit per resource. Each game folder gets one native decoder, opened asynchronously before the route's first open and shared by every route and cache folder on it (installation-registry.ts; resolver-host.ts openNativeRoute): a worker that reads archives with the game's own Oodle library, with a 10 s budget per resource. Each cache folder's fetch port (ResolverFetcher) asks it first and falls back to that folder's WolvenKit fetcher for a resource it doesn't answer: one its archive doesn't list, a root class outside the verified set, or a refusal (unsupported, malformed, decompress, over-budget, io, unavailable, internal). Every fallback is counted by kind and recorded in the rolling diagnostics window; the unexpected kinds (all but a class the reader doesn't read) are logged in plain words, each resource and kind once and at most 12 of each kind per session (so a worker outage never keeps a later reader bug out of the log), and a reader bug as a failure with the worker's stack. Production never rethrows reader bugs (strict is for benches and tests). A fallback whose native failure may not repeat (unavailable, io) keeps the read retryable, so a worker that was down doesn't settle a lasting WolvenKit failure. When the decoder can't be opened (not Windows x64, no library, an unrecognised library, a worker that won't start), the route reads with WolvenKit alone and the diagnostics log says why in plain words (native_reader_off); a reason that may pass is tried again after a while, and XFS_NATIVE_READER=0 turns it off for comparisons. A decoder that can't answer now (closed, or waiting after a failed start) doesn't count earlier native answers as ready. The worker releases the game's Oodle library after a minute idle; texture and mesh workers exit after 15 s, and every worker lets go of its archive indexes and collects its heap once its queue has been quiet 5 s (DESK-08) (archive formats §2). The creator catalogue's on-screen texts (onscreens.json, JsonResources) go through the same fetch port (fetchJsonResource): natively at background priority, gated on their payload class, else WolvenKit's JSON-resource mode in the resolver's own batches; a cold catalogue on the reference route read 927 texts natively in 5.3 s with no WolvenKit launch. The catalogue keeps its own rule for texts that couldn't be read: a read that may pass (the worker couldn't start, an archive briefly unreadable) leaves a catalogue that is served with a plain line and Try again but built again after a backoff, and never counts against a person's V (jsonTransientNulls, not transientNulls); a text only WolvenKit could read while WolvenKit isn't set up is said plainly with that next step; only a language whose game texts no archive holds is "not installed". The catalogue's key includes the game's on-screen language, so changing it in the game's settings builds the catalogue again. Parsed texts an earlier reader wrote are removed from the cache while the native reader is on. The clothing host decodes its 13 MB preset in a worker of its own, so it never holds up the route's queue.

WolvenKit is optional until export. A route opens without WolvenKit (wolvenKitCli: null): it reads natively alone, and a resource only WolvenKit could read is answered null for that route (counted as withoutWolvenKit) and named on the part in plain words, with the hint that setting WolvenKit up may read it. The creator catalogue needs only the game folder. The character details read their textures, layer masks, meshes and morph targets with XF Studio's own readers (WolvenKit exports only what those refuse), yet a V is still drawn only once WolvenKit is set up; without it the host answers at once that the V waits for WolvenKit (need: "wolvenkit", not a failure: nothing is resolved and nothing is logged as an error), preparing choices ahead stops as setup, and the page offers WolvenKit's own next step (its download consent, the copy found on this computer, .NET, or where it is set) beside the line, even while the 3D preview card is hidden, and asks again every few seconds so the V is prepared once WolvenKit is set up. The packaged app starts the worker from its bundle (native-decode-worker.js, built by prepare-static.ts), which the desktop server hands to the registry (useNativeWorker).

Native answers are not written to the JSON cache: reading and decoding takes milliseconds. The graph reads what the answer adds: the reader's notes become rule notes on the .app, mesh or morph model (R11-stored-type, R12-property-absent, R13-array-past-count) and, through the plan, the record's notes. A rendChunk.renderMask the file left out is its class default, no flags, so the chunk is not drawn, as with WolvenKit's "0" (archive formats §6.5); R12 says so on the part. A preparation that met a null answer that may not repeat, from either reader, is degraded and prepared again (ResolverFetcher.transientNulls). A native answer carries the same extractedSha256 as WolvenKit's (the bytes are identical), so provenance doesn't depend on the reader. Native answers are not fresh, so the graph's prefetch (below), which exists to batch WolvenKit launches, doesn't run for them.

Cold resolve of the reference save on the reference MO2 route (1,079 mounted archives), 27 September 2026, tools/native-resolver-bench.ts (offline, not game evidence): native first, with the worker the hosts use, 4.0–4.2 s, no WolvenKit launches, 515 resources read natively, no fallbacks (3.5 s decoding in-process); the route open took 2.5–2.6 s either way. A cold WolvenKit-only run of the same save now exceeds the 6 GB memory guard (its launches load over a thousand archives), so the comparison ran against a WolvenKit cache seeded from the Studio's own; the cold WolvenKit figure stays the 26 September one (90–95 s, 23 launches). The resolved characters are equal except the framework body .app above. Preparing the default V and hairstyles 01–12 through the character-detail host with empty resolver caches: 585 native reads, no fallbacks, no WolvenKit launch for resolving (exports still launch WolvenKit).

Fewer WolvenKit launches. When WolvenKit does run, a resolver batch extracts every resource that has a depot path with one uncook -u -s launch, whose JSON equals unbundle followed by convert s once the converter's Header.ArchiveFileName (the temporary file it read, a private path) and the export time are set aside; the cache stores neither route's ArchiveFileName. An opt-in oracle (tests/wolvenkit-uncook-oracle.test.ts) compares both routes on a material instance, a mesh, an .app, an .ent, a morph target, a layer setup and a texture from the installed game, and passed with WolvenKit 9.0.1 and 8.17.4 on 26 September 2026. Resources it does not write (hash-only references, archives without path names, or a resource written without JSON) fall back to unbundle --hash and convert. A launch's selection pattern and archive list are split so its whole command line, escaping included, stays within Windows' 32,767 characters. Each WolvenKit launch costs about 2.6 s before doing any work, and 4.0–5.1 s with -gp (measured with a launch that selects nothing, WolvenKit 9.0.1). A resource the archive lists but WolvenKit writes nothing for, in launches that finished cleanly, is marked not-written for that archive and WolvenKit; from the second such batch the marker is lasting, so it is not extracted again until the archive or WolvenKit changes. The resource graph reads a resource's ArchiveXL patch sources together with it (only once the target itself is provided), reads a requested appearance's part .ents together, and prefetches from a freshly extracted resource the kinds that consumers otherwise read one at a time: the .mt, .hp, .sp, .gradient and .mlsetup a mesh or material names (a fresh .mlsetup brings its .mltemplates). An .app's parts are no longer prefetched: an .app names every appearance's parts (a framework's piercing .app names 31, one appearance needs one to three). A morph component's mesh is read with its morph target rather than after it (ResourceGraph.prefetchRef; the component usually names the morph target's own base mesh). A cached resource is never expanded, so a cached V starts no extraction. What remains is the chain's real depth (.app → .ent → morph target → mesh → material instances → layer setup → layer templates → textures), one batch per level. Precedence ambiguities are recorded for the resources a V's resolution reads (a prefetched resource nobody asked for adds none), so a V reports the same ambiguities from a cold or a warm cache.

Timings on the reference MO2 route in one process, WolvenKit 9.0.1, 26 September 2026 (offline measurements, not game evidence): a cached preparation took 3.9–5.4 s per V before (every preparation opened the route and resolved from scratch) and now takes 0.3–1.2 s once the route is open (about 3 s for the first V in a process, which also opens the route). Through the localhost server: 2.9 s for the first V, then 0.6–1.1 s. With new resources, trying piercing style 05 on the default V took 85.7 s and 24 launches before, 29.6 s and 8 now; a save's first preparation took 180.6 s and 36 launches before, 100.9 s and 14 now (reference save), and 133.7 s and 40 before, 58.6 s and 16 now (a second save). The graph each cache folder keeps holds a mesh, morph target or .app as the shape its models read rather than the whole document (a morph target's document is mostly per-target data nothing reads: 10 MB for the player head's), about 44 MiB of JSON after three Vs and three piercing styles; the heap after that sequence fell from 318 to 136 MB, with the same or shorter times (the reference save 1.07 → 0.85 s, a second save 0.45 → 0.38 s, a cached try 0.08–0.37 s). The graphs of every route and cache folder keep at most 256 MiB of JSON together (the least recently used start afresh first), and a graph is replaced after a consumer's read failed in a way that may not repeat.

One preparation at a time, each page its own (character-detail-host.ts). The character-detail host runs one preparation at a time on its shared cache. Each open page (a window or tab) names itself with every request (X-XFS-Page), and a page's new request supersedes only that page's earlier one: switching V never finishes the previous V, but another page's V is left to finish and the new one waits its turn. Before this, one run was kept for every page, so two pages following different Vs (a person's own tab and a ?verify=1 tab) cancelled each other on every poll and neither V was ever prepared (PIPE-103).

Where a preparation's time goes. Each preparation's log line gives every stage's time with the native reads, WolvenKit reads and WolvenKit launches it made. Measured on the reference MO2 route through an isolated localhost server, 27 September 2026 (offline, not game evidence), default V with the body:

CaseTotalLargest costs
First ever (empty export and resolver caches), textures and meshes read natively (27 September, own server, default V of the verification workspace with the body, censored)19.9–20.0 s (two runs)exports 11.9–12.5 s: the 18 meshes and morph targets decoded natively in 1.55–1.75 s and the 77 textures in 14.9–16.5 s, beside WolvenKit's one launch for the layer masks (4.4–4.6 s); layer maps 4.3–6.5 s; open 3.5–3.8 s; creator resource 1.8–1.9 s; resolve 1.3–1.4 s. Process tree peak 2.6–2.7 GB
Same, meshes through WolvenKit (XFS_NATIVE_MESHES=0)56.0–63.2 s (two runs)exports 48.4–52.1 s: WolvenKit's geometry and mask launch 37.3–37.5 s beside the 77 native texture decodes (18.8–24.3 s); peak 2.9 GB. The machine was busier than for the 23 s row below (other agents' work), so compare rows of the same day
First ever (empty export and resolver caches), textures read natively, meshes through WolvenKit23.1–23.5 s (three runs)exports 17.1–17.3 s: WolvenKit exports the parts' geometry in one launch (17.1 s) while XF Studio's texture reader decodes all 66 textures beside it (14.0–16.3 s; none went to WolvenKit), the body texture mod's four 8192² maps served from their 4096² mips; layer maps 2.9–3.4 s; open 2.8–3.0 s; creator resource 1.4 s (243 native reads); resolve 1.5 s (131 native)
First ever before the texture reader (15 of 81 exports cached from an earlier interrupted run)64 sexports 32.9 s in one WolvenKit launch (peak 7.9 GB private in WolvenKit.CLI, from the four 8192² maps); halving those maps to 4096 on the host 25.5 s
Clothes added, first time (same process)35 sthe item factories: 642 .csv read through WolvenKit in 10 launches (the native reader doesn't decode C2dArray's compiled tail)
After a restart (disk caches warm), 27 September7.4 s native meshes, 6.3 s WolvenKit's cached GLBsopen 1.8–2.2 s; creator resource 1.8–2.1 s; resolve 2.0 s; exports 0.3–0.5 s (97 of 97 from the export caches). Peak 1.4–1.6 GB
After a restart (disk caches warm)4.8–5.6 sopen 1.7 s; resolve 1.1–2.1 s; creator resource 0.9–1.3 s; exports 0.2–0.4 s (81 of 81 from the export caches, 66 of them the texture reader's); with clothes, factories 0.4 s from WolvenKit's JSON cache
Same V again, or another page's V, in the same process0.1 snothing is read again

So a restart cost about 5–7 s on the host alone, and the long waits are first uses: the texture decodes (now the longest export stage) and the factories (PIPE-105); the geometry export went native with phase 4 of the native reader.

After a restart: kept discovery, kept answers and the page's warm start (PREV-196, 29 September 2026). Measured from the page's start with tools/measure-warm-restart.ts (isolated server, default V, a kept Chrome profile), a restart took 12–13 s to the whole V drawn: the first open walked the MO2 route's 10,209 folders and files synchronously (1.6–2.0 s in which the host answered nothing), the V was resolved afresh, and the page asked for it only after its head, idle and blink. Three things now carry across a restart:

  • The route's discovery (installation-snapshot.ts), kept in the resolver cache and reused when every stamp it depends on is unchanged, the same rule the registry reuses an opened installation by, read in parallel (40–90 ms); the indexes and .xl files it names are read ahead without blocking (readInstallationAhead), so the open only assembles. It is named by the route's settings, the scan limits, the host code (host-code-identity.ts: every source and JSON data file's size and time on localhost, the bundle and the decode worker's bundle on the desktop; a failed read of it is retried, PIPE-128/129) and its own version. A discovery that met a read failure that may not repeat (a folder, entry, profile or manifest it couldn't read) is never kept or reused (PIPE-127).
  • The V's answer (prepared-answers.ts): a complete preparation is kept under the choice manifests (choices/answers/, so Clear removes it) with the request, the route and WolvenKit's identity (installationRouteKey), the host code, the record and the watch list of the installation it was prepared from. A request this process hasn't answered is answered from it at once when every stamp is unchanged (read 1,024 at a time) and its record is still stored; anything else prepares as before. The V is then resolved into the shared cache in the background once the page has been quiet for 2 s, so the first change after a restart resolves only what it changes. A kept answer's check vouches for the same V's next request within 5 s (KEPT_VOUCH_MS: the page's own request follows its warm start's).
  • The page's warm start (character-warm-start.ts): the page remembers the last request it asked for (in the workspace's storage, per workspace) and at its next start asks the host about it with X-XFS-Warm (answered only from a known or kept answer, never by preparing), then fetches, verifies and decodes the V's files while its head loads; the load that shows the V takes them, counting their bytes against its own budget. When the page asks for another V, what was read ahead is let go at once; a V this process already answered (a page reloaded) is checked against the installation before the warm start is told it is ready (PREV-199).

With the skin's CPU reads done on copies scaled off the page's thread (they took 0.5 s of it) and the face records' base64 decoded in one pass, a restart now shows V whole 2.8–3.0 s after the page starts (4.1 s without the warm start), first paint at 0.28–0.30 s. What is left is the page's own order (the head, its idle and blink before the V's request) and the host's other start-up work contending for its thread (performance).

Textures are read by XF Studio's own reader (src/native-texture-export.ts, PIPE-104). The character-detail host wraps its WolvenKit exporter: each texture of a winning archive is decoded natively in a texture decode worker of its own (one per game folder, so a map never holds up the resolver's reads; archive formats §10), and only the mip the preview takes is decoded, the largest at or under the served 4096 (SERVED_TEXTURE_MAX), so an 8192² map is neither exported at full size nor halved on the host; the record notes such a map ("8192×8192 in the game files; the preview uses its 4096×4096 mip"). Layer masks are read by the same workers (PREV-190: archive formats §10.4), and a batch's textures and masks are decoded by two workers side by side while more than 4 GB is free (textureLanes); geometry goes to the mesh reader beside them. WolvenKit exports any texture or mask the readers refuse (a format it doesn't decode, a damaged file, over a budget, the worker down), in one extra launch per batch; refusals are counted by kind in the preparation's log line and unexpected ones go to the diagnostics log. When that extra launch fails, what the archive's other launches and the texture reader answered is kept: the geometry and the natively decoded textures are still served, and the failure only marks the preparation degraded, so it is prepared again (PIPE-108; before, one texture both readers refused dropped every part of its archive). Native PNGs are cached in the export cache folder under the texture reader's identity (xfs-native-texture:<version> and the served size) with the depot hash and archive fingerprint, never WolvenKit's identity, so the prepared-files budget, Clear and the choice manifests cover them. The PNGs are texel for texel what WolvenKit's are. A folder of archives (the core preview's game content source) is still exported by WolvenKit. XFS_NATIVE_READER=0 turns the texture reader off with the rest of the native reader.

Meshes and morph targets are read by XF Studio's own reader (src/native-geometry-export.ts, native reader phase 4). It wraps the texture exporter the same way: each mesh or morph target of a winning archive, asked for without WolvenKit's materials file (the character details resolve materials themselves), is decoded in a mesh decode worker of its own (one per game folder, beside the texture worker) to the GLB WolvenKit's MeshOnly export gives, with the extracted resource as its raw file; the vertex data is WolvenKit's bit for bit (archive formats §12). A morph target's joints come from its effective base mesh in the archive the resolver says wins (the plan's baseMesh, passed as the request's bases), as the game resolves it, rather than from the morph target's own archive as WolvenKit's per-archive export does; on the reference route that gives the facial-rig fix mod's head morph target, the nails morph mod's nails, an earring morph and two piercing morphs their skin. The entry records which base it used. What the reader refuses (a cloth mesh, an unusual layout, a damaged file, the worker down) goes to WolvenKit in one extra launch per batch, counted by kind in the preparation's log line (meshes read natively: …); unexpected refusals go to the diagnostics log. Native GLBs are cached in the export cache folder under the mesh reader's identity (xfs-native-mesh:<version>:<resource reader version>:<data hash>) with the depot hash and archive fingerprint, so the budget, Clear and the choice manifests cover them; a choice manifest names a morph target's base mesh too, so a cached morph target counts as ready only for the base its skin came from. Where WolvenKit needs its repair route (short bonePositions, short garment flags) the reader reads the file directly and the record carries its plain note instead of the repair line. The core preview's head and eyes, exported with materials from the content folder, still go to WolvenKit. XFS_NATIVE_MESHES=0 turns only the mesh reader off; XFS_NATIVE_READER=0 turns off all of it.

A first-time choice: what is left. After the resolution, everything the plan implies is asked for at once, so it shares one extraction batch: hair and skin profiles, colour ramps, layer setups (their templates follow in the next batch) and each texture's colour flag, and the templates read for their names. The export of the change's parts runs beside those reads. All of a change's WolvenKit exports (every archive, geometry and masks, and any texture the texture reader refuses; the texture reader decodes the rest beside them) go into one uncook launch (GameAssetExporter.exportAll) when the archives' requested resources don't collide (outputs are written by depot path; the index says which archives hold what), otherwise into as few as don't; a shared launch that fails is retried per archive, so one archive can't fail the others. The character details export geometry without the game folder (-gp): WolvenKit then neither resolves the materials nor decodes every texture they name (73 files for one CCXL hair mesh, which the character details never read, since they resolve materials themselves), and the GLB is the same, byte for byte [resource: a CCXL hair mesh and a vanilla morph target, WolvenKit 9.0.1]; that cuts one hair export from 7.6–8.8 s to 4.2 s. A resource that gives no GLB that way is exported again with the game folder. The core preview still exports its head and eyes with materials. What remains of a first-time click is the chain's depth, one batch per level, and one export beside the last reads: a CCXL hairstyle is .app → mesh → material instance → (profiles and textures, export), 5 launches instead of 6–7; a vanilla .app whose name WolvenKit's hash list lacks (e.g. hh_011_pwa__hairs_063.app) costs unbundle and convert besides. About three quarters of what is left is WolvenKit start-up (5 launches × 2.6 s of a CCXL hairstyle's 16–20 s).

Settled exports. An export is cached per resource, archive identity and WolvenKit identity (game-asset-export.ts). A rewrite of an entry (a partial count, a raw check, a repair) puts its files in a new generation folder inside the entry and switches entry.json to it in one rename, so paths another export already returned (a warm-up beside a prefetch, two exporters over one cache) stay readable; superseded generations are removed on a later write after 15 minutes (PREV-200). An export's work folders are removed only once every decode lane has stopped, and one a crash left behind is swept with the budget check once an hour old (PREV-198). A mesh exported without the materials file it was asked for (the core preview's) is kept as a partial entry and served from the cache from its second clean run (PARTIAL_RUNS); a resource clean launches produce no GLB (or PNG, or mask layers) for is recorded (<entry>.none.json) and, from its second, not asked for again. Both follow the resolver's not-written rule: another WolvenKit or a changed archive has another key. Both are also recorded under the exporter's repair identity (GameAssetExportCache.lastingIdentity: the WolvenKit key plus MESH_EXPORT_REPAIR_VERSION), and a marker or partial count recorded under another identity counts 0, so adding or changing a mesh repair tries again what an exporter without it had settled; a repaired export (an entry with repair.txt) is likewise reused only under the repair version that made it. A mesh without a GLB goes through the repair route only on its last launch (after the retry with the game folder), at the launch's priority. resource WolvenKit 9.0.1 reads the CCXL High Ponytail's fhair_highpony_pony.mesh but writes no GLB for it, with or without the game folder; before the repair existed this rule settled it, and the identity rule is what lets the repair reach it.

Preparing choices ahead (choice-prefetch.ts, CharacterDetailHost.prefetchRow, warmCharacters). When a Character panel row is open, the host prepares its choices in the background, the ones in view first: each choice's request is the V with that choice set, and a batch of up to 8 of them resolves side by side on the shared graph, so each level of all their chains is one extraction batch, and their parts are exported in one launch. Nothing is written to the preview's store; the shared preparation cache keeps what was resolved and exported, so the click only plans and writes. On the reference route, 16 CCXL hairstyles took 11 launches and 44 s (65% of it WolvenKit start-up) and added 185 MB (11.5 MB per hairstyle); the 16 hair colours of one of them took 6 launches and added 0.8 MB. A click on a prepared choice then took 0.08–0.23 s (0.4–1.3 s while other work loaded the machine), against 3–25 s the first time. Rules:

  • A person's own change comes first. Preparations run as foreground work on the resolver's cache folder (foregroundExtraction): their batches go on their own chain, never waiting behind a background batch already in WolvenKit, at normal priority. Background batches (backgroundExtraction) and prefetch exports, by-hash exports included, run below normal process priority, decided as each launch starts rather than when its batch was queued: once a preparation runs on the same cache folder, a background launch that starts runs at normal priority, since the preparation may wait on it (backgroundPriority). When a preparation starts, WolvenKit processes still running below normal are raised to normal (raiseBackgroundWolvenKit). The batch being prepared ahead is also stopped (its export launch is ended; reads already in WolvenKit finish and are cached) and its choices are queued again; the queue waits until the preparation ends, and nothing awaits between that check and a batch's start, so a preparation always finds the batch to stop. The stopped batch answers the queue at once, and the preparation starts once the queue has let go of it (ChoicePrefetcher.idle). The batch reads through a stoppable view of the shared graph (stoppableGraph): once stopped it asks for nothing further down its chains, while reads WolvenKit already has finish and stay cached for everyone; it forgets nothing and writes no manifest, and the next batch starts only once it has settled, so repeated clicks never pile up background preparations. A hover or focus moves a choice to the front.
  • One shared cache, attributed per run. Each preparation and each batch runs as its own CacheRun on the shared preparation cache (followed through its async work with AsyncLocalStorage): a run that met a failure which may not repeat forgets only the entries it added, never one another run added meanwhile, and a run's manifest names the reads behind every entry it was served from memory (transitively), not only its own graph reads, so a choice never shows ready after a mod changes a resource it was built from.
  • Single flight holds across both. A resource one chain is extracting is awaited by the other (the lane's in-flight map), batch folders are unique, and an export published twice keeps the first.
  • Bounded. A row's job stops after 10 minutes of its own work (time spent waiting for a person's change or for a stopped batch isn't counted) and preparing ahead stops for the session once it has added 2 GB of prepared files; the remaining choices show "not prepared", and a click still prepares them. Clear prepared game files starts the session's 2 GB again. Closing the row stops its job, and so does the page no longer asking about it for 90 seconds (the page asks every 0.8–4 s; a hidden browser tab may be held to one timer a minute), which also stops its batch in WolvenKit; asking again starts the job afresh.
  • Never ends the host. Background work has nobody to report to, and an unhandled rejection would end the Bun process: a failure in the job (a dependency throwing, a damaged manifest) stops it, leaves its remaining choices "not prepared" and is reported once to the diagnostics log. A choice whose readiness can't be checked is queued; one whose catalogue lookup fails is not prepared. Manifests are validated entry by entry on read, so a damaged one means "not prepared". Both servers also record any other unhandled rejection in the diagnostics log instead of exiting (logUnhandledRejections).
  • Only one row at a time, the one opened or pointed at last, and only while it shows all its choices (not a search) and the 3D view draws the option. Its job is keyed by the V without the row's own choice, so choosing in the row keeps the job and its states; the chosen choice shows as being prepared, then ready.
  • Background reads don't check the installation. A readiness check or a batch uses the route's opened installation as it is (InstallationRegistry.peek): a check reads every watched stamp (9,334 on the reference route), and an acquire would spend the vouch a person's request relies on (PIPE-62). What they derive is checked again before it is used, and a person's request still checks.
  • Batches grow. The first batch is 8 choices (the ones in view come back soonest); each later one that ran to its end doubles the next, up to 32 (a stopped batch doesn't), since every batch costs its chains' depth in launches whatever its size: the reference route's 51 vanilla hairstyles took 70 launches and 302 s in batches of 8, and 39 launches and 133 s growing (1 MB per hairstyle, mostly JSON; on that route a hair replacer pack of 22 archives replaces many of them, and WolvenKit can't read its .app files, see "Resolution: mod resources WolvenKit won't read").

Measured on the reference MO2 route with WolvenKit 9.0.1, 26 September 2026, through the character-detail host (bun tools/measure-choices.ts, copies of the reference caches restored before each run, the default V; offline measurements, not game evidence). Other work loaded the machine during some runs, so times are ranges over runs; launch counts are exact.

ChangeBeforeAfter, first timeAfter, row prepared ahead
Vanilla hairstyle 1215.6 s, 4 launches13.3–14.4 s, 40.06–0.07 s, 0
CCXL hairstyle (Bree)25.4 s, 617.0–20.3 s, 50.08–0.10 s (0.4–1.3 s under load), 0
CCXL hairstyle (Emily)25.2 s, 615.6–19.2 s, 50.13–0.23 s (0.9 s under load), 0
A CCXL hairstyle's hair colour3.2 s, 13.3–4.8 s, 10.06–0.38 s, 0
Eye colour6.6 s, 26.7–8.7 s, 20.06 s, 0
Piercing style 054.0 s, 13.8–4.7 s, 10.16–0.23 s, 0
Empty caches: the default V, then Bree150.1 s / 37 launches, then 26.6 s / 7115.7 s / 29, then 16.1 s / 6–

Preparing ahead, per row: 16 CCXL hairstyles 44–116 s and 11 launches, 11.5 MB each; their 16 hair colours 26 s and 6 launches, 0.05 MB each; 24 eye colours 34 s and 11 launches, 0.16 MB each; 15 piercing styles 60 s and 11 launches, 3.5 MB each; all 51 vanilla hairstyles 133 s and 39 launches, 1 MB each. In the browser (?verify=1), a click on a prepared hairstyle answered 0.31 s after the click, and the next session opened the row with the choices prepared before already ready.

Click → pixels: the page, and the host's one thread. Measured 27 September 2026 on the reference MO2 route with the reference save's V (29 parts, clothes on), through an isolated server and a headless GPU Chrome (tools/measure-character-page.ts, page marks from src/character-timing.ts; offline measurements, not game evidence):

  • The page was not the slow part. A change reuses every unchanged part (PREV-68): 22–24 of 23–29 parts, and a record takes 20–250 ms from its arrival to the first frame drawn with it. The page waited a fixed 600 ms before its first poll; it now asks again after 25, 50, 100, 200 and 400 ms, then every 600 ms.
  • The host's event loop was. With a Character row open, the host checked which of the row's choices were ready (every read of every choice's manifest: an archive stat and a cache exists check per resource, all synchronous), asked the registry for .NET with a synchronous reg launch every 3 s, and walked the prepared files' size with every lstat in flight at once. A /health probe stalled for up to 26.6 s, and changes took 3–28 s (Reset all after a burst of hairstyles: 27.8 s) while the log said 0.2–0.3 s, because it timed only the preparation. Now the ready check runs one manifest entry at a time and takes a turn between them (manifestProblemSliced, src/event-loop.ts), reading each archive's identity once per manifest (withArchiveFingerprints); background work waits while a person's change is asked for, prepared or read, and until the page has been quiet for 400 ms; a change stops the batch in progress even when its answer is ready; the registry is asked in the background at most once a minute; the prepared files' checks keep at most 16 file-system calls in flight, shared by every walk at once, and eviction lists entries only when the size is over budget. The log line gives the wait too ("waited 1.2 s before starting, 2.9 s in all").
  • Parts are kept for the next change (DetailPartPool, character-detail-loader.ts): parts the next record doesn't use, and those a superseded change built, stay loaded on the renderer's context, keyed by their content and what they were built on (a face decal's skin, a garment's body shape), up to 48 parts or 64 M texels that only kept parts use. Undo, a hairstyle tried again, makeup shown again or a burst's last step then fetch, decode and upload nothing. Another V releases them, and so does a slot the V stops drawing at all (the body turned off, clothes taken off: PREV-108) or no V shown. A part being built when its change is superseded is finished and kept.
Change (reference save V)Row open, before (four runs)Row open, after: first time, againNo row open, after: first time, again
Hairstyle 120.38–7.2 s1.07 s, 0.10 s0.79 s, 0.09 s
Hairstyle 010.77–9.3 s0.35 s, 0.10 s0.33 s, 0.08 s
Hair colour0.09–15.7 s0.56 s, 0.09 s0.30 s, 0.08 s
Makeup on (four rows in one step)0.83–8.5 s1.12 s, 0.10 s0.41 s, 0.08 s
Hide makeup, as a creator change (every makeup row Off)0.11–14.0 s0.32 s, 0.08 s0.31 s, 0.08 s
Hide or show the V's own makeup, now a viewing setting (character.setOwnMakeup: nothing is prepared)–0.007–0.015 s–
Reset all0.19–7.5 s0.28 s, 0.11 s0.08 s, 0.07 s
Three hairstyles back to back0.30–12.5 s0.13 s, 0.12 s0.10 s, 0.10 s
Reset all after them0.14–27.8 s0.09 s, 0.11 s0.07 s, 0.08 s
/health while it ran: median, p95, max0.12, 0.76, 26.6 s0.10, 0.25, 2.0 s0.10, 0.15, 0.23 s

"First time" is the first round after a server restart: it includes the host preparing each record (0.2–0.7 s) and the page loading parts it hadn't shown. "Again" is the second round. The /health median is mostly the probe's own start-up. What is left: each change asked for checks the mod setup's watched paths (about 55–90 ms of the 75–110 ms), in the performance backlog.

A part new to the page: made ready before it shows (PREV-189, 29 September 2026). Measured on the default V with a headless GPU Chrome (a fresh profile each run, so no compiled program was cached; ANGLE on D3D11, RTX 4070), tools/measure-hairstyle-switch.ts with the page instrumented by tools/gl-probe.ts. Before, a hairstyle new to the page froze the page for its first frame: 0.7–0.9 s for a 4096² hairstyle (three 4096² maps at 110–145 ms each, because an image element uploaded unpremultiplied is decoded again on the page's thread, and two to four programs linked at 120–160 ms each), after 0.35 s of loading its files one after another; 1.28–1.36 s in all. Now:

  • Files at once, bitmaps off the page's thread. A record's new parts are fetched and decoded together (the loader starts every file its budget allows before building), and each map is decoded a second way, as an ImageBitmap with the raw texels WebGL is given (premultiplyAlpha: none, colorSpaceConversion: none), which its first upload uses and then closes. A 4096² upload is 40–45 ms. The image element stays the texture's image (a restored context uploads from it), and only the skin's maps, which the CPU reads under decals, are decoded ahead as elements.
  • Uploads stream, a few per frame. A part's maps start uploading as soon as it is built, each turn just after a frame in a task of its own, never inside the frame's own draw.
  • Prepared before placing (prepareDetails, character-renderer.ts): the remaining maps, the layered stacks' bakes, the rest-pose bounds of each skinned mesh (Three otherwise skins every vertex on the CPU in the first frame to sort it: 85 ms for a large hairstyle), and every program compiled for each pass the scene draws the part in (forward into the display's target, the skin scatter's input variants, the contact shadows' caster depth) with compileAsync over KHR_parallel_shader_compile; once the GPU has drained (a fence), each program's first use is taken between frames. The shown V keeps drawing meanwhile; a newer change stops it (xfs:character:prepared marks its end).
  • Loaded ahead on hover. With a Character row open, a prepared choice the pointer rests on for 150 ms has its parts loaded and prepared into the part pool, asked of the host as another page (X-XFS-Page: <page>-ahead), so the person's own V is never superseded and nothing is prepared that the host hadn't prepared already; the person's own change stops it at once.
Hairstyle switch (default V, host warm)BeforeAfter
Prepared, new to the page (4096² maps, a program new to the page)1.28–1.36 s, one 0.84–0.89 s task0.68–0.91 s, no task over 50 ms
Prepared, new to the page (a CCXL hairstyle, 10 MB geometry)1.36 s, tasks of 0.71 s and 78 ms0.80–0.85 s, no task over 50 ms
Prepared, new to the page (2048² maps), after the host's answer0.28 s, one 135–137 ms task0.21–0.23 s, no task over 50 ms
Prepared, the pointer rested on it 0.4 s–77–86 ms (a load ahead still under way at the click: 0.26–0.47 s)
Shown before on this page71–85 ms73–89 ms
Never preparedhost 0.4–4.2 s, then the abovethe same host time, then the above: layer masks still went to WolvenKit (3.3–3.8 s), native texture decodes ran one at a time (0.1–0.3 s each). Since PREV-190 (masks native, two decode workers): host 0.18–1.16 s with no WolvenKit launch, click to frame 0.34–2.3 s

Prepared files: what persists, and when it is prepared again. A choice is shown ready when a later preparation of it would need no WolvenKit. Everything prefetch writes is keyed so that a changed source is never served:

WhatWhereKeyed byInvalidated when
Extracted JSONresolver cache json/depot hash, archive path + size + modification time, WolvenKit identitythe archive changes (another key), another WolvenKit
Which resources the native reader answered (empty markers, no content)resolver cache native/depot hash, archive path + size + modification time, native reader identity (NATIVE_READER_VERSION, RTTI slice and learned defaults, Oodle library)the archive changes, another reader or game library
Exports (GLB, PNG, mask layers), partial and settled markerspreview cache exports/resources/depot hash, archive path + size + modification time, exporter version, WolvenKit identitythe same
What a prepared request depended on (xfs/choice-manifest-1)preview cache choices/the route (settings, WolvenKit path and identity) and the requestchecked, not trusted: see below
The preparation cache (resolved appearances, templates, exports, built parts)memoryinstallation fingerprint (route, stamps, WolvenKit, registry generation)a new installation or process: rebuilt from the files above without WolvenKit
Served files and recordspreview cache characters/content (SHA-256)never stale; removed only by Clear

A manifest (choice-manifest.ts) lists every resource the preparation read (the depot hash, the archive entry after ArchiveXL copies and links, and that archive), every export it served, the WolvenKit identity and the identity of the route's .xl files. It holds when, on the installation opened now, WolvenKit and every .xl file are the same, each read still resolves to the same entry in the same archive and is answered without WolvenKit for that archive's current identity (in the resolver's JSON cache, or answered natively by the same reader, native/), and each export's archive still wins its path and is in the export cache. So a mod updated in place (its archive's size or time changes), a mod installed or reordered so another archive wins, a changed .xl or an evicted file makes the choice "not prepared" again, and preparing it reads only what changed. A choice's manifest carries its batch's reads together, which can only make it checked again more often. Loose files are not read by the character details (the resolver reads archives and .xl files only; .xl files are fingerprinted by the route's watch list). [test] tests/choice-prefetch.test.ts: a restart keeps a prepared choice ready; another archive time or size, another .xl or an evicted export does not.

Budget and Clear (prepared-files.ts). Exports and extracted JSON are kept within 8 GB together, the least recently used first (a cache hit or write stamps its entry's time; eviction runs at most once a minute after a preparation or a prefetch batch); nothing this process used is evicted, so a V on screen and its tried choices keep their files even over the budget. Failure markers, archive indexes and creator texts are small and kept. The Character panel shows the prepared files' size on this computer and Clear prepared game files (character.clearPreparedFiles), which stops preparing ahead, waits for a running preparation and a stopped batch to finish, removes exports, extracted JSON, the served store, the manifests and the live face preview's cache (facial/: the face rig, setup and animation sets as JSON and the installed expressions; it keeps itself within 768 MB), and forgets what was derived from them, so the next preparation reads the game files again. The size shown counts the facial cache too. On the reference machine before this work the caches held about 1.35 GB (0.48 GB exports, 0.55 GB JSON, 0.32 GB store).

7. Store editions: where the game is, and which copies can be modded

Everything above starts from a game folder. Which store installed it changes only how that folder is found, not how files load.

StoreWhere the install is recordedXF StudioEvidence
GOGHKLM\SOFTWARE\(WOW6432Node\)GOG.com\Games\<product> pathDetected[observed] on the development machine, plus fixtures
SteamSteamPath/InstallPath, then libraryfolders.vdf, then appmanifest_1091500.acf installdir in any libraryDetectedFixtures only; formats observed without the game
Epic Games Store.item manifests (InstallLocation, bIsIncompleteInstall) and LauncherInstalled.datDetectedFixtures only; formats observed with other apps
Xbox app / Microsoft Store.GamingRoot library folders (<drive>\XboxGames\<title>\Content by default), Gaming Services package listRecognised, never offeredFixtures only
  • Frameworks install the same way on Steam, GOG and Epic. RED4ext and ArchiveXL are extracted into the game folder, and RED4ext recognises the game by the executable's ProductName and the game's own cyberpunk2077_addresses.json, not by store. source RED4ext src/dll/Image.cpp, src/dll/Addresses.cpp.
  • No framework mentions an Xbox app edition, and there isn't one for Windows. Microsoft's store lists Cyberpunk 2077 for Xbox consoles and cloud only, and CD PROJEKT RED's REDmod and support pages name only Steam, GOG and Epic. source Citations, clone commits and the message XF Studio shows are in store coverage.
  • Framework versions and launch routes do not depend on the store. The version check reads files under the chosen folder (and the MO2 profile), whichever store installed it.

Open questions

  1. REDengine's lookup across and within archive groups (mod over base, EP1 over content, content-internal order, collation). Decisive evidence: a version-matched read of ResourceDepot lookup, or a runtime probe logging which archive served a hash.
  2. Does the game mount archives in archive/pc/mod subfolders, and where do REDmod groups sit relative to archive/pc/mod?
  3. How does the game treat an archive absent from a visible modlist.txt?
  4. The order in which ArchiveXL enumerates .xl files under MO2's VFS.
  5. How does the engine treat a property stored with an outdated type (a castShadows field stored as Bool)? WolvenKit refuses such a resource; the Studio reads the value as stored and notes it. And does the engine read an array record past its count (a mesh's renderLODs), as WolvenKit shows it?

In-game test asks

  1. Mod over base, one resource. With the reference profile, compare the lash colour with Alliekat's archive enabled and disabled (same save, same camera). A visible change confirms mod-over-base for brown_liquorice.hp.
  2. Mod-versus-mod order. In a throwaway MO2 profile, add a separate mod holding a copy of one PRC item archive under a name that sorts after PRC_z_999_Framework_128.archive, and disable the original item mod: the ring should disappear. Confirms first-alphabetical-wins without touching the installed mods.
  3. First-run detection on a Steam copy (community). A Steam user with the game in a second Steam library (not the drive Steam itself is on) starts XF Studio for the first time, with no game folder chosen. Expected: the 3D preview card says "We found Cyberpunk 2077 at …" naming that library's steamapps\common\Cyberpunk 2077, and Use this folder makes the framework check read that folder. Record the card text (with the user folder replaced by a placeholder) and the XF Studio version. The same run on an Epic copy would move Epic from fixture-only to real-machine evidence.

CC file chain · Archive and resource formats · Vortex · Source discovery · MO2 source resolution · Brown liquorice collision · Resolver validation

Corrections and credit

This page is generated from knowledge/mod-loading.md in the repository. If something here is wrong or missing, please open an issue or suggest an edit; links to sources, captures or tests help most.

It builds on the Cyberpunk 2077 modding community’s work. The inline citations link to the wiki pages, mods, tools and repositories each finding came from, and the credits acknowledge everyone we learned from. Citing a source does not mean its authors endorse these notes.