Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
164 lines
9.8 KiB
JavaScript
164 lines
9.8 KiB
JavaScript
// tools/local-extract/extract.py (DESIGN §13 / §15): the job table, the manifest merge of --only, and the helpers that
|
|
// summarise Materials (map/<theme>/materials.json) and GameObjects (mesh/<bundle>/prefab.json) for the official 3D
|
|
// board. The Python helpers run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was
|
|
// extracted on this machine.
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const TOOL = path.join(ROOT, 'tools/local-extract');
|
|
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
|
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
|
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
|
|
|
|
/** Run python code with `extract` imported as `e`; returns the parsed JSON it prints. */
|
|
function py(code) {
|
|
const src = `import sys, json\nfrom types import SimpleNamespace as NS\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${code}`;
|
|
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
return JSON.parse(r.stdout);
|
|
}
|
|
|
|
describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
|
|
test('job table: map themes export Materials, the board meshes are exported with their prefab', () => {
|
|
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const { jobs } = JSON.parse(r.stdout);
|
|
for (const sub of ['map/autochess', 'map/autochesssand']) assert.deepEqual(jobs.find((j) => j.sub === sub).kinds, ['Material', 'Texture2D'], sub);
|
|
const mesh = jobs.filter((j) => j.sub.startsWith('mesh/'));
|
|
assert.deepEqual(mesh.map((j) => j.bundle), [
|
|
'arts/maps/map_autochess/bkg_mesh.ab', 'arts/maps/common/meshes/s_background_common.ab',
|
|
'arts/maps/common/meshes/s_common_box_01.ab', 'arts/maps/common/meshes/s_wind_device.ab',
|
|
]);
|
|
for (const j of mesh) assert.deepEqual(j.kinds, ['GameObject', 'Material', 'Mesh', 'Texture2D']);
|
|
assert.equal(new Set(jobs.map((j) => j.sub)).size, jobs.length, 'one output dir per job');
|
|
});
|
|
|
|
test('material_info: textures by name with tiling, floats, colours, sorted keywords; unresolvable refs → null', () => {
|
|
const out = py(`
|
|
class P:
|
|
def __init__(self, obj=None, pid=1, err=False): self.m_PathID, self._o, self._e = pid, obj, err
|
|
def read(self):
|
|
if self._e: raise FileNotFoundError('cab-x not found')
|
|
return self._o
|
|
tex = lambda n: NS(m_Name=n)
|
|
v2 = lambda x, y: NS(x=x, y=y)
|
|
mat = NS(m_Name='MT', m_Shader=P(err=True), m_ValidKeywords=['_NORMALMAP', 'HG_LIGHTMAP'],
|
|
m_SavedProperties=NS(
|
|
m_TexEnvs=[('_MainTex', NS(m_Texture=P(tex('TX_D')), m_Scale=v2(2, 1), m_Offset=v2(0, -0.0))),
|
|
('_BumpMap', NS(m_Texture=P(pid=0), m_Scale=v2(1, 1), m_Offset=v2(0, 0))),
|
|
('_EmissionMap', NS(m_Texture=P(err=True), m_Scale=v2(1, 1), m_Offset=v2(0, 0)))],
|
|
m_Floats=[('_Cutoff', 0.40799999237060547), ('_Bad', float('nan'))],
|
|
m_Colors=[('_EmissionColor', NS(r=0.559, g=0.559, b=0.559, a=1))]))
|
|
bg = NS(m_Name='BG', m_Shader=P(NS(m_ParsedForm=NS(m_Name='Torappu/Unlit/Texture'))), m_ShaderKeywords='B A',
|
|
m_SavedProperties=NS(m_TexEnvs=[], m_Floats=[], m_Colors=[]))
|
|
print(json.dumps([e.material_info(mat), e.material_info(bg)]))`);
|
|
assert.deepEqual(out[0], {
|
|
shader: null, keywords: ['HG_LIGHTMAP', '_NORMALMAP'],
|
|
textures: { _MainTex: { texture: 'TX_D', scale: [2, 1], offset: [0, 0] } },
|
|
floats: { _Bad: 0, _Cutoff: 0.408 }, colors: { _EmissionColor: [0.559, 0.559, 0.559, 1] },
|
|
});
|
|
assert.deepEqual(out[1], { shader: 'Torappu/Unlit/Texture', keywords: ['A', 'B'], textures: {}, floats: {}, colors: {} });
|
|
});
|
|
|
|
test('dep_bundles: the shader bundles load beside jobs that export Materials only', () => {
|
|
const out = py(`
|
|
import tempfile, pathlib
|
|
d = pathlib.Path(tempfile.mkdtemp())
|
|
(d / 'shaders').mkdir()
|
|
for n in ('standarddirectional.ab', 'other.ab', 'notes.txt'): (d / 'shaders' / n).write_bytes(b'')
|
|
(d / 'shaders' / 'sub.ab').mkdir()
|
|
rel = lambda ps: [p.relative_to(d).as_posix() for p in ps]
|
|
print(json.dumps({'mat': rel(e.dep_bundles(d, {'Texture2D', 'Material'})), 'sprite': rel(e.dep_bundles(d, {'Sprite'})),
|
|
'none': rel(e.dep_bundles(d / 'missing', {'Material'}))}))`);
|
|
assert.deepEqual(out, { mat: ['shaders/other.ab', 'shaders/standarddirectional.ab'], sprite: [], none: [] });
|
|
});
|
|
|
|
test('prefab_node: local transform, parent, mesh and materials; no -0.0 in the output', () => {
|
|
const out = py(`
|
|
class P:
|
|
def __init__(self, obj=None, pid=1, err=False): self.m_PathID, self._o, self._e = pid, obj, err
|
|
def read(self):
|
|
if self._e: raise FileNotFoundError('external')
|
|
return self._o
|
|
class Transform:
|
|
def __init__(self, **k): self.__dict__.update(k)
|
|
class MeshFilter:
|
|
def __init__(self, **k): self.__dict__.update(k)
|
|
class MeshRenderer:
|
|
def __init__(self, **k): self.__dict__.update(k)
|
|
v3 = lambda x, y, z: NS(x=x, y=y, z=z)
|
|
root_go = NS(m_Name='Root')
|
|
root_tr = Transform(m_LocalPosition=v3(0, 0, 0), m_LocalRotation=NS(x=0, y=0, z=0, w=1), m_LocalScale=v3(1, 1, 1),
|
|
m_Father=P(pid=0), m_GameObject=P(root_go))
|
|
child_tr = Transform(m_LocalPosition=v3(-0.0, 3.5254242420196533, -8), m_LocalRotation=NS(x=0, y=-0.0, z=0, w=1),
|
|
m_LocalScale=v3(0.01, 0.01, 0.01), m_Father=P(root_tr), m_GameObject=None)
|
|
go = NS(m_Name='S_Background_shadow', m_Component=[
|
|
NS(component=P(child_tr)),
|
|
NS(component=P(MeshFilter(m_Mesh=P(NS(m_Name='S_Background_shadow'))))),
|
|
NS(component=P(MeshRenderer(m_Materials=[P(NS(m_Name='lambert1')), P(err=True)]))),
|
|
NS(component=P(err=True)),
|
|
])
|
|
print(json.dumps(e.prefab_node(go)))`);
|
|
assert.deepEqual(out, {
|
|
name: 'S_Background_shadow', parent: 'Root', pos: [0, 3.525424, -8], rot: [0, 0, 0, 1], scale: [0.01, 0.01, 0.01],
|
|
mesh: 'S_Background_shadow', materials: ['lambert1', null],
|
|
});
|
|
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
|
|
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
|
|
});
|
|
});
|
|
|
|
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
|
|
const onDisk = (p) => path.join(ROOT, 'public', p);
|
|
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/mesh'));
|
|
|
|
test('map/autochess materials.json: the official material parameters, textures present in the group', { skip: !manifest?.groups?.['map/autochess']?.materials && 'not extracted' }, () => {
|
|
const g = manifest.groups['map/autochess'];
|
|
assert.equal(g.materials.kind, 'Materials');
|
|
if (!hasFiles) return;
|
|
const mats = JSON.parse(readFileSync(onDisk(g.materials.path), 'utf8'));
|
|
assert.deepEqual(Object.keys(mats), ['MT_autochess', 'MT_autochess_Transparent', 'MT_autochess_common', 'MT_autochessi_BG']);
|
|
assert.equal(mats.MT_autochess.textures._MainTex.texture, 'TX_autochessi_D');
|
|
assert.equal(mats.MT_autochess.textures._BumpMap.texture, 'TX_autochessi_N');
|
|
assert.ok(mats.MT_autochess.keywords.includes('_EMISSION'));
|
|
assert.equal(mats.MT_autochessi_BG.shader, 'Torappu/Unlit/Texture');
|
|
// external shader references resolve through the shaders/*.ab bundles (they were all null before)
|
|
for (const k of ['MT_autochess', 'MT_autochess_Transparent', 'MT_autochess_common']) assert.equal(mats[k].shader, 'Torappu/Scene/StandardDirectional', k);
|
|
const sand = JSON.parse(readFileSync(onDisk(manifest.groups['map/autochesssand'].materials.path), 'utf8'));
|
|
for (const [k, m] of Object.entries(sand)) assert.match(m.shader || '', /^Torappu\/Scene\/(StandardRealtimeShadow|StylizedWater)$/, k);
|
|
for (const m of Object.values(mats)) for (const t of Object.values(m.textures)) assert.ok(g[t.texture], `${t.texture} extracted`);
|
|
});
|
|
|
|
test('mesh/*: OBJ files with their vertex counts and a prefab.json each', { skip: !Object.keys(manifest?.groups || {}).some((k) => k.startsWith('mesh/')) && 'not extracted' }, () => {
|
|
const groups = Object.entries(manifest.groups).filter(([k]) => k.startsWith('mesh/'));
|
|
assert.deepEqual(groups.map(([k]) => k).sort(), ['mesh/map_autochess_bkg', 'mesh/s_background_common', 'mesh/s_common_box_01', 'mesh/s_wind_device']);
|
|
for (const [name, g] of groups) {
|
|
assert.equal(g.prefab?.kind, 'Prefab', name);
|
|
const meshes = Object.values(g).filter((x) => x.kind === 'Mesh');
|
|
assert.ok(meshes.length >= 1, name);
|
|
if (!hasFiles) continue;
|
|
const prefab = JSON.parse(readFileSync(onDisk(g.prefab.path), 'utf8'));
|
|
assert.equal(prefab.length, g.prefab.count);
|
|
for (const node of prefab) {
|
|
assert.equal(node.pos.length, 3);
|
|
assert.equal(node.rot.length, 4);
|
|
if (node.mesh) assert.ok(g[node.mesh], `${name}: prefab mesh ${node.mesh} exported`);
|
|
}
|
|
for (const m of meshes) {
|
|
const obj = readFileSync(onDisk(m.path), 'utf8');
|
|
const v = obj.split('\n').filter((l) => l.startsWith('v ')).length;
|
|
assert.equal(v, m.verts, m.path);
|
|
assert.ok(obj.split('\n').some((l) => l.startsWith('f ')), `${m.path} has faces`);
|
|
}
|
|
}
|
|
const bkg = JSON.parse(readFileSync(onDisk(manifest.groups['mesh/map_autochess_bkg'].prefab.path), 'utf8'));
|
|
assert.deepEqual(bkg.map((n) => n.name), ['S_Background_common', 'S_Background_shadow']);
|
|
});
|
|
});
|