Hot swap
Example apps / hot-swap / v0.1.1
Overlay apparchives permissionneeds rednode
Swap an archive into the running game, sound mods included: type its name or path, press Swap, and swap it again from the list next time you build it.
Nothing is swapped on this page: it checks what the host checks and pretends. Its mod folder holds Plain.archive and MyJacket.archive.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hot swap</title>
<script src="rednode://ui/rednode-ui.js"></script>
<script type="importmap">{ "imports": { "rednode": "rednode://sdk/index.js" } }</script>
<style>
/* Hot swap (REDnode Sound Tools): an archive into the running game through REDnode (REDswap),
sound archives included; recent swaps kept to swap again. The window never scrolls. */
html, body { margin: 0; width: 680px; height: 520px; overflow: hidden; background: transparent; }
rn-window { position: absolute; inset: 6px; }
.bar { display: flex; gap: 8px; align-items: center; flex: none; }
.bar rn-input { flex: 1; min-width: 0; }
.line { margin: 0; flex: none; font: 12px var(--rn-mono); color: var(--rn-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line.bad { color: var(--rn-danger); }
.result { flex: none; min-height: 40px; margin: 0; padding: 8px 10px; font: 12px/1.5 var(--rn-mono); color: var(--rn-dim); white-space: pre-wrap; overflow-wrap: anywhere;
border: 1px solid var(--rn-line); border-left: 3px solid var(--rn-line); }
.result.good { color: var(--rn-fg); border-left-color: var(--rn-accent-2); }
.result.bad { color: var(--rn-danger); border-left-color: var(--rn-danger); }
h3 { margin: 4px 0 0; flex: none; font: 700 12px var(--rn-display); letter-spacing: .12em; text-transform: uppercase; color: var(--rn-accent); }
.list { flex: 1; min-height: 0; overflow-x: hidden; overflow-y: auto; font: 12px var(--rn-mono); border: 1px solid var(--rn-line); }
.row { display: flex; align-items: center; gap: 6px; padding: 3px 6px; color: var(--rn-fg); }
.row:nth-child(even) { background: color-mix(in srgb, var(--rn-panel-2) 45%, transparent); }
.row:hover { background: color-mix(in srgb, var(--rn-accent) 14%, transparent); }
.t { flex: none; color: var(--rn-dim); }
.n { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.n small { color: var(--rn-dim); font-size: 11px; margin-left: 6px; }
.tag { flex: none; font-size: 11px; color: var(--rn-dim); }
.tag.ok { color: var(--rn-accent-2); }
.tag.bad { color: var(--rn-danger); }
.row button { flex: none; width: 22px; height: 18px; padding: 0; font: 11px var(--rn-mono); cursor: pointer;
color: var(--rn-accent-2); background: transparent; border: 1px solid color-mix(in srgb, var(--rn-accent-2) 50%, transparent); }
.row button:hover:not(:disabled) { background: color-mix(in srgb, var(--rn-accent-2) 18%, transparent); }
.row button:disabled { opacity: .3; cursor: default; }
.empty { padding: 12px 10px; color: var(--rn-dim); font: 12px var(--rn-mono); line-height: 1.6; }
.note { margin: 0; flex: none; font: 12px/1.45 var(--rn-mono); color: var(--rn-dim); }
</style>
</head>
<body>
<rn-window id="win" title="Hot swap" draggable closable>
<p id="needs" class="line">Checking what Hot swap needs...</p>
<div class="bar">
<rn-input id="file" placeholder="MyMod.archive in archive/pc/mod, or its full path; Enter swaps"></rn-input>
<rn-button id="swap">Swap</rn-button>
</div>
<p id="result" class="result">Swap an archive into the game while it runs: a new one, or a newer build of one you have installed.</p>
<h3>Recent</h3>
<div class="list" id="recent"></div>
<p class="note">Sound archives: the game pauses for a moment while REDnode loads its sound index again. A sound that played just before the swap can keep its old version until the game loads it again (for a weapon, equip it again).</p>
</rn-window>
<script type="module">
import { archives, kernel, ui } from 'rednode';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
const two = (n) => String(n).padStart(2, '0');
const clock = (t) => {
const d = new Date(t);
return `${two(d.getHours())}:${two(d.getMinutes())}`;
};
// Windows' Copy as path puts quotes around the path: they are not part of it.
const clean = (s) => String(s).trim().replace(/^"(.*)"$/, '$1');
const baseName = (p) => p.split(/[\\/]/).pop();
// ---- what it needs: REDnode new enough (0.41.0 installs REDswap, which swaps) ----
(async () => {
try {
const missing = (await kernel.requirements()).filter((r) => !r.ok);
$('needs').textContent = missing.length
? `Missing: ${missing.map((r) => `${r.name} ${r.wants} (${r.note || r.found || 'not installed'})`).join(', ')}`
: 'Ready: REDnode swaps through REDswap.';
$('needs').className = missing.length ? 'line bad' : 'line';
} catch (err) {
$('needs').textContent = err.message;
$('needs').className = 'line bad';
}
})();
// ---- recent swaps, kept in this window's local storage ----
const KEY = 'rednode.hot-swap.recent.v1';
const KEEP = 30;
let recent = [];
try {
recent = JSON.parse(localStorage.getItem(KEY) ?? '[]');
} catch {
recent = [];
}
function keep() {
try {
localStorage.setItem(KEY, JSON.stringify(recent));
} catch {
// the list still works for this session
}
draw();
}
function draw() {
const list = $('recent');
if (!recent.length) {
list.innerHTML = '<div class="empty">Your swaps show here, newest first. ↻ swaps one again: build your mod, press ↻, and see it in the game.</div>';
return;
}
list.innerHTML = recent
.map((r, i) => {
const dir = r.file.slice(0, r.file.length - baseName(r.file).length);
return (
`<div class="row" data-i="${i}">` +
`<span class="t">${clock(r.at)}</span>` +
`<span class="n" title="${esc(r.file)}">${esc(baseName(r.file))}${dir ? `<small>${esc(dir)}</small>` : ''}</span>` +
`<span class="tag ${r.ok ? 'ok' : 'bad'}" title="${esc(r.text)}">${r.ok ? (r.sound ? 'swapped, sound' : 'swapped') : 'failed'}</span>` +
`<button data-act="again" title="Swap it again">↻</button>` +
`<button data-act="copy" title="Copy its path">⧉</button>` +
`<button data-act="remove" title="Take it off the list">✕</button></div>`
);
})
.join('');
}
$('recent').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-act]');
if (!b || b.disabled) return;
const r = recent[Number(b.closest('.row').dataset.i)];
if (!r) return;
if (b.dataset.act === 'again') swap(r.file);
else if (b.dataset.act === 'copy') ui.copy(r.file).catch(() => {});
else if (b.dataset.act === 'remove') {
recent = recent.filter((x) => x !== r);
keep();
}
});
// ---- swapping ----
let busy = false;
function show(text, tone) {
$('result').textContent = text;
$('result').className = `result ${tone}`;
}
async function swap(raw) {
const file = clean(raw ?? $('file').value);
if (!file || busy) return;
busy = true;
$('swap').setAttribute('disabled', '');
show(`Swapping ${baseName(file)}...`, '');
let entry;
try {
const r = await archives.reload([file]);
const lines = [];
if (r.reloaded.length) lines.push(`${baseName(file)} swapped into the game in ${r.ms} ms.`);
for (const f of r.failed) lines.push(`Not swapped: ${f.why}`);
if (r.sound) {
lines.push(r.sound.index === 'reloaded' ? 'The game\'s sound index was loaded again: the new sounds play now.' : `Sound index: ${r.sound.index}.`);
if (r.sound.note) lines.push(r.sound.note);
}
const ok = r.reloaded.length > 0 && !r.failed.length;
show(lines.join('\n'), ok ? 'good' : 'bad');
entry = { file, at: Date.now(), ok, sound: !!r.sound, text: lines.join(' ') };
} catch (err) {
show(err.message, 'bad');
entry = { file, at: Date.now(), ok: false, sound: false, text: err.message };
} finally {
busy = false;
$('swap').removeAttribute('disabled');
}
recent = [entry, ...recent.filter((x) => x.file.toLowerCase() !== file.toLowerCase())].slice(0, KEEP);
keep();
}
$('swap').addEventListener('click', () => swap());
$('file').addEventListener('submit', () => swap());
draw();
</script>
</body>
</html>
import { archives, kernel, ui } from 'rednode';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
const two = (n) => String(n).padStart(2, '0');
const clock = (t) => {
const d = new Date(t);
return `${two(d.getHours())}:${two(d.getMinutes())}`;
};
// Windows' Copy as path puts quotes around the path: they are not part of it.
const clean = (s) => String(s).trim().replace(/^"(.*)"$/, '$1');
const baseName = (p) => p.split(/[\\/]/).pop();
// ---- what it needs: REDnode new enough (0.41.0 installs REDswap, which swaps) ----
(async () => {
try {
const missing = (await kernel.requirements()).filter((r) => !r.ok);
$('needs').textContent = missing.length
? `Missing: ${missing.map((r) => `${r.name} ${r.wants} (${r.note || r.found || 'not installed'})`).join(', ')}`
: 'Ready: REDnode swaps through REDswap.';
$('needs').className = missing.length ? 'line bad' : 'line';
} catch (err) {
$('needs').textContent = err.message;
$('needs').className = 'line bad';
}
})();
// ---- recent swaps, kept in this window's local storage ----
const KEY = 'rednode.hot-swap.recent.v1';
const KEEP = 30;
let recent = [];
try {
recent = JSON.parse(localStorage.getItem(KEY) ?? '[]');
} catch {
recent = [];
}
function keep() {
try {
localStorage.setItem(KEY, JSON.stringify(recent));
} catch {
// the list still works for this session
}
draw();
}
function draw() {
const list = $('recent');
if (!recent.length) {
list.innerHTML = '<div class="empty">Your swaps show here, newest first. ↻ swaps one again: build your mod, press ↻, and see it in the game.</div>';
return;
}
list.innerHTML = recent
.map((r, i) => {
const dir = r.file.slice(0, r.file.length - baseName(r.file).length);
return (
`<div class="row" data-i="${i}">` +
`<span class="t">${clock(r.at)}</span>` +
`<span class="n" title="${esc(r.file)}">${esc(baseName(r.file))}${dir ? `<small>${esc(dir)}</small>` : ''}</span>` +
`<span class="tag ${r.ok ? 'ok' : 'bad'}" title="${esc(r.text)}">${r.ok ? (r.sound ? 'swapped, sound' : 'swapped') : 'failed'}</span>` +
`<button data-act="again" title="Swap it again">↻</button>` +
`<button data-act="copy" title="Copy its path">⧉</button>` +
`<button data-act="remove" title="Take it off the list">✕</button></div>`
);
})
.join('');
}
$('recent').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-act]');
if (!b || b.disabled) return;
const r = recent[Number(b.closest('.row').dataset.i)];
if (!r) return;
if (b.dataset.act === 'again') swap(r.file);
else if (b.dataset.act === 'copy') ui.copy(r.file).catch(() => {});
else if (b.dataset.act === 'remove') {
recent = recent.filter((x) => x !== r);
keep();
}
});
// ---- swapping ----
let busy = false;
function show(text, tone) {
$('result').textContent = text;
$('result').className = `result ${tone}`;
}
async function swap(raw) {
const file = clean(raw ?? $('file').value);
if (!file || busy) return;
busy = true;
$('swap').setAttribute('disabled', '');
show(`Swapping ${baseName(file)}...`, '');
let entry;
try {
const r = await archives.reload([file]);
const lines = [];
if (r.reloaded.length) lines.push(`${baseName(file)} swapped into the game in ${r.ms} ms.`);
for (const f of r.failed) lines.push(`Not swapped: ${f.why}`);
if (r.sound) {
lines.push(r.sound.index === 'reloaded' ? 'The game\'s sound index was loaded again: the new sounds play now.' : `Sound index: ${r.sound.index}.`);
if (r.sound.note) lines.push(r.sound.note);
}
const ok = r.reloaded.length > 0 && !r.failed.length;
show(lines.join('\n'), ok ? 'good' : 'bad');
entry = { file, at: Date.now(), ok, sound: !!r.sound, text: lines.join(' ') };
} catch (err) {
show(err.message, 'bad');
entry = { file, at: Date.now(), ok: false, sound: false, text: err.message };
} finally {
busy = false;
$('swap').removeAttribute('disabled');
}
recent = [entry, ...recent.filter((x) => x.file.toLowerCase() !== file.toLowerCase())].slice(0, KEEP);
keep();
}
$('swap').addEventListener('click', () => swap());
$('file').addEventListener('submit', () => swap());
draw();
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "hot-swap",
"name": "Hot swap",
"version": "0.1.1",
"author": "Edgemodders",
"description": "Swap an archive into the running game, sound mods included: type its name or path and press Swap. Keeps your recent swaps to swap again in one press. Part of REDnode Sound Tools.",
"entry": "index.html",
"kind": "window",
"width": 680,
"height": 520,
"anchor": "top-left",
"x": 160,
"y": 130,
"open": false,
"permissions": [
"archives"
],
"requires": {
"rednode": ">=0.41.0"
}
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
REDswap, which REDnode 0.41.0 installs, does the swap. REDnode hands it the file, and answers with what was swapped and what failed.
An archive with the game's sound paks swaps too: REDnode pauses the game, holds its audio, and loads the game's sound index again afterwards. A sound that just played may keep its old version until the game loads it again.
Swapping changes the game's archives, so the package asks for the archives permission and the player allows it in the Mod Manager. Its manifest also requires a REDnode new enough, and the window says what is missing.
Recent swaps are kept in the window's local storage, so ↻ swaps the same file again after your next build, in this session or the next.
Part of REDnode Sound Tools.
What it uses
archives.reload "archives" permission requires kernel.requirements localStorage in an app
Try this
- Type Plain.archive and press Swap.
- Type Nope.archive and press Swap: the host refuses a file it cannot find, and the list marks it failed.
- Press ↻ on Plain.archive to swap it again.
Use it
Copy the files into red4ext/plugins/REDnode/apps/hot-swap/ in the game folder, or start your own package from them: change id and name in the manifest first. The whole folder is on GitHub.