Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Creator tooling can [inventory an installed MO2 setup and compare original archi

Players can now use **Discover → Open .loadstone** for [guided native installation](docs/native-guided-install.md): select owned games, prepared prerequisites and original archives without creating an inputs JSON. Downloads shows measured progress, cancellation and resume, then offers explicit review of the completed folder for My library. This uses the same builder and verification rules as the creator tools. Draft selections last until app restart; gameplay and provider/client acceptance remain open.

The [real TTW acceptance record](docs/native-steam-acceptance.md) now includes direct launch to the main menu and a verified Steam game-recording export after a normal Steam restart. Gameplay, save/reload, controller and clean-machine acceptance remain unfinished. See [Steam startup guidance](docs/native-library.md#steam-game-startup) for the observed load error and recovery steps.

[Manual Nexus downloads](docs/native-manual-downloads.md) open each declared file page in the default browser and verify the downloaded original before selection, with byte progress and cancellation. Browser mode tracks pending files and requires all Nexus selections before installation review; existing cache files can be explicitly checked too. Automatic Nexus downloads still require fresh Premium access. Real account/website acceptance and public API registration remain open.

## Persistence and desktop behavior
Expand Down
2 changes: 2 additions & 0 deletions docs/installed-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Neither mode is an atomic filesystem snapshot. File hashing rejects changes whil

The report is **not installable and not publication-approved**. Even with complete hashes for the included files, it does not establish upstream game equivalence, archive contents/conflict resolution, runtime compatibility, source-download reproducibility or redistribution rights. Saves are not read from the profile. No network request or program launch is required to inspect an instance.

MO2 instance plugin inventories also exclude CPython `__pycache__` files when their adjacent ordinary `.py` source is present. Each receives the explicit `regenerable-python-bytecode` reason; the source remains inventoried. Standalone bytecode, caches without a source, unknown cache naming and mod/game content remain included. This avoids pinning machine-dependent cache bytes that MO2 regenerates after relocation. Existing inventories, recipes and verification fingerprints are unchanged; use a new reviewed recipe/version to apply this boundary.

## Private source ledger

```powershell
Expand Down
6 changes: 6 additions & 0 deletions docs/native-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ The review shows the selected folder, profile, output count and the recipe autho

The full comparison is a point-in-time result. Registration rechecks metadata and the launch-critical files after review; launching does not rehash every mod file. Close other MO2/game windows yourself, including windows started outside Loadstone.

### Steam game startup

For a Steam edition, keep the owning Steam client running before **Launch native setup**. If New Vegas displays `Application load error 5:0000065434`, close the game and its MO2 instance, exit Steam normally, reopen Steam, then retry the same setup from Loadstone. A normal client restart resolved this error in our [isolated TTW menu test](native-steam-acceptance.md#direct-launch-after-a-normal-steam-restart). The [Viva New Vegas FAQ](https://vivanewvegas.moddinglinked.com/faq.html) also describes launching the owned game once through Steam as another recovery step. Launching the owned game that way is separate from launching the isolated modded setup.

If the error persists, retain the setup and record the exact error, game edition and Loadstone build before further diagnosis. A full reconstruction or a changed app ID is not established as a remedy by this test. Loadstone does not automatically restart Steam or change its registration. Reaching the menu and verifying a recording still leave new-game, save/reload and controller acceptance outstanding.

## Shortcuts and local state

Desktop and Steam buttons create or repair shortcuts to the candidate's manager and exact profile. Creation checks the same launch layout. Desktop links use the registered candidate ID as an ownership marker. Steam preserves unrelated entries and existing owned app IDs, backs up its shortcut file, and requires Steam to be completely closed. Choose the intended local account before saving.
Expand Down
28 changes: 28 additions & 0 deletions docs/native-steam-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,31 @@ The source now supports explicit, target-matched Steam app IDs while preserving
Both actual game attempts ended at the Steam error. No new recording appeared, no clip could be exported, and no game/save/controller acceptance is claimed. The microphone remained off and the prior recording audio source was restored. All 31 protected original/previous-candidate profile, configuration and save files remained unchanged. New-candidate profile/configuration writes were backed up; no save was created or loaded. The existing portable/depot preview retains its older provenance.

Evidence for this pass is ignored under `test-results/native-acceptance/ttw-edition/`. Next work must resolve the direct Steam initialization failure and separately retest a fresh recipe with all required declarations, then verify actual game footage before longer play. The previous successful Steam-shortcut menu launch does not establish direct-launch or recording success for this candidate.

## Direct launch after a normal Steam restart

At `2026-10-14T02:08Z`, the same `2026.10.13-edition` candidate reached the TTW main menu directly from **My library → Launch native setup**, without the edition chooser or Steam load error. The application was the clean r4 unpacked build from `68b6951c9398bbd7071eeea3fbe6d7eea9c4d550` (merged as `e2b09a818640d520648f9b87075cea1e33fd3280`, identical tree `29d1feb0ca24bcfaa1ab5d8ec7f455c02dd7c613`). It used the existing isolated app data and unchanged recipe fingerprint above.

Before the retry, a read-only check found that Steam's registered active process referred to a process that no longer existed, while another Steam client process was running. Exiting Steam normally and reopening the installed client made the registration match its running process. No registry value, game binary, recipe, receipt or candidate configuration was edited for this retry; the earlier temporary app-ID edit had already been restored. The selected game/Steam API/xNVSE binaries matched the installed baseline. The actual process chain was Loadstone → candidate-local MO2 with the declared profile/title → candidate-local FalloutNV.

This is evidence that a normal client restart resolved this particular direct-launch failure. It is not proof that stale registration explains every `5:0000065434` error, nor a reason to add a hard launch requirement based on an undocumented registry field. The [Viva New Vegas FAQ](https://vivanewvegas.moddinglinked.com/faq.html) also lists restarting Steam/MO2 among recovery steps for that error (checked October 14, 2026).

### Recording and session limits

Steam's background recording was confirmed in its UI before launch: all games, a known output folder, microphone off and game-only audio. A new app-22380 video session and 30-second clip were produced. The clip played in Steam's recording viewer, displaying the TTW menu at multiple playback positions, before any attempt to start a new character.

The clip was exported locally to MP4. Independent playback decoded frames at 1, 14 and 28 seconds and reached the end without a media error; the decoded 14-second frame was visually inspected. Export: 30.001667 seconds, 2560 × 1440, 62,966,756 bytes, SHA-256 `a0d6a7df8f8d29ecf77a9d31bde12fbaf8c890bcc1f8d6a90d4248c370aaa1d3`. The original clip segments, full background session and MP4 are retained under the platform's ignored `publication/media/raw/ttw-2026-10-14/`. Nothing was uploaded or added to promotional assets.

Automated mouse/keyboard input did not reliably navigate from the menu into New. No new-game confirmation, gameplay, save creation or save loading was observed. This is an automation limitation encountered during the test, not evidence that human controls are broken. The game and manager exited after Alt+F4. All 31 protected original/previous-candidate files and all 12 new-candidate profile files retained their hashes; the candidate saves folder remained empty. Steam's previous **All System Audio** preference was restored and microphone remained off.

Private preparation, process, profile, recording and decoded-playback evidence stays in ignored `test-results/native-acceptance/ttw-steam-context/`. This pass establishes direct launch to the menu and actual game-video capture. A recorded disposable new game, save/reload, human keyboard/mouse/controller checks, mod behavior, maintenance after gameplay, the revised candidate's Steam/Desktop shortcuts and clean-machine/provider installation remain open. The earlier shortcut recording-identity problem was not retested or declared fixed.

### Verification after MO2 ran

The actual r4 library's subsequent **Verify recipe files** failed and retained the previous successful verification timestamp. A complete read-only comparison of all 13,951 static outputs found 13,732 exact matches and 219 differences: 217 larger CPython plugin caches under `__pycache__`, plus two profile-order files. Every adjacent `.py` source for those caches was still present in the recipe and matched its recorded SHA-256. The regenerated caches were retained, not restored to force a passing check.

`loadorder.txt` and `plugins.txt` already differed from the recipe before this session and retained those same hashes afterward. Each contains the same 35 plugin names as the original, with 17 positions changed; this is ordering drift, not a missing-plugin or line-ending difference. The original installed profile still matches the recipe. No new profile change or game-save creation occurred in this session. The cache exclusion does not resolve this earlier order drift: its cause and compatibility with the installed baseline remain a separate acceptance gate. Do not silently accept the changed order or modify the original profile.

Source now excludes those regenerable instance-plugin caches from future inventories when their ordinary source exists, while retaining source-less/standalone bytecode and all mod/game content. Existing recipes still require their declared bytes and therefore still report this drift. The verifier now identifies the relative recipe file when its ordinary-file/size check fails, without exposing the absolute installation path. A fresh reviewed recipe and separate reconstruction must exercise the new inventory boundary before post-launch verification can be certified. The r4 executable and source ZIP remain unchanged.

A fresh metadata inventory of the original TTW installation excludes the same 217 regenerable caches and retains 1,080 instance files, 279 game files and 12 selected-profile files. This is a read-only boundary check, not a fresh full-hash baseline or rebuilt recipe. Regression checks cover preserved sources and bytecode-only content, source-change detection, strict existing-recipe verification, unchanged verification timestamps after failure and the relative error displayed through the actual Electron bridge. The full local suite passed 184 tests with one optional skip; Svelte checks reported zero errors and warnings.
39 changes: 35 additions & 4 deletions electron/installed-inventory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,41 @@ async function inspectMo2(root, options = {}) {
throw new Error(
'Installation inventory requires distinct instance and configured collection roots.',
);
const instanceFiles = await scan(root, 'instance', (relative) => {
const full = path.join(root, relative);
return separated.find(([folder]) => contains(folder, full))?.[1];
});
const instanceFiles = await scan(
root,
'instance',
(relative, isDirectory) => {
const full = path.join(root, relative);
const separate = separated.find(([folder]) =>
contains(folder, full),
)?.[1];
if (separate) return separate;
// MO2 recompiles Python plugin caches after relocation. Keep the source
// and bytecode-only plugins; exclude only caches with an ordinary source.
const parts = relative.replaceAll('\\', '/').split('/');
if (
!isDirectory &&
parts.length >= 3 &&
parts[0].toLowerCase() === 'plugins' &&
parts.at(-2).toLowerCase() === '__pycache__'
) {
const cache = parts
.at(-1)
.match(/^(.+)\.cpython-\d+(?:\.opt-\d+)?\.pyc$/i);
if (cache) {
const source = path.join(
path.dirname(path.dirname(full)),
`${cache[1]}.py`,
);
if (fs.existsSync(source)) {
ordinary(source);
return 'regenerable-python-bytecode';
}
}
}
return null;
},
);
const gameFiles = await scan(game, 'game');
const profileFiles = await scan(profile, 'profile');
installation = { schemaVersion: 1, instanceFiles, gameFiles, profileFiles };
Expand Down
9 changes: 8 additions & 1 deletion electron/native-library.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,15 @@ async function verifyCandidate(root, options = {}) {
for (const output of meta.recipe.outputs) {
options.signal?.throwIfAborted();
if (output.action.type !== 'mo2-config') {
const file = path.join(meta.root, output.path),
const file = path.join(meta.root, output.path);
let before;
try {
before = ordinaryFile(file, output.bytes);
} catch {
throw new Error(
`Recipe file is missing, larger than expected, redirected or not an ordinary file: ${output.path}`,
);
}
demand(
before.size === output.bytes,
`Recipe file size differs: ${output.path}`,
Expand Down
71 changes: 71 additions & 0 deletions tests/installed-inventory.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,77 @@ test('expanded inventory hashes disabled mods, game/tool files and full selected
);
});

test('instance inventory excludes regenerable Python plugin caches while retaining sources and bytecode-only content', async () => {
const f = fixture();
for (const folder of ['plugins/example/__pycache__', 'mods/High/__pycache__'])
fs.mkdirSync(path.join(f.root, folder), { recursive: true });
const caches = [
'plugins/example/__pycache__/tool.cpython-312.opt-2.pyc',
'plugins/example/__pycache__/tool.cpython-312.pyc',
];
const included = [
'plugins/example/tool.py',
'plugins/example/standalone.pyc',
'plugins/example/__pycache__/orphan.cpython-312.pyc',
'plugins/example/__pycache__/tool.pypy-312.pyc',
'plugins/example/__pycache__/payload.dat',
'mods/High/tool.py',
'mods/High/__pycache__/tool.cpython-312.pyc',
];
for (const file of [...caches, ...included])
f.write(file, `fixture: ${file}`);
const stream = fs.createReadStream;
fs.createReadStream = (file, ...args) => {
assert.equal(
caches.some((v) => path.resolve(file) === path.join(f.root, v)),
false,
);
return stream(file, ...args);
};
let report;
try {
report = await inspectMo2(f.root, {
hashFiles: true,
installationFiles: true,
});
} finally {
fs.createReadStream = stream;
}
const paths = [
...report.installation.instanceFiles.map((v) => v.path),
...report.mods.flatMap((mod) =>
(mod.files || []).map((v) => `mods/${mod.name}/${v.path}`),
),
];
for (const file of included) assert.ok(paths.includes(file), file);
for (const file of caches) {
assert.equal(paths.includes(file), false);
assert.ok(
report.exclusions.some(
(v) =>
v.scope === 'instance' &&
v.path === file &&
v.reason === 'regenerable-python-bytecode',
),
);
assert.equal(
fs.readFileSync(path.join(f.root, file), 'utf8'),
`fixture: ${file}`,
);
}
await assert.rejects(
inspectMo2(f.root, {
hashFiles: true,
installationFiles: true,
progress(value) {
if (value.phase === 'stability')
f.write('plugins/example/tool.py', 'changed source');
},
}),
/changed/,
);
});

test('final inventory guards reject added, removed and changed files or replaced trees', async () => {
for (const mutation of ['added', 'removed', 'changed', 'replaced']) {
const f = fixture();
Expand Down
23 changes: 23 additions & 0 deletions tests/native-library-electron.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,29 @@ test(
.getByRole('status')
.filter({ hasText: 'profile isolation verified' })
.waitFor();
const verifiedAt = (
await page.evaluate(() => window.loadstone.nativeLibrary())
).entries[0].verifiedAt;
const modlistFile = path.join(f.destination, 'profiles/Test/modlist.txt');
const modlist = fs.readFileSync(modlistFile);
fs.appendFileSync(modlistFile, '\nfixture mutation');
await page
.getByRole('button', { name: 'Verify recipe files', exact: true })
.click();
await page
.getByRole('alert')
.filter({
hasText:
'Recipe file is missing, larger than expected, redirected or not an ordinary file: profiles/Test/modlist.txt',
})
.waitFor();
assert.equal(
(await page.evaluate(() => window.loadstone.nativeLibrary())).entries[0]
.verifiedAt,
verifiedAt,
);
assert.match(fs.readFileSync(modlistFile, 'utf8'), /fixture mutation$/);
fs.writeFileSync(modlistFile, modlist);
fs.mkdirSync('test-results', { recursive: true });
await page.screenshot({ path: 'test-results/native-library.png' });
// Reject before spawn: no synthetic or real game executable is ever run.
Expand Down
7 changes: 6 additions & 1 deletion tests/native-library.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ test('native checks reject redirected saves, changed executables and unsafe prof
path.join(f.destination, 'profiles/Test/modlist.txt'),
'changed',
);
await assert.rejects(library.verify(record.id));
const verifiedAt = library.entries[0].verifiedAt;
await assert.rejects(
library.verify(record.id),
/Recipe file is missing, larger than expected, redirected or not an ordinary file: profiles\/Test\/modlist\.txt/,
);
assert.equal(library.entries[0].verifiedAt, verifiedAt);
assert.match(
fs.readFileSync(
path.join(f.destination, 'profiles/Test/modlist.txt'),
Expand Down
Loading