Skip to content

Instantly share code, notes, and snippets.

@Kreijstal
Last active April 26, 2026 22:47
Show Gist options
  • Select an option

  • Save Kreijstal/b06086e8d81dd42239750dbca939f34a to your computer and use it in GitHub Desktop.

Select an option

Save Kreijstal/b06086e8d81dd42239750dbca939f34a to your computer and use it in GitHub Desktop.
Paper.io 3D deobfuscation pipeline — reproducible reverse-engineering with OXC bug catalog

Paper.io 3D — Deobfuscation & Local Build

Reverse-engineering the Paper.io 3D browser game engine for local execution.

Project Structure

paperio/
├── deobfuscate.js          # Main pipeline: paper3d.js → paper3d_deob.js
├── unvendor.js             # Strips vendored deps from app.js → app_unvendored.js
├── apply_patches.js        # Applies .json patches from patches/
├── repl.js                 # Interactive AST REPL for pipeline debugging
├── oxc_bugs.js             # OXC bug reproduction suite
├── patches/                # Runtime bug fixes (JSON format)
│   └── anti_piracy.json    # Removes paperio.site redirect
├── html/                   # Serve this directory
│   ├── index.html          # Game page (hand-written, do NOT edit manually)
│   ├── config.json         # Game config from paperio.site/3d/
│   ├── rng.wasm            # RNG WebAssembly module (copied from study/)
│   ├── res/                # 3D model files (.gltf + .bin)
│   └── assets/             # languages.json, skins.json, config.json copies
├── js_files/
│   ├── paper3d.js          # Original obfuscated source (352 KB) — DO NOT EDIT
│   ├── paper3d.js.bak      # Backup of original
│   ├── paper3d_deob.js     # GENERATED OUTPUT of deobfuscate.js
│   ├── app.js              # Original app loader
│   └── app_unvendored.js   # Unvendored app (GSAP→CDN, Preact inline)
├── study/
│   ├── rng.wasm            # Compiled WASM binary (7637 bytes)
│   ├── rng.wat             # Human-readable WAT disassembly (74 KB)
│   └── paper3d_study.js    # Annotated study version of deobfuscated code
├── node_modules/           # Dependencies (npm install)
├── package.json
└── package-lock.json

Prerequisites

npm install

Requires:

  • Node.js ≥18
  • deob CLI tool (from @oxc-project/deob) on PATH
  • Browser with WebGL support

Deobfuscation Pipeline

One-shot command

node deobfuscate.js

This regenerates js_files/paper3d_deob.js from js_files/paper3d.js.

Pipeline stages

Step Tool What it does
1 vm sandbox Executes the obfuscated IIFE to capture the post-shuffle string array (2793 strings)
2 Babel Replaces every w(idx) call with the resolved string literal; removes dead shuffling code
3 OXC deob CLI Expression folding, dead code elimination, member expression simplification
4 Babel Cleans up leftover IIFE wrappers, dead const XX = Hj aliases
4b Babel AST (NEW) Extracts base64 WASM, converts to WAT via wabt, embeds WAT + runtime compiler

How the WASM fix works

The original obfuscated code stores the RNG WASM module as base64 in the shuffled string array. The Babel string-resolution step (2) replaces the byte-decoding calls with raw string literals, breaking the decoder. The fix:

  1. Extract the base64 string var O = "AGFzbQE..." from the AST
  2. Build-time: decode b64 → WASM binary → convert to WAT text via wabt
  3. Embed WAT as a JS template literal: var O = `(module ...)`
  4. Runtime: compile WAT→WASM using the wabt CDN library inside async function R

Result: human-readable WAT in the source instead of opaque base64 blob.

OXC Bugs

The OXC deob CLI (step 3) introduces several semantic bugs during expression folding and member simplification. These are cataloged in oxc_bugs.js and fixed either in the pipeline (step 4) or via .patch files.

# Bug Example Fix Status
1 Swap elimination var tmp=a; a=b; b=tmpa=b,b=a (no-op) Pipeline: rewrite to [a,b]=[b,a]
2 Optional chaining drop x?.["y"]?.["z"]x.y?.["z"] (drops 1st ?.) Patch: null_currstate.json
3 Value inlining var x="..."; x.charCodeAt(i) inlines the string everywhere Pipeline: WASM block replaced with WAT
4 Var reference inlining new Uint8Array(x)new Uint8Array("...") duplicates value Pipeline: surplus declarators filtered
5 void 0undefined Constant folding Harmless (strict mode)
6 0x4001024 Hex→decimal folding Harmless
7 obj["key"]obj.key Bracket→dot simplification Correct
8 Dead alias removal const XX = Hj removed Correct

Interactive REPL

node repl.js

Pre-loads paper3d_deob.js as a Babel AST with helpers:

Command Description
find("pattern") Grep source for regex, returns {line, text}[]
lines(n, ctx) Show ctx lines of source around line n
pp(node) Pretty-print AST node structure
parse("code") Parse a JS snippet → AST
gen(astNode) Generate code from AST node
nodesOfType("IfStatement") Find all nodes of a given type
ast Full AST of paper3d_deob.js
src Full source text
t @babel/types

Unvendoring app.js

node unvendor.js

Strips the bundled GSAP (greensock) from app.js and replaces it with a CDN <script> tag. Preact is kept inline (too small to warrant CDN overhead).

Output: js_files/app_unvendored.js

Patching

node apply_patches.js

Applies .json patches from patches/ to fix runtime bugs. Currently:

Patch What it does
anti_piracy.json Removes location.replace("https://paperio.site") from "Change Mode" button
null_currstate.json Fixes dropped ?. on this.currState (OXC bug #2)

Running the Game

# Start a local server (e.g. Python)
python3 -m http.server 9999 -d html/

Then open http://localhost:9999/index.html.

The HTML page includes:

  • Nickname input
  • Map/model selection
  • Play button
  • CDN scripts: GSAP, wabt (for WAT→WASM compilation)
  • paper3d_deob.js + app_unvendored.js

3D Models

7 real models downloaded from paperio.site/3d/:

  • res/cow.gltf + cow.bin
  • res/dragon.gltf + dragon.bin
  • res/fox.gltf + fox.bin
  • res/frog.gltf + frog.bin
  • res/pig.gltf + pig.bin
  • res/tiger.gltf + tiger.bin
  • res/tucano.gltf + tucano.bin

Remaining Known Bugs

These bugs exist in paper3d_deob.js. Fixes go in the pipeline (deobfuscate.js) or as .patch files — do NOT edit the generated file directly.

# Bug Line Status
1 Swap bug: HK = Hr, Hr = HK (OXC no-op swap) 3443 🟢 fixed in pipeline
2 Anti-piracy redirect: location.replace("paperio.site") in btnChangeMode app_unvendored.js:3737 🟢 patched
3 GPU upload timing: loaded=true set before GPU setData() 7427 🟡 needs investigation
4 Null currState guard: this.currState.Centr accessed without null check ~5335 🟢 may be fixed (now uses ?.)
5 Invalid face in getNeighborhood: getTriVerts(invalidFace) returns undefined ~6131 🟡 needs investigation
6 Invalid face in getTriData: called with face = -1 ~4393 🟡 needs investigation
{
"target": "js_files/app_unvendored.js",
"description": "Neuter anti-piracy redirect in btnChangeMode",
"replacements": [
{
"old": " location.replace(\"https://paperio.site\");",
"new": " /* patched: redirect removed */ ;"
}
]
}
#!/usr/bin/env node
/**
* Apply .json patch files from patches/ directory.
*
* Each patch file has:
* { "target": "path/to/file", "description": "...",
* "replacements": [ { "old": "exact text", "new": "replacement" } ] }
*
* Usage: node apply_patches.js
*/
const fs = require('fs');
const path = require('path');
const PATCH_DIR = path.join(__dirname, 'patches');
const ROOT = __dirname;
const files = fs.readdirSync(PATCH_DIR).filter(f => f.endsWith('.json'));
let applied = 0;
for (const f of files) {
const patch = JSON.parse(fs.readFileSync(path.join(PATCH_DIR, f), 'utf8'));
const target = path.join(ROOT, patch.target);
if (!fs.existsSync(target)) {
console.log(` skip ${f}: target "${patch.target}" not found`);
continue;
}
let code = fs.readFileSync(target, 'utf8');
let changed = false;
for (const r of patch.replacements) {
if (!code.includes(r.old)) {
console.log(` warn ${f}: pattern not found in ${patch.target}`);
console.log(` "${r.old.slice(0, 80)}..."`);
continue;
}
code = code.replace(r.old, r.new);
changed = true;
}
if (changed) {
fs.writeFileSync(target, code);
console.log(` ✓ ${f}: ${patch.description}`);
applied++;
}
}
console.log(`\n${applied} patch(es) applied.`);
#!/usr/bin/env node
/**
* paper3d.js deobfuscator — single-pass, reproducible pipeline
*
* Input: js_files/paper3d.js (obfuscated)
* Output: js_files/paper3d_deob.js
*
* Pipeline:
* 1. Sandbox-execute the original prefix to capture the post-shuffle string array
* 2. Babel: replace every w()-family call with the resolved string
* 3. OXC : deob CLI for expression folding, dead code, member simplification
* 4. Babel: remove the leftover shuffling init IIFE and dead w-var aliases
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { execSync } = require('child_process');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
const ROOT = __dirname;
const INPUT = path.join(ROOT, 'js_files', 'paper3d.js');
const TMP1 = path.join(ROOT, 'js_files', '._tmp_babel.js');
const TMP2 = path.join(ROOT, 'js_files', '._tmp_oxc.js');
const OUTPUT = path.join(ROOT, 'js_files', 'paper3d_deob.js');
// ─────────────────────────────────────────────────────────────────────────────
// STEP 1 – extract shuffled string array
// ─────────────────────────────────────────────────────────────────────────────
function extractStringArray(code) {
const zStart = code.indexOf('function z(){');
const arrayEnd = code.indexOf('];z=function(){return cL;};return z();}') +
'];z=function(){return cL;};return z();}'.length;
const wAndInitEnd = code.indexOf('}(z,0x3d53b));') + '}(z,0x3d53b));'.length;
const zFunc = code.substring(zStart, arrayEnd);
const wAndInit = code.substring(0, wAndInitEnd);
const sandboxCode = zFunc + ';\n' + wAndInit + ';\nglobalThis.__sa = z();';
const sandbox = { globalThis: {}, parseInt, RegExp, String, Function,
Array, Object, Error };
vm.createContext(sandbox);
vm.runInContext(sandboxCode, sandbox, { timeout: 10000 });
const arr = sandbox.globalThis.__sa;
if (!Array.isArray(arr)) throw new Error('Failed to capture string array');
return arr;
}
// ─────────────────────────────────────────────────────────────────────────────
// STEP 2 – Babel: resolve w() calls → string literals
// ─────────────────────────────────────────────────────────────────────────────
function resolveStringLookups(code, stringArray) {
const OFFSET = 0xfe;
const ast = parser.parse(code, { sourceType: 'script', errorRecovery: false });
// --- iteratively discover all w-var aliases ---
const wVars = new Set(['w']);
let changed = true;
while (changed) {
changed = false;
traverse(ast, {
VariableDeclarator(p) {
if (!p.node.id || !p.node.init) return;
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) &&
wVars.has(p.node.init.name) && !wVars.has(p.node.id.name)) {
wVars.add(p.node.id.name); changed = true;
}
},
AssignmentExpression(p) {
if (t.isIdentifier(p.node.left) && t.isIdentifier(p.node.right) &&
wVars.has(p.node.right.name) && !wVars.has(p.node.left.name) &&
p.node.operator === '=') {
wVars.add(p.node.left.name); changed = true;
}
},
});
}
// --- replace calls ---
let replaced = 0;
traverse(ast, {
CallExpression(p) {
const c = p.node.callee, a = p.node.arguments;
if (!t.isIdentifier(c) || !wVars.has(c.name)) return;
if (a.length !== 1 || !t.isNumericLiteral(a[0])) return;
const idx = a[0].value - OFFSET;
if (idx >= 0 && idx < stringArray.length) {
p.replaceWith(t.stringLiteral(stringArray[idx]));
replaced++;
}
},
});
// --- remove dead code (w/z functions, shuffling init, anti-debug Q/J) ---
const deadNodes = [];
traverse(ast, {
FunctionDeclaration(p) {
if (!p.node.id) return;
if ((p.node.id.name === 'w' && p.node.params.length === 2) ||
(p.node.id.name === 'z')) {
deadNodes.push(p);
}
},
ExpressionStatement(p) {
const ex = p.node.expression;
if (t.isCallExpression(ex) && t.isFunctionExpression(ex.callee) &&
ex.arguments.some(a => t.isIdentifier(a) && a.name === 'z')) {
deadNodes.push(p);
}
},
VariableDeclaration(p) {
if (p.node.declarations[0]?.id?.name === 'Q') deadNodes.push(p);
},
// remove `const VAR = w;` style declarations
VariableDeclarator(p) {
if (!p.node.id || !p.node.init) return;
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) &&
p.node.init.name === 'w') {
const d = p.parentPath;
deadNodes.push(d.node.declarations.length === 1 ? d : p);
}
},
});
// also remove J=Q(...) and J() at top level
traverse(ast, {
Program(p) {
const b = p.node.body;
for (let i = 0; i < b.length; i++) {
const s = b[i];
if (t.isExpressionStatement(s)) {
const ex = s.expression;
if ((t.isAssignmentExpression(ex) &&
t.isIdentifier(ex.left) && ex.left.name === 'J') ||
(t.isCallExpression(ex) &&
t.isIdentifier(ex.callee) && ex.callee.name === 'J')) {
b.splice(i, 1); i--;
}
}
}
},
});
deadNodes.sort((a, b) => (b.node?.start || 0) - (a.node?.start || 0));
for (const p of deadNodes) { if (!p.removed) p.remove(); }
const out = generate(ast, { compact: false, comments: false }).code;
return { code: out, replaced, wVarCount: wVars.size };
}
// ─────────────────────────────────────────────────────────────────────────────
// STEP 3 – OXC deob CLI
// ─────────────────────────────────────────────────────────────────────────────
function oxcDeob(inputPath, outputPath) {
execSync('deob ' + inputPath + ' -o ' + outputPath, {
stdio: 'pipe', timeout: 120000
});
return fs.readFileSync(outputPath, 'utf8');
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper – convert b64 WASM → WAT text (build time, using wabt)
// ─────────────────────────────────────────────────────────────────────────────
function wasmB64toWat(b64) {
const wasmBytes = Buffer.from(b64, 'base64');
const tmpWasm = path.join(ROOT, '._tmp_rng.wasm');
const tmpWat = path.join(ROOT, '._tmp_rng.wat');
const tmpScript = path.join(ROOT, '._tmp_watconv.js');
fs.writeFileSync(tmpWasm, wasmBytes);
fs.writeFileSync(tmpScript,
'const wabt = require("wabt");\n' +
'const fs = require("fs");\n' +
'const wasm = fs.readFileSync(process.argv[2]);\n' +
'const watPath = process.argv[3];\n' +
'wabt().then(m => {\n' +
' const r = m.readWasm(wasm, {});\n' +
' r.generateNames();\n' +
' r.applyNames();\n' +
' const t = r.toText({foldExprs:false,inlineExport:false});\n' +
' fs.writeFileSync(watPath, t);\n' +
' r.destroy();\n' +
'}).catch(e => { console.error(e); process.exit(1); });\n'
);
execSync('node ' + tmpScript + ' ' + tmpWasm + ' ' + tmpWat, {
stdio: 'pipe', timeout: 30000
});
const wat = fs.readFileSync(tmpWat, 'utf8');
fs.rmSync(tmpWasm); fs.rmSync(tmpWat); fs.rmSync(tmpScript);
return wat;
}
// ─────────────────────────────────────────────────────────────────────────────
// AST pass – replace embedded b64 WASM with inline WAT + runtime compilation
// ─────────────────────────────────────────────────────────────────────────────
function fixWasmInAst(ast, b64, watText) {
let targetBody = null; // the body array containing var O
let oDeclIdx = -1; // index of var O in that body
const garbageNodes = []; // nodes to remove after O
// Pass 1: find var O, replace its value, collect garbage
traverse(ast, {
VariableDeclarator(path) {
if (oDeclIdx >= 0) return;
if (!t.isIdentifier(path.node.id, { name: 'O' })) return;
if (!t.isStringLiteral(path.node.init)) return;
if (!path.node.init.value.startsWith('AGFzbQE')) return;
path.node.init = t.stringLiteral(watText);
// OXC merged: var O=..., E=..., x=..., X=new Uint8Array(x)
// Remove the E, x, X declarators, keep only O
const decl = path.parentPath.node;
if (t.isVariableDeclaration(decl) && decl.declarations.length > 1) {
decl.declarations = decl.declarations.filter(d =>
t.isIdentifier(d.id, { name: 'O' })
);
}
const body = path.parentPath?.parentPath?.node?.body;
if (body && Array.isArray(body)) {
targetBody = body;
oDeclIdx = body.indexOf(path.parentPath.node);
if (oDeclIdx >= 0) {
for (let i = oDeclIdx + 1; i < body.length; i++) {
const stmt = body[i];
// Match: ForStatement with v-loop init, or var v,L=X declaration
if (t.isForStatement(stmt)) {
const init = stmt.init;
// OXC folds the `var v = 0` into bare `v = 0` (AssignmentExpression)
if ((t.isVariableDeclaration(init) &&
init.declarations.length === 1 &&
t.isIdentifier(init.declarations[0].id, { name: 'v' })) ||
(t.isAssignmentExpression(init) &&
t.isIdentifier(init.left, { name: 'v' }))) {
garbageNodes.push(i);
continue;
}
}
if (t.isVariableDeclaration(stmt) &&
stmt.declarations.length === 2 &&
t.isIdentifier(stmt.declarations[0].id, { name: 'v' }) &&
t.isIdentifier(stmt.declarations[1].id, { name: 'L' }) &&
t.isIdentifier(stmt.declarations[1].init, { name: 'X' })) {
garbageNodes.push(i);
continue;
}
break;
}
}
}
path.stop();
}
});
// Pass 2: remove garbage (reverse order so indices stay valid)
if (targetBody && garbageNodes.length > 0) {
for (let i = garbageNodes.length - 1; i >= 0; i--) {
targetBody.splice(garbageNodes[i], 1);
}
// Adjust oDeclIdx if needed (removing items after it doesn't affect it)
}
// Pass 3: insert var X = null; var L = X; after var O
if (targetBody && oDeclIdx >= 0) {
targetBody.splice(oDeclIdx + 1, 0,
t.variableDeclaration('var', [
t.variableDeclarator(t.identifier('X'), t.nullLiteral())
]),
t.variableDeclaration('var', [
t.variableDeclarator(t.identifier('L'), t.identifier('X'))
])
);
}
// Pass 4: In async function R, insert WAT→WASM compilation before compile(X)
traverse(ast, {
FunctionDeclaration(path) {
if (!t.isIdentifier(path.node.id, { name: 'R' })) return;
if (!path.node.async) return;
path.traverse({
CallExpression(inner) {
const callee = inner.node.callee;
if (!t.isIdentifier(callee, { name: 'C' })) return;
const arg0 = inner.node.arguments[0];
if (!arg0 || !t.isAwaitExpression(arg0)) return;
const cc = arg0.argument;
if (!t.isCallExpression(cc)) return;
const ccallee = cc.callee;
if (!t.isMemberExpression(ccallee)) return;
if (!t.isIdentifier(ccallee.object, { name: 'WebAssembly' })) return;
if (!t.isIdentifier(ccallee.property, { name: 'compile' })) return;
const stmt = inner.findParent(p => p.isStatement());
if (!stmt) return;
// Wrap WAT→WASM in try/catch so errors don't silently freeze
const watCompileStmts = t.tryStatement(
t.blockStatement([
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('_wabt'),
t.awaitExpression(t.callExpression(t.identifier('WabtModule'), []))
)
]),
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('_mod'),
t.callExpression(
t.memberExpression(t.identifier('_wabt'), t.identifier('parseWat')),
[t.stringLiteral('inline'), t.identifier('O')]
)
)
]),
t.variableDeclaration('var', [
t.variableDeclarator(
t.identifier('X'),
t.newExpression(t.identifier('Uint8Array'), [
t.memberExpression(
t.callExpression(
t.memberExpression(t.identifier('_mod'), t.identifier('toBinary')),
[t.objectExpression([])]
),
t.identifier('buffer')
)
])
)
]),
t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('_mod'), t.identifier('destroy')),
[]
)
)
]),
t.catchClause(
t.identifier('_e'),
t.blockStatement([
t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('console'), t.identifier('error')),
[t.stringLiteral('WAT→WASM compile failed:'), t.identifier('_e')]
)
),
t.throwStatement(t.identifier('_e'))
])
)
);
stmt.insertBefore(watCompileStmts);
inner.stop();
path.stop();
}
});
}
});
}
// ─────────────────────────────────────────────────────────────────────────────
// STEP 4 – Babel: remove dead init block and leftover w-var aliases
// ─────────────────────────────────────────────────────────────────────────────
function finalCleanup(code) {
const ast = parser.parse(code, { sourceType: 'script', errorRecovery: false });
traverse(ast, {
Program(p) {
const body = p.node.body;
for (let i = 0; i < body.length; i++) {
const stmt = body[i];
if (t.isExpressionStatement(stmt)) {
const expr = stmt.expression;
if (t.isCallExpression(expr) && t.isFunctionExpression(expr.callee) &&
expr.arguments.some(a => t.isIdentifier(a) && a.name === 'z')) {
body.splice(i, 1); i--;
}
}
}
},
// remove dead `const XX = Hj;` aliases
VariableDeclarator(p) {
if (!p.node.id || !p.node.init) return;
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) &&
p.node.init.name === 'Hj') {
const parent = p.parentPath;
if (parent.node.declarations.length === 1)
parent.parentPath.remove();
else
p.remove();
}
},
});
// ── Fix swap bugs: OXC folds temp vars incorrectly, turning `tmp=a;a=b;b=tmp`
// into `a=b,b=a` (a no-op). Detect and rewrite to `[a,b]=[b,a]`.
traverse(ast, {
SequenceExpression(p) {
const exprs = p.node.expressions;
if (exprs.length !== 2) return;
const a1 = exprs[0], a2 = exprs[1];
if (!t.isAssignmentExpression(a1, { operator: '=' }) ||
!t.isAssignmentExpression(a2, { operator: '=' })) return;
if (!t.isIdentifier(a1.left) || !t.isIdentifier(a2.left)) return;
if (!t.isIdentifier(a1.right) || !t.isIdentifier(a2.right)) return;
const a = a1.left.name, b1 = a1.right.name;
const c = a2.left.name, b2 = a2.right.name;
if (a === b2 && b1 === c && a !== b1) {
p.replaceWith(
t.assignmentExpression('=',
t.arrayPattern([t.identifier(a), t.identifier(b1)]),
t.arrayExpression([t.identifier(b1), t.identifier(a)])
)
);
}
}
});
// ── Fix WASM: extract b64 from AST, convert to WAT, patch AST ──
let b64 = null;
traverse(ast, {
VariableDeclarator(p) {
if (b64) return;
if (t.isIdentifier(p.node.id, { name: 'O' }) &&
t.isStringLiteral(p.node.init) &&
p.node.init.value.startsWith('AGFzbQE')) {
b64 = p.node.init.value;
}
}
});
if (b64) {
const wat = wasmB64toWat(b64);
fixWasmInAst(ast, b64, wat);
}
let out = generate(ast, { compact: false, comments: false }).code;
// squash any dead-comment stubs from earlier passes
out = out.replace(/\/\*\s*\[.*?removed.*?\]\s*\*\/\s*/g, '');
out = out.replace(/\n{3,}/g, '\n\n');
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────────────────────────────────────────
console.log('=== paper3d deobfuscator (1-shot) ===\n');
const raw = fs.readFileSync(INPUT, 'utf8');
console.log('[1] Input: %s (%d bytes)', INPUT, raw.length);
// Step 1
const strings = extractStringArray(raw);
console.log('[2] String array: %d entries', strings.length);
// Step 2
const babelResult = resolveStringLookups(raw, strings);
fs.writeFileSync(TMP1, babelResult.code);
console.log('[3] Babel: %d lookups resolved, %d w-var aliases found',
babelResult.replaced, babelResult.wVarCount);
// Step 3
const oxcCode = oxcDeob(TMP1, TMP2);
console.log('[4] OXC deob: %d bytes', oxcCode.length);
// Step 4
const final = finalCleanup(oxcCode);
fs.writeFileSync(OUTPUT, final);
console.log('[5] Cleanup → %s (%d bytes)', OUTPUT, final.length);
// Tidy up temp files
fs.rmSync(TMP1); fs.rmSync(TMP2);
console.log('\nDone.');
#!/usr/bin/env node
/**
* Fetch all source material from paperio.site needed for the deobfuscation project.
*
* Downloads:
* js_files/ — paper3d.js, app.js, adinAds.js (obfuscated originals)
* html/assets/ — config.json, languages.json, skins.json, images/logo.png
* html/res/ — 3D models (.gltf + .bin) and textures (font1.png, scull.png)
*
* Skips any file already present (use --force to re-download).
*
* Usage: node fetch_models.js [--force]
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const BASE_3D = 'https://paperio.site/3d/';
const BASE_RES = 'https://paperio.site/3d/res/';
const ROOT = __dirname;
const FORCE = process.argv.includes('--force');
// ── What to fetch ────────────────────────────────────────────────────────────
const FETCH = [
// ── obfuscated JS sources ──
{ url: BASE_3D + 'paper3d.js', dest: 'js_files/paper3d.js', label: 'game engine (obfuscated)' },
{ url: BASE_3D + 'app.js', dest: 'js_files/app.js', label: 'app UI wrapper' },
{ url: BASE_3D + 'adinAds.js', dest: 'js_files/adinAds.js', label: 'ad integration (unused)' },
// ── config & assets ──
{ url: BASE_3D + 'config.json', dest: 'html/config.json', label: 'game config' },
{ url: BASE_3D + 'languages.json', dest: 'html/assets/languages.json', label: 'translations' },
{ url: BASE_3D + 'skins.json', dest: 'html/assets/skins.json', label: 'skin data' },
{ url: BASE_3D + 'logo.png', dest: 'html/assets/images/logo.png', label: 'logo' },
];
// ── 3D models (from config.json, plus extras) ──
const MODELS = [
'0-cube', '1-cruash', '3-duck-eq', '6-star-eq',
'7-cupcake-eq', '8-heart-eq', '12-lightbulb-eq',
// extra maps / arenas
'arena', 'beach', 'candy', 'classic', 'desert', 'forest', 'volcano', 'winter',
];
const RES_FILES = ['font1.png', 'scull.png'];
for (const m of MODELS) {
FETCH.push({ url: BASE_RES + m + '.gltf', dest: 'html/res/' + m + '.gltf', label: 'model ' + m });
FETCH.push({ url: BASE_RES + m + '.bin', dest: 'html/res/' + m + '.bin', label: 'model ' + m });
}
for (const f of RES_FILES) {
FETCH.push({ url: BASE_RES + f, dest: 'html/res/' + f, label: 'texture ' + f });
}
// ── Download logic ──────────────────────────────────────────────────────────
function doFetch(url, dest, label) {
return new Promise((resolve) => {
const file = path.join(ROOT, dest);
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!FORCE && fs.existsSync(file) && fs.statSync(file).size > 0) {
console.log(` have ${dest} (${label})`);
return resolve({ ok: true, cached: true });
}
https.get(url, { timeout: 30000 }, res => {
if (res.statusCode === 301 || res.statusCode === 302) {
https.get(res.headers.location, { timeout: 30000 }, r2 => {
if (r2.statusCode !== 200) {
console.log(` fail ${dest} — HTTP ${r2.statusCode}`);
return resolve({ ok: false });
}
const out = fs.createWriteStream(file);
r2.pipe(out);
out.on('finish', () => {
out.close();
console.log(` ok ${dest} (${(fs.statSync(file).size/1024).toFixed(0)} KB) ${label}`);
resolve({ ok: true, cached: false });
});
}).on('error', e => { console.log(` err ${dest}${e.message}`); resolve({ ok: false }); });
return;
}
if (res.statusCode !== 200) {
console.log(` fail ${dest} — HTTP ${res.statusCode}`);
return resolve({ ok: false });
}
const out = fs.createWriteStream(file);
res.pipe(out);
out.on('finish', () => {
out.close();
console.log(` ok ${dest} (${(fs.statSync(file).size/1024).toFixed(0)} KB) ${label}`);
resolve({ ok: true, cached: false });
});
}).on('error', e => {
console.log(` err ${dest}${e.message}`);
resolve({ ok: false });
}).on('timeout', function() { this.destroy(); resolve({ ok: false }); });
});
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
console.log(`Fetching ${FETCH.length} files from paperio.site/3d/\n`);
let downloaded = 0, cached = 0, failed = 0;
for (const f of FETCH) {
const r = await doFetch(f.url, f.dest, f.label);
if (r.ok && r.cached) cached++;
else if (r.ok) downloaded++;
else failed++;
}
console.log(`\nDone: ${downloaded} downloaded, ${cached} already present, ${failed} failed`);
if (failed > 0) console.log('Some files failed — the site may be down or URLs changed.');
}
main().catch(e => { console.error(e); process.exit(1); });
{
"target": "js_files/paper3d_deob.js",
"description": "Guard against null this.currState (crashes on init before state is set)",
"replacements": [
{
"old": "let KP = this.currState.Centr?.[\"face\"];",
"new": "let KP = this.currState?.Centr?.[\"face\"];"
},
{
"old": "this.lastState && j(this.lastState.Centr.vPos._data, this.currState.Centr.vPos._data) > 0 && (zt(this, Kq, KP, KN, \"L\", this.lastState.L, this.currState.L, this.cases_L), zt(this, Kq, KP, KN, \"R\", this.lastState.R, this.currState.R, this.cases_R));",
"new": "this.currState && this.lastState && j(this.lastState.Centr.vPos._data, this.currState.Centr.vPos._data) > 0 && (zt(this, Kq, KP, KN, \"L\", this.lastState.L, this.currState.L, this.cases_L), zt(this, Kq, KP, KN, \"R\", this.lastState.R, this.currState.R, this.cases_R));"
}
]
}
#!/usr/bin/env node
/**
* OXC `deob` bug reproduction suite.
*
* Run: node oxc_bugs.js
*
* Each test writes a small .js file, runs `deob`, and shows the diff.
* Green marked bugs are fixed in our pipeline. Red are still open.
*/
const fs = require('fs');
const { execSync } = require('child_process');
const TMP_IN = '/tmp/oxc_test_in.js';
const TMP_OUT = '/tmp/oxc_test_out.js';
let failures = 0, passed = 0;
function test(name, code, expectFn) {
fs.writeFileSync(TMP_IN, code);
let out;
try {
out = execSync(`deob ${TMP_IN} -o ${TMP_OUT} 2>/dev/null`, {
timeout: 5000, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
console.log(` ✗ CRASH: ${e.message.slice(0, 80)}`);
failures++;
return;
}
out = fs.readFileSync(TMP_OUT, 'utf8');
const inNorm = code.trim().replace(/\s+/g, ' ');
const outNorm = out.trim().replace(/\s+/g, ' ');
const changed = inNorm !== outNorm;
const label = changed ? '⚠' : ' ';
console.log(` ${label} ${name}`);
if (expectFn) {
const { ok, reason } = expectFn(code, out);
if (ok) { passed++; console.log(` ✓ ${reason || 'OK'}`); }
else { failures++; console.log(` ✗ ${reason || 'FAIL'}`); }
}
}
// ═══════════════════════════════════════════════════════════════════════════
console.log('OXC Bug Reproduction Suite\n');
console.log('Legend: ⚠ = OXC modified ✗ = semantic bug ✓ = handled\n');
// ── BUG 1: Swap temp elimination ──
test('Swap via temp var', `
function f(a, b) {
var tmp = a;
a = b;
b = tmp;
return [a, b];
}`, (inp, out) => {
if (out.includes('a = b') && out.includes('b = a') && !out.includes('tmp')) {
return { ok: true, reason: 'OXC eliminates tmp → broken swap (fixed in pipeline)' };
}
return { ok: false, reason: 'unexpected output: ' + out.slice(0, 80) };
});
// ── BUG 2: Large value inlining ──
test('Large value inlining', `
var x = "abcdefghijklmnopqrstuvwxyz";
for (var i = 0; i < x.length; i++) {
arr[i] = x.charCodeAt(i);
}`, (inp, out) => {
const count = (out.match(/abcdefghijklmnopqrstuvwxyz/g) || []).length;
if (count > 1) {
return { ok: true, reason: `OXC inlines large value ${count}x (fixed for WASM in pipeline)` };
}
return { ok: false, reason: 'string not inlined' };
});
// ── BUG 3: void 0 → undefined ──
test('void 0 → undefined', `
var x = void 0;
var y = typeof x;
var z = !0;
var w = !1;`, (inp, out) => {
if (out.includes('undefined') && out.includes('true') && out.includes('false')) {
return { ok: true, reason: 'constant folding (harmless in strict-mode ES5+)' };
}
return { ok: false, reason: 'unexpected' };
});
// ── BUG 4: Hex → decimal ──
test('Hex → decimal', `
var x = 0x0;
var y = 0xFF;
var z = 0x400;`, (inp, out) => {
if (out.includes('0') && out.includes('255') && out.includes('1024')) {
return { ok: true, reason: 'hex→dec folding (harmless)' };
}
return { ok: false, reason: 'unexpected' };
});
// ── BUG 5: Member expression simplification ──
test('Member expression simplification', `
function f(obj) {
obj["key"] = obj["value"];
return obj.key;
}`, (inp, out) => {
if (out.includes('obj.value') && out.includes('obj.key')) {
return { ok: true, reason: 'bracket→dot (correct)' };
}
return { ok: false, reason: 'unexpected' };
});
// ── BUG 6: Var merging ──
test('Adjacent var merging', `
var O = "hello";
var E = "world";
var x = "foo";
var X = new Uint8Array(x);`, (inp, out) => {
// OXC keeps them separate but inlines x into Uint8Array
if (out.includes('new Uint8Array("foo")')) {
return { ok: true, reason: 'inlines reference (fixed in pipeline)' };
}
return { ok: false, reason: 'unexpected: ' + out.slice(0, 80) };
});
// ── BUG 7: Dead alias elimination ──
test('Dead const alias elimination', `
var Hj = {};
function f() {
const XX = Hj;
return XX.foo;
}`, (inp, out) => {
// OXC removes `const XX = Hj;` and inlines
if (!out.includes('XX = Hj') && out.includes('Hj.foo')) {
return { ok: true, reason: 'dead alias removed (correct)' };
}
return { ok: false, reason: 'unexpected' };
});
// ── Cleanup ──
fs.rmSync(TMP_IN, { force: true });
try { fs.rmSync(TMP_OUT, { force: true }); } catch(e) {}
console.log(`\n───`);
console.log(`Results: ${passed} confirmed, ${failures} failures`);
console.log(`\nPipeline fixes:`);
console.log(` ✓ Swap elimination — AST pass rewrites a=b,b=a → [a,b]=[b,a]`);
console.log(` ✓ Large value inlining — WASM block replaced with WAT text`);
console.log(` ✓ Var merging + inlining — surplus declarators filtered out`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment